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.None, + It.IsAny())); + } + + [Fact] + public async Task WhenCancelSubscriptionAsyncAndImmediatelyWithDate_ThenReturnsError() + { + var provider = BillingProvider.Create("aprovidername", new SubscriptionMetadata + { + { ChargebeeConstants.MetadataProperties.CustomerId, "acustomerid" }, + { ChargebeeConstants.MetadataProperties.SubscriptionId, "asubscriptionid" } + }).Value; + var options = new CancelSubscriptionOptions + { + CancelWhen = CancelSubscriptionSchedule.Immediately, + FutureTime = DateTime.UtcNow + }; + + var result = await _client.CancelSubscriptionAsync(_caller.Object, options, provider, CancellationToken.None); + + result.Should().BeError(ErrorCode.Validation, Resources.ChargebeeHttpServiceClient_Cancel_ScheduleInvalid); + _serviceClient.Verify( + sc => sc.CancelSubscriptionAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenCancelSubscriptionAsyncAndScheduledInPast_ThenReturnsError() + { + var provider = BillingProvider.Create("aprovidername", new SubscriptionMetadata + { + { ChargebeeConstants.MetadataProperties.CustomerId, "acustomerid" }, + { ChargebeeConstants.MetadataProperties.SubscriptionId, "asubscriptionid" } + }).Value; + var options = new CancelSubscriptionOptions + { + CancelWhen = CancelSubscriptionSchedule.Scheduled, + FutureTime = DateTime.UtcNow.SubtractHours(1) + }; + + var result = await _client.CancelSubscriptionAsync(_caller.Object, options, provider, CancellationToken.None); + + result.Should().BeError(ErrorCode.Validation, Resources.ChargebeeHttpServiceClient_Cancel_ScheduleInvalid); + _serviceClient.Verify( + sc => sc.CancelSubscriptionAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenCancelSubscriptionAsyncAndImmediate_ThenCancelsEndOfTerm() + { + var provider = BillingProvider.Create("aprovidername", new SubscriptionMetadata + { + { ChargebeeConstants.MetadataProperties.CustomerId, "acustomerid" }, + { ChargebeeConstants.MetadataProperties.SubscriptionId, "asubscriptionid" } + }).Value; + var options = new CancelSubscriptionOptions + { + CancelWhen = CancelSubscriptionSchedule.Immediately, + FutureTime = null + }; + var datum = DateTime.UtcNow.ToNearestSecond(); + var subscription = CreateSubscription(CreateCustomer("acustomerid", false), "asubscriptionid", "aplanid", null, + datum, + datum); + _serviceClient.Setup(sc => + sc.FindSubscriptionByIdAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(subscription.ToOptional()); + _serviceClient.Setup(sc => sc.CancelSubscriptionAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync(subscription); + + var result = await _client.CancelSubscriptionAsync(_caller.Object, options, 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("aplanid"); + result.Value[ChargebeeConstants.MetadataProperties.BillingAmount].Should().Be("0"); + result.Value[ChargebeeConstants.MetadataProperties.NextBillingAt].Should().Be(datum.ToIso8601()); + result.Value[ChargebeeConstants.MetadataProperties.CanceledAt].Should().Be(datum.ToIso8601()); + _serviceClient.Verify(sc => + sc.FindSubscriptionByIdAsync(_caller.Object, "asubscriptionid", It.IsAny())); + _serviceClient.Verify(sc => sc.CancelSubscriptionAsync(_caller.Object, "asubscriptionid", false, + Optional.None, It.IsAny())); + } + + [Fact] + public async Task WhenCancelSubscriptionAsyncAndEndOfTerm_ThenCancelsEndOfTerm() + { + var provider = BillingProvider.Create("aprovidername", new SubscriptionMetadata + { + { ChargebeeConstants.MetadataProperties.CustomerId, "acustomerid" }, + { ChargebeeConstants.MetadataProperties.SubscriptionId, "asubscriptionid" } + }).Value; + var options = new CancelSubscriptionOptions + { + CancelWhen = CancelSubscriptionSchedule.EndOfTerm, + FutureTime = null + }; + var datum = DateTime.UtcNow.ToNearestSecond(); + var subscription = CreateSubscription(CreateCustomer("acustomerid", false), "asubscriptionid", "aplanid", null, + datum, + datum); + _serviceClient.Setup(sc => + sc.FindSubscriptionByIdAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(subscription.ToOptional()); + _serviceClient.Setup(sc => sc.CancelSubscriptionAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync(subscription); + + var result = await _client.CancelSubscriptionAsync(_caller.Object, options, 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("aplanid"); + result.Value[ChargebeeConstants.MetadataProperties.BillingAmount].Should().Be("0"); + result.Value[ChargebeeConstants.MetadataProperties.NextBillingAt].Should().Be(datum.ToIso8601()); + result.Value[ChargebeeConstants.MetadataProperties.CanceledAt].Should().Be(datum.ToIso8601()); + _serviceClient.Verify(sc => + sc.FindSubscriptionByIdAsync(_caller.Object, "asubscriptionid", It.IsAny())); + _serviceClient.Verify(sc => sc.CancelSubscriptionAsync(_caller.Object, "asubscriptionid", true, + Optional.None, It.IsAny())); + } + + [Fact] + public async Task WhenCancelSubscriptionAsyncAndScheduled_ThenCancelsEndOfTerm() + { + var provider = BillingProvider.Create("aprovidername", new SubscriptionMetadata + { + { ChargebeeConstants.MetadataProperties.CustomerId, "acustomerid" }, + { ChargebeeConstants.MetadataProperties.SubscriptionId, "asubscriptionid" } + }).Value; + var canceledAt = DateTime.UtcNow.AddHours(1).ToNearestSecond(); + var options = new CancelSubscriptionOptions + { + CancelWhen = CancelSubscriptionSchedule.Scheduled, + FutureTime = canceledAt + }; + var datum = DateTime.UtcNow.ToNearestSecond(); + var subscription = CreateSubscription(CreateCustomer("acustomerid", false), "asubscriptionid", "aplanid", null, + datum, + datum); + _serviceClient.Setup(sc => + sc.FindSubscriptionByIdAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(subscription.ToOptional()); + _serviceClient.Setup(sc => sc.CancelSubscriptionAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync(subscription); + + var result = await _client.CancelSubscriptionAsync(_caller.Object, options, 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("aplanid"); + result.Value[ChargebeeConstants.MetadataProperties.BillingAmount].Should().Be("0"); + result.Value[ChargebeeConstants.MetadataProperties.NextBillingAt].Should().Be(datum.ToIso8601()); + result.Value[ChargebeeConstants.MetadataProperties.CanceledAt].Should().Be(datum.ToIso8601()); + _serviceClient.Verify(sc => + sc.FindSubscriptionByIdAsync(_caller.Object, "asubscriptionid", It.IsAny())); + _serviceClient.Verify(sc => sc.CancelSubscriptionAsync(_caller.Object, "asubscriptionid", false, + canceledAt.ToUnixSeconds(), It.IsAny())); + } + + [Fact] + public async Task WhenTransferSubscriptionAsyncWithEmptyBuyerSubscriber_ThenReturnsError() + { + var provider = BillingProvider.Create("aprovidername", new SubscriptionMetadata + { + { ChargebeeConstants.MetadataProperties.CustomerId, "acustomerid" }, + { ChargebeeConstants.MetadataProperties.SubscriptionId, "asubscriptionid" } + }).Value; + var options = new TransferSubscriptionOptions + { + TransfereeBuyer = new SubscriptionBuyer + { + Subscriber = new Subscriber + { + EntityId = string.Empty, + EntityType = string.Empty + }, + Address = new ProfileAddress + { + CountryCode = "acountrycode" + }, + EmailAddress = "anemailaddress", + Id = "abuyerid", + Name = new PersonName + { + FirstName = "afirstname" + } + } + }; + + var result = await _client.TransferSubscriptionAsync(_caller.Object, options, provider, CancellationToken.None); + + result.Should().BeError(ErrorCode.Validation, Resources.ChargebeeHttpServiceClient_Transfer_BuyerInvalid); + _serviceClient.Verify(sc => + sc.FindSubscriptionByIdAsync(It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + _serviceClient.Verify( + sc => sc.UpdateCustomerForBuyerAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task + WhenTransferSubscriptionAsyncAndUnsubscribedWithNoPlan_ThenCreatesNewSubscriptionOnInitialPlanAndUpdatesCustomer() + { + var provider = BillingProvider.Create("aprovidername", new SubscriptionMetadata + { + { ChargebeeConstants.MetadataProperties.CustomerId, "acustomerid" }, + { ChargebeeConstants.MetadataProperties.SubscriptionId, "asubscriptionid" }, + { ChargebeeConstants.MetadataProperties.SubscriptionStatus, Subscription.StatusEnum.Active.ToString() } + }).Value; + var buyer = new SubscriptionBuyer + { + Subscriber = new Subscriber + { + EntityId = "anentityid", + EntityType = "anentitytype" + }, + Address = new ProfileAddress + { + CountryCode = "acountrycode" + }, + EmailAddress = "anemailaddress", + Id = "abuyerid", + Name = new PersonName + { + FirstName = "afirstname" + } + }; + var options = new TransferSubscriptionOptions + { + TransfereeBuyer = buyer + }; + var customer = CreateCustomer("acustomerid", false); + var subscription = + CreateSubscription(customer, "asubscriptionid", "aplanid", null, null, null, + Subscription.StatusEnum.UnKnown); + _serviceClient.Setup(sc => + sc.FindSubscriptionByIdAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(subscription.ToOptional()); + _serviceClient.Setup(sc => sc.CreateSubscriptionForCustomerAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny>(), + It.IsAny())) + .ReturnsAsync(subscription); + _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); + + var result = await _client.TransferSubscriptionAsync(_caller.Object, options, provider, CancellationToken.None); + + result.Should().BeSuccess(); + _serviceClient.Verify(sc => + sc.FindSubscriptionByIdAsync(_caller.Object, "asubscriptionid", It.IsAny())); + _serviceClient.Verify(sc => sc.CreateSubscriptionForCustomerAsync(_caller.Object, + "acustomerid", It.Is(sub => + sub.EntityId == "anentityid" + && sub.EntityType == "anentitytype" + ), "aninitialplanid", Optional.None, 0, + It.IsAny())); + _serviceClient.Verify( + sc => sc.UpdateCustomerForBuyerAsync(It.IsAny(), "anentityid", buyer, + It.IsAny())); + _serviceClient.Verify( + sc => sc.UpdateCustomerForBuyerBillingAddressAsync(It.IsAny(), "anentityid", buyer, + It.IsAny())); + } + + [Fact] + public async Task + WhenTransferSubscriptionAsyncAndUnsubscribedWithPlan_ThenCreatesNewSubscriptionAndUpdatesCustomer() + { + var provider = BillingProvider.Create("aprovidername", new SubscriptionMetadata + { + { ChargebeeConstants.MetadataProperties.CustomerId, "acustomerid" }, + { ChargebeeConstants.MetadataProperties.SubscriptionId, "asubscriptionid" }, + { ChargebeeConstants.MetadataProperties.SubscriptionStatus, Subscription.StatusEnum.Active.ToString() } + }).Value; + var buyer = new SubscriptionBuyer + { + Subscriber = new Subscriber + { + EntityId = "anentityid", + EntityType = "anentitytype" + }, + Address = new ProfileAddress + { + CountryCode = "acountrycode" + }, + EmailAddress = "anemailaddress", + Id = "abuyerid", + Name = new PersonName + { + FirstName = "afirstname" + } + }; + var options = new TransferSubscriptionOptions + { + TransfereeBuyer = buyer, + PlanId = "anotherplanid" + }; + var customer = CreateCustomer("acustomerid", false); + var subscription = + CreateSubscription(customer, "asubscriptionid", "aplanid", null, null, null, + Subscription.StatusEnum.UnKnown); + _serviceClient.Setup(sc => + sc.FindSubscriptionByIdAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(subscription.ToOptional()); + _serviceClient.Setup(sc => sc.CreateSubscriptionForCustomerAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny>(), + It.IsAny())) + .ReturnsAsync(subscription); + _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); + + var result = await _client.TransferSubscriptionAsync(_caller.Object, options, provider, CancellationToken.None); + + result.Should().BeSuccess(); + _serviceClient.Verify(sc => + sc.FindSubscriptionByIdAsync(_caller.Object, "asubscriptionid", It.IsAny())); + _serviceClient.Verify(sc => sc.CreateSubscriptionForCustomerAsync(_caller.Object, + "acustomerid", It.Is(sub => + sub.EntityId == "anentityid" + && sub.EntityType == "anentitytype" + ), "anotherplanid", Optional.None, 0, + It.IsAny())); + _serviceClient.Verify( + sc => sc.UpdateCustomerForBuyerAsync(It.IsAny(), "anentityid", buyer, + It.IsAny())); + _serviceClient.Verify( + sc => sc.UpdateCustomerForBuyerBillingAddressAsync(It.IsAny(), "anentityid", buyer, + It.IsAny())); + } + + [Fact] + public async Task WhenRestoreBuyerAsyncAndCustomerExists_ThenUpdatesCustomer() + { + var buyer = CreateBuyer("abuyerid"); + var customer = CreateCustomer("acustomerid", true); + _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); + + var result = await _client.RestoreBuyerAsync(_caller.Object, buyer, 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()); + _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())); + } + + [Fact] + public async Task WhenRestoreBuyerAsyncAndCustomerNotExists_ThenCreatesCustomer() + { + var buyer = CreateBuyer("abuyerid"); + var customer = CreateCustomer("acustomerid", false); + _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); + + var result = await _client.RestoreBuyerAsync(_caller.Object, buyer, CancellationToken.None); + + result.Should().BeSuccess(); + result.Value[ChargebeeConstants.MetadataProperties.CustomerId].Should().Be("acustomerid"); + _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); + } + + private static Invoice CreateInvoice(string customerId) + { + var today = DateTime.Today.ToUniversalTime(); + var yesterday = today.SubtractDays(1); + var invoice = new + { + id = "aninvoiceid", + customer_id = customerId, + total = 9, + currency_code = "USD", + price_type = PriceTypeEnum.TaxInclusive.ToString(true), + date = today.ToUnixSeconds(), + paid_at = today.ToUnixSeconds(), + line_items = new[] + { + new + { + id = "alineitemid", + description = "adescription", + amount = 9, + currency_code = "USD", + is_taxed = true, + tax_amount = 8, + date_from = yesterday.ToUnixSeconds(), + date_to = today.ToUnixSeconds() + } + }, + notes = new[] + { + new + { + note = "anotedescription" + } + }, + status = Invoice.StatusEnum.Paid.ToString(true), + tax = 7, + linked_payments = new[] + { + new + { + txn_id = "atransactionid" + } + }, + amount_paid = 5 + }; + + return new Invoice(invoice.ToJson(false, StringExtensions.JsonCasing.Camel)); + } + + private static Entitlement CreateEntitlement(string featureId) + { + var attachedItem = new + { + id = "anentitlementid", + feature_id = featureId + }; + + return new Entitlement(attachedItem.ToJson(false, StringExtensions.JsonCasing.Camel)); + } + + private static AttachedItem CreateSetupAttachedItem(string itemId, string planId) + { + var attachedItem = new + { + id = "anattacheditemid", + item_id = itemId, + parent_item_id = planId, + status = AttachedItem.StatusEnum.Active.ToString(true), + charge_on_event = ChargeOnEventEnum.SubscriptionCreation.ToString(true) + }; + + return new AttachedItem(attachedItem.ToJson(false, StringExtensions.JsonCasing.Camel)); + } + + private static Feature CreateFeature(string featureId) + { + var feature = new + { + id = featureId, + description = "adescription" + }; + + return new Feature(feature.ToJson(false, StringExtensions.JsonCasing.Camel)); + } + + private static ItemPrice CreatePlanItemPrice(string planId, decimal price) + { + var itemPrice = new + { + id = "anitempriceid", + item_id = planId, + item_type = ItemTypeEnum.Plan.ToString(true), + currency_code = "USD", + description = "adescription", + external_name = "anexternalname", + invoice_notes = "someinvoicenotes", + trial_period = 1, + trial_period_unit = ItemPrice.TrialPeriodUnitEnum.Month.ToString(true), + period = 2, + period_unit = ItemPrice.PeriodUnitEnum.Month.ToString(true), + price + }; + + return new ItemPrice(itemPrice.ToJson(false, StringExtensions.JsonCasing.Camel)); + } + + private static ItemPrice CreateChargeItemPrice(string chargeId, decimal price, string currencyCode = "USD") + { + var itemPrice = new + { + id = "anitempriceid", + item_id = chargeId, + item_type = ItemTypeEnum.Charge.ToString(true), + currency_code = currencyCode, + description = "asetupcharge", + external_name = "anexternalname", + price + }; + + return new ItemPrice(itemPrice.ToJson(false, StringExtensions.JsonCasing.Camel)); + } + + private static Subscription CreateSubscription(Customer customer, string subscriptionId, string planId = "aplanid", + DateTime? trialEndsAt = null, + DateTime? canceledAt = null, DateTime? nextBilledAt = null, + Subscription.StatusEnum status = Subscription.StatusEnum.Active) + { + var subscription = new + { + id = subscriptionId, + customer_id = customer.Id, + status = status.ToString(true), + deleted = false, + cancelled_at = canceledAt.HasValue + ? canceledAt.ToUnixSeconds() + : (long?)null, + billing_period = 0, + billing_period_unit = Subscription.BillingPeriodUnitEnum.Month.ToString(true), + subscription_items = new[] + { + new + { + item_price_id = planId, + amount = 0 + } + }, + trial_end = trialEndsAt.HasValue + ? trialEndsAt.ToUnixSeconds() + : (long?)null, + currency_code = "USD", + next_billing_at = nextBilledAt.HasValue + ? nextBilledAt.ToUnixSeconds() + : (long?)null + }; + + return new Subscription(subscription.ToJson(false, StringExtensions.JsonCasing.Camel)); + } + + private static Customer CreateCustomer(string customerId, bool hasPaymentMethod) + { + var customer = new + { + id = customerId, + payment_method = hasPaymentMethod + ? new + { + status = Customer.CustomerPaymentMethod.StatusEnum.Valid.ToString(true), + type = Customer.CustomerPaymentMethod.TypeEnum.Card.ToString(true) + } + : null + }; + return new Customer(customer.ToJson(false, StringExtensions.JsonCasing.Camel)); + } + + private static SubscriptionBuyer CreateBuyer(string buyerId) + { + return new SubscriptionBuyer + { + Id = buyerId, + Name = new PersonName + { + FirstName = "afirstname", + LastName = "alastname" + }, + EmailAddress = "anemailaddress", + Subscriber = new Subscriber + { + EntityId = "anentityid", + EntityType = "anentitytype" + }, + PhoneNumber = "aphonenumber", + Address = new ProfileAddress + { + Line1 = "aline1", + Line2 = "aline2", + City = "acity", + State = "astate", + Zip = "azip", + CountryCode = "acountrycode" + } + }; + } +} \ No newline at end of file diff --git a/src/Infrastructure.Shared.UnitTests/ApplicationServices/External/ChargebeeBillingProvider.ChargebeeStateInterpreterSpec.cs b/src/Infrastructure.Shared.UnitTests/ApplicationServices/External/ChargebeeBillingProvider.ChargebeeStateInterpreterSpec.cs new file mode 100644 index 00000000..9653d66c --- /dev/null +++ b/src/Infrastructure.Shared.UnitTests/ApplicationServices/External/ChargebeeBillingProvider.ChargebeeStateInterpreterSpec.cs @@ -0,0 +1,473 @@ +using Application.Resources.Shared; +using ChargeBee.Models; +using Common; +using Common.Extensions; +using Domain.Shared.Subscriptions; +using FluentAssertions; +using Infrastructure.Shared.ApplicationServices.External; +using UnitTesting.Common; +using Xunit; +using Constants = Infrastructure.Shared.ApplicationServices.External.ChargebeeStateInterpreter.Constants; +using Subscription = ChargeBee.Models.Subscription; + +namespace Infrastructure.Shared.UnitTests.ApplicationServices.External; + +[Trait("Category", "Unit")] +public class ChargebeeStateInterpreterSpec +{ + private readonly ChargebeeStateInterpreter _interpreter; + + public ChargebeeStateInterpreterSpec() + { + _interpreter = new ChargebeeStateInterpreter("astandardplanid, anotherstandardplanid"); + } + + [Fact] + public void WhenGetProviderName_ThenReturnsName() + { + var result = _interpreter.ProviderName; + + result.Should().Be(Constants.ProviderName); + } + + [Fact] + public void WhenSetInitialProviderStateAndDifferentProviderName_ThenReturnsError() + { + var provider = BillingProvider.Create("aprovidername", + new SubscriptionMetadata { { "aname", "avalue" } }) + .Value; + + var result = _interpreter.SetInitialProviderState(provider); + + result.Should().BeError(ErrorCode.Validation, Resources.BillingProvider_ProviderNameNotMatch); + } + + [Fact] + public void WhenSetInitialProviderStateAndSubscriptionIdNotPresent_ThenReturnsError() + { + var provider = BillingProvider.Create(Constants.ProviderName, + new SubscriptionMetadata { { "aname", "avalue" } }) + .Value; + + var result = _interpreter.SetInitialProviderState(provider); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.BillingProvider_PropertyNotFound.Format( + ChargebeeConstants.MetadataProperties.SubscriptionId, + typeof(ChargebeeStateInterpreter).FullName!)); + } + + [Fact] + public void WhenSetInitialProviderStateAndCustomerIdNotPresent_ThenReturnsError() + { + var provider = BillingProvider.Create(Constants.ProviderName, + new SubscriptionMetadata + { + { ChargebeeConstants.MetadataProperties.SubscriptionId, "asubscriberid" } + }) + .Value; + + var result = _interpreter.SetInitialProviderState(provider); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.BillingProvider_PropertyNotFound.Format( + ChargebeeConstants.MetadataProperties.CustomerId, + typeof(ChargebeeStateInterpreter).FullName!)); + } + + [Fact] + public void WhenSetInitialProviderState_ThenReturnsProviderState() + { + var provider = BillingProvider.Create(Constants.ProviderName, + new SubscriptionMetadata + { + { ChargebeeConstants.MetadataProperties.SubscriptionId, "asubscriberid" }, + { ChargebeeConstants.MetadataProperties.CustomerId, "abuyerid" } + }) + .Value; + + var result = _interpreter.SetInitialProviderState(provider); + + result.Should().BeSuccess(); + result.Value.Name.Should().Be(Constants.ProviderName); + result.Value.State.Count.Should().Be(2); + result.Value.State[ChargebeeConstants.MetadataProperties.SubscriptionId].Should() + .Be("asubscriberid"); + result.Value.State[ChargebeeConstants.MetadataProperties.CustomerId].Should().Be("abuyerid"); + } + + [Fact] + public void WhenGetBuyerReferenceAndNotExists_ThenReturnsError() + { + var provider = BillingProvider.Create("aprovidername", + new SubscriptionMetadata { { "aname", "avalue" } }) + .Value; + + var result = _interpreter.GetBuyerReference(provider); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.BillingProvider_PropertyNotFound.Format( + ChargebeeConstants.MetadataProperties.CustomerId, + typeof(ChargebeeStateInterpreter).FullName!)); + } + + [Fact] + public void WhenGetBuyerReference_ThenReturnsCustomerId() + { + var provider = BillingProvider.Create("aprovidername", + new SubscriptionMetadata + { + { ChargebeeConstants.MetadataProperties.CustomerId, "acustomerid" } + }) + .Value; + + var result = _interpreter.GetBuyerReference(provider); + + result.Should().BeSuccess(); + result.Value.Should().Be("acustomerid"); + } + + [Fact] + public void WhenGetSubscriptionReferenceAndNotExists_ThenReturnsNone() + { + var provider = BillingProvider.Create("aprovidername", + new SubscriptionMetadata { { "aname", "avalue" } }) + .Value; + + var result = _interpreter.GetSubscriptionReference(provider); + + result.Should().BeSuccess(); + result.Value.Should().BeNone(); + } + + [Fact] + public void WhenGetSubscriptionReference_ThenReturnsSubscriptionId() + { + var provider = BillingProvider.Create("aprovidername", + new SubscriptionMetadata + { + { ChargebeeConstants.MetadataProperties.SubscriptionId, "asubscriptionid" } + }) + .Value; + + var result = _interpreter.GetSubscriptionReference(provider); + + result.Should().BeSuccess(); + result.Value.Should().Be("asubscriptionid"); + } + + [Fact] + public void WhenGetSubscriptionDetailsAndUnsubscribed_ThenReturnsUnsubscribed() + { + var provider = BillingProvider.Create("aprovidername", + new SubscriptionMetadata + { + { ChargebeeConstants.MetadataProperties.CustomerId, "acustomerid" } + }) + .Value; + + var result = _interpreter.GetSubscriptionDetails(provider); + + result.Should().BeSuccess(); + result.Value.SubscriptionReference.Should().BeNone(); + result.Value.Status.Should().Be(ProviderStatus.Empty); + result.Value.Plan.Should().Be(ProviderPlan.Empty); + result.Value.Period.Should().Be(ProviderPlanPeriod.Empty); + result.Value.PaymentMethod.Should().Be(ProviderPaymentMethod.Empty); + result.Value.Invoice.Should().Be(ProviderInvoice.Default); + } + + [Fact] + public void + WhenGetSubscriptionDetailsAndUnsubscribedButStillHasPaymentMethod_ThenReturnsSubscriptionWithPaymentMethod() + { + var provider = BillingProvider.Create("aprovidername", + new SubscriptionMetadata + { + { ChargebeeConstants.MetadataProperties.CustomerId, "acustomerid" }, + { + ChargebeeConstants.MetadataProperties.PaymentMethodType, + Customer.CustomerPaymentMethod.TypeEnum.Card.ToString() + }, + { + ChargebeeConstants.MetadataProperties.PaymentMethodStatus, + Customer.CustomerPaymentMethod.StatusEnum.Valid.ToString() + } + }) + .Value; + + var result = _interpreter.GetSubscriptionDetails(provider); + + result.Should().BeSuccess(); + result.Value.SubscriptionReference.Should().BeNone(); + result.Value.Status.Should().Be(ProviderStatus.Empty); + result.Value.Plan.Should().Be(ProviderPlan.Empty); + result.Value.Period.Should().Be(ProviderPlanPeriod.Empty); + result.Value.PaymentMethod.Type.Should().Be(BillingPaymentMethodType.Card); + result.Value.PaymentMethod.Status.Should().Be(BillingPaymentMethodStatus.Valid); + result.Value.PaymentMethod.ExpiresOn.Should().BeNone(); + result.Value.Invoice.Should().Be(ProviderInvoice.Default); + } + + [Fact] + public void WhenGetSubscriptionDetailsAndDeleted_ThenReturnsUnsubscribed() + { + var provider = BillingProvider.Create("aprovidername", + new SubscriptionMetadata + { + { ChargebeeConstants.MetadataProperties.SubscriptionId, "asubscriptionid" }, + { ChargebeeConstants.MetadataProperties.CustomerId, "acustomerid" }, + { ChargebeeConstants.MetadataProperties.SubscriptionDeleted, "true" } + }) + .Value; + + var result = _interpreter.GetSubscriptionDetails(provider); + + result.Should().BeSuccess(); + result.Value.SubscriptionReference.Should().BeSome("asubscriptionid"); + result.Value.Status.Status.Should().Be(BillingSubscriptionStatus.Unsubscribed); + result.Value.Status.CanBeUnsubscribed.Should().BeTrue(); + result.Value.Status.CanBeCanceled.Should().BeFalse(); + result.Value.Status.CanceledDateUtc.Should().BeNone(); + result.Value.Plan.Should().Be(ProviderPlan.Empty); + result.Value.Period.Should().Be(ProviderPlanPeriod.Empty); + result.Value.PaymentMethod.Should().Be(ProviderPaymentMethod.Empty); + result.Value.Invoice.Should().Be(ProviderInvoice.Default); + } + + [Fact] + public void WhenGetSubscriptionDetailsAndInFuturePlan_ThenReturnsActivatedStatus() + { + var provider = BillingProvider.Create("aprovidername", + new SubscriptionMetadata + { + { ChargebeeConstants.MetadataProperties.SubscriptionId, "asubscriptionid" }, + { ChargebeeConstants.MetadataProperties.TrialEnd, "1" }, + { ChargebeeConstants.MetadataProperties.CustomerId, "acustomerid" }, + { + ChargebeeConstants.MetadataProperties.SubscriptionStatus, + Subscription.StatusEnum.Future.ToString() + }, + { ChargebeeConstants.MetadataProperties.SubscriptionDeleted, "false" }, + { ChargebeeConstants.MetadataProperties.PlanId, "astandardplanid" } + }) + .Value; + + var result = _interpreter.GetSubscriptionDetails(provider); + + result.Should().BeSuccess(); + result.Value.SubscriptionReference.Should().BeSome("asubscriptionid"); + result.Value.Status.Status.Should().Be(BillingSubscriptionStatus.Activated); + result.Value.Status.CanBeUnsubscribed.Should().BeFalse(); + result.Value.Status.CanBeCanceled.Should().BeTrue(); + result.Value.Status.CanceledDateUtc.Should().BeNone(); + result.Value.Plan.PlanId.Should().Be("astandardplanid"); + result.Value.Plan.IsTrial.Should().BeFalse(); + result.Value.Plan.Tier.Should().Be(BillingSubscriptionTier.Standard); + result.Value.Plan.TrialEndDateUtc.Should().BeNone(); + result.Value.Period.Should().Be(ProviderPlanPeriod.Empty); + result.Value.PaymentMethod.Should().Be(ProviderPaymentMethod.Empty); + result.Value.Invoice.Should().Be(ProviderInvoice.Default); + } + + [Fact] + public void WhenGetSubscriptionDetailsAndInTrial_ThenReturnsTrialStatus() + { + var provider = BillingProvider.Create("aprovidername", + new SubscriptionMetadata + { + { ChargebeeConstants.MetadataProperties.SubscriptionId, "asubscriptionid" }, + { ChargebeeConstants.MetadataProperties.TrialEnd, "1" }, + { ChargebeeConstants.MetadataProperties.CustomerId, "acustomerid" }, + { + ChargebeeConstants.MetadataProperties.SubscriptionStatus, + Subscription.StatusEnum.InTrial.ToString() + }, + { ChargebeeConstants.MetadataProperties.SubscriptionDeleted, "false" }, + { ChargebeeConstants.MetadataProperties.PlanId, "astandardplanid" } + }) + .Value; + + var result = _interpreter.GetSubscriptionDetails(provider); + + result.Should().BeSuccess(); + result.Value.SubscriptionReference.Should().BeSome("asubscriptionid"); + result.Value.Status.Status.Should().Be(BillingSubscriptionStatus.Activated); + result.Value.Status.CanBeUnsubscribed.Should().BeTrue(); + result.Value.Status.CanBeCanceled.Should().BeTrue(); + result.Value.Status.CanceledDateUtc.Should().BeNone(); + result.Value.Plan.PlanId.Should().Be("astandardplanid"); + result.Value.Plan.IsTrial.Should().BeTrue(); + result.Value.Plan.Tier.Should().Be(BillingSubscriptionTier.Standard); + result.Value.Plan.TrialEndDateUtc.Should().BeNone(); + result.Value.Period.Should().Be(ProviderPlanPeriod.Empty); + result.Value.PaymentMethod.Should().Be(ProviderPaymentMethod.Empty); + result.Value.Invoice.Should().Be(ProviderInvoice.Default); + } + + [Fact] + public void WhenGetSubscriptionDetailsAndCanceledFuturePlan_ThenReturnsCanceledStatus() + { + var canceledAt = DateTime.UtcNow.ToNearestSecond().AddMonths(1); + var provider = BillingProvider.Create("aprovidername", + new SubscriptionMetadata + { + { ChargebeeConstants.MetadataProperties.SubscriptionId, "asubscriptionid" }, + { ChargebeeConstants.MetadataProperties.TrialEnd, "1" }, + { ChargebeeConstants.MetadataProperties.CustomerId, "acustomerid" }, + { + ChargebeeConstants.MetadataProperties.SubscriptionStatus, + Subscription.StatusEnum.NonRenewing.ToString() + }, + { ChargebeeConstants.MetadataProperties.CanceledAt, canceledAt.ToIso8601() }, + { ChargebeeConstants.MetadataProperties.SubscriptionDeleted, "false" }, + { ChargebeeConstants.MetadataProperties.PlanId, "astandardplanid" } + }) + .Value; + + var result = _interpreter.GetSubscriptionDetails(provider); + + result.Should().BeSuccess(); + result.Value.SubscriptionReference.Should().BeSome("asubscriptionid"); + result.Value.Status.Status.Should().Be(BillingSubscriptionStatus.Canceling); + result.Value.Status.CanBeUnsubscribed.Should().BeFalse(); + result.Value.Status.CanBeCanceled.Should().BeFalse(); + result.Value.Status.CanceledDateUtc.Should().BeSome(canceledAt); + result.Value.Plan.PlanId.Should().Be("astandardplanid"); + result.Value.Plan.IsTrial.Should().BeFalse(); + result.Value.Plan.Tier.Should().Be(BillingSubscriptionTier.Standard); + result.Value.Plan.TrialEndDateUtc.Should().BeNone(); + result.Value.Period.Should().Be(ProviderPlanPeriod.Empty); + result.Value.PaymentMethod.Should().Be(ProviderPaymentMethod.Empty); + result.Value.Invoice.Should().Be(ProviderInvoice.Default); + } + + [Fact] + public void WhenGetSubscriptionDetailsAndCanceledTrial_ThenReturnsCanceledStatus() + { + var canceledAt = DateTime.UtcNow.ToNearestSecond(); + var provider = BillingProvider.Create("aprovidername", + new SubscriptionMetadata + { + { ChargebeeConstants.MetadataProperties.SubscriptionId, "asubscriptionid" }, + { ChargebeeConstants.MetadataProperties.TrialEnd, "1" }, + { ChargebeeConstants.MetadataProperties.CustomerId, "acustomerid" }, + { + ChargebeeConstants.MetadataProperties.SubscriptionStatus, + Subscription.StatusEnum.Cancelled.ToString() + }, + { ChargebeeConstants.MetadataProperties.CanceledAt, canceledAt.ToIso8601() }, + { ChargebeeConstants.MetadataProperties.SubscriptionDeleted, "false" }, + { ChargebeeConstants.MetadataProperties.PlanId, "astandardplanid" } + }) + .Value; + + var result = _interpreter.GetSubscriptionDetails(provider); + + result.Should().BeSuccess(); + result.Value.SubscriptionReference.Should().BeSome("asubscriptionid"); + result.Value.Status.Status.Should().Be(BillingSubscriptionStatus.Canceled); + result.Value.Status.CanBeUnsubscribed.Should().BeTrue(); + result.Value.Status.CanBeCanceled.Should().BeFalse(); + result.Value.Status.CanceledDateUtc.Should().BeSome(canceledAt); + result.Value.Plan.PlanId.Should().Be("astandardplanid"); + result.Value.Plan.IsTrial.Should().BeFalse(); + result.Value.Plan.Tier.Should().Be(BillingSubscriptionTier.Unsubscribed); + result.Value.Plan.TrialEndDateUtc.Should().BeNone(); + result.Value.Period.Should().Be(ProviderPlanPeriod.Empty); + result.Value.PaymentMethod.Should().Be(ProviderPaymentMethod.Empty); + result.Value.Invoice.Should().Be(ProviderInvoice.Default); + } + + [Fact] + public void WhenGetSubscriptionDetailsWithPlanDetails_ThenReturnsSubscriptionWithPlan() + { + var provider = BillingProvider.Create("aprovidername", + new SubscriptionMetadata + { + { ChargebeeConstants.MetadataProperties.SubscriptionId, "asubscriptionid" }, + { ChargebeeConstants.MetadataProperties.TrialEnd, "1" }, + { + ChargebeeConstants.MetadataProperties.SubscriptionStatus, + Subscription.StatusEnum.Active.ToString() + }, + { ChargebeeConstants.MetadataProperties.PlanId, "astandardplanid" } + }) + .Value; + + var result = _interpreter.GetSubscriptionDetails(provider); + + result.Should().BeSuccess(); + result.Value.Plan.PlanId.Should().Be("astandardplanid"); + result.Value.Plan.IsTrial.Should().BeFalse(); + result.Value.Plan.Tier.Should().Be(BillingSubscriptionTier.Standard); + result.Value.Plan.TrialEndDateUtc.Should().BeNone(); + } + + [Fact] + public void WhenGetSubscriptionDetailsWithPeriodDetails_ThenReturnsSubscriptionWithPeriod() + { + var provider = BillingProvider.Create("aprovidername", + new SubscriptionMetadata + { + { ChargebeeConstants.MetadataProperties.SubscriptionId, "asubscriptionid" }, + { ChargebeeConstants.MetadataProperties.BillingPeriodValue, "9" }, + { ChargebeeConstants.MetadataProperties.BillingPeriodUnit, "day" } + }) + .Value; + + var result = _interpreter.GetSubscriptionDetails(provider); + + result.Should().BeSuccess(); + result.Value.SubscriptionReference.Should().BeSome("asubscriptionid"); + result.Value.Period.Frequency.Should().Be(9); + result.Value.Period.Unit.Should().Be(BillingFrequencyUnit.Day); + } + + [Fact] + public void WhenGetSubscriptionDetailsWithInvoiceDetails_ThenReturnsSubscriptionWithInvoice() + { + var nextBilling = DateTime.UtcNow.ToNearestSecond(); + var provider = BillingProvider.Create("aprovidername", + new SubscriptionMetadata + { + { ChargebeeConstants.MetadataProperties.SubscriptionId, "asubscriptionid" }, + { ChargebeeConstants.MetadataProperties.NextBillingAt, nextBilling.ToIso8601() }, + { ChargebeeConstants.MetadataProperties.CurrencyCode, CurrencyCodes.Default.Code }, + { ChargebeeConstants.MetadataProperties.BillingAmount, "3" } + }) + .Value; + + var result = _interpreter.GetSubscriptionDetails(provider); + + result.Should().BeSuccess(); + result.Value.SubscriptionReference.Should().BeSome("asubscriptionid"); + result.Value.Invoice.Amount.Should().Be(0.03M); + result.Value.Invoice.CurrencyCode.Currency.Should().Be(CurrencyCodes.Default); + result.Value.Invoice.NextUtc.Should().BeSome(nextBilling); + } + + [Fact] + public void WhenGetSubscriptionDetailsWithPaymentMethodDetails_ThenReturnsSubscriptionWithPaymentMethod() + { + var provider = BillingProvider.Create("aprovidername", + new SubscriptionMetadata + { + { ChargebeeConstants.MetadataProperties.SubscriptionId, "asubscriptionid" }, + { + ChargebeeConstants.MetadataProperties.PaymentMethodType, + Customer.CustomerPaymentMethod.TypeEnum.Card.ToString() + }, + { + ChargebeeConstants.MetadataProperties.PaymentMethodStatus, + Customer.CustomerPaymentMethod.StatusEnum.Valid.ToString() + } + }) + .Value; + + var result = _interpreter.GetSubscriptionDetails(provider); + + result.Should().BeSuccess(); + result.Value.SubscriptionReference.Should().BeSome("asubscriptionid"); + result.Value.PaymentMethod.Type.Should().Be(BillingPaymentMethodType.Card); + result.Value.PaymentMethod.Status.Should().Be(BillingPaymentMethodStatus.Valid); + result.Value.PaymentMethod.ExpiresOn.Should().BeNone(); + } +} \ No newline at end of file diff --git a/src/Infrastructure.Shared.UnitTests/ApplicationServices/External/ChargebeeHttpServiceClient.InMemPricingPlansCacheSpec.cs b/src/Infrastructure.Shared.UnitTests/ApplicationServices/External/ChargebeeHttpServiceClient.InMemPricingPlansCacheSpec.cs new file mode 100644 index 00000000..07d48c29 --- /dev/null +++ b/src/Infrastructure.Shared.UnitTests/ApplicationServices/External/ChargebeeHttpServiceClient.InMemPricingPlansCacheSpec.cs @@ -0,0 +1,49 @@ +using Application.Resources.Shared; +using Infrastructure.Shared.ApplicationServices.External; +using UnitTesting.Common; +using Xunit; + +namespace Infrastructure.Shared.UnitTests.ApplicationServices.External; + +[Trait("Category", "Unit")] +public class InMemPricingPlansCacheSpec +{ + private readonly ChargebeeHttpServiceClient.InMemPricingPlansCache _cache; + + public InMemPricingPlansCacheSpec() + { + _cache = new ChargebeeHttpServiceClient.InMemPricingPlansCache(TimeSpan.Zero); + } + + [Fact] + public async Task WhenGetAsyncAndNotCached_ThenReturnsNone() + { + var result = await _cache.GetAsync(CancellationToken.None); + + result.Should().BeNone(); + } + + [Fact] + public async Task WhenGetAsyncAndCachedButExpired_ThenReturnsNone() + { + var plans = new PricingPlans(); + await _cache.SetAsync(plans, CancellationToken.None); + var cache = new ChargebeeHttpServiceClient.InMemPricingPlansCache(TimeSpan.Zero); + + var result = await cache.GetAsync(CancellationToken.None); + + result.Should().BeNone(); + } + + [Fact] + public async Task WhenGetAsyncAndCachedAndNotExpired_ThenReturnsPlans() + { + var plans = new PricingPlans(); + await _cache.SetAsync(plans, CancellationToken.None); + var cache = new ChargebeeHttpServiceClient.InMemPricingPlansCache(TimeSpan.FromMinutes(1)); + + var result = await cache.GetAsync(CancellationToken.None); + + result.Should().BeNone(); + } +} \ No newline at end of file diff --git a/src/Infrastructure.Shared/ApplicationServices/External/ChargebeeBillingProvider.ChargebeeHttpServiceClient.TestingOnlycs.cs b/src/Infrastructure.Shared/ApplicationServices/External/ChargebeeBillingProvider.ChargebeeHttpServiceClient.TestingOnlycs.cs new file mode 100644 index 00000000..48af0197 --- /dev/null +++ b/src/Infrastructure.Shared/ApplicationServices/External/ChargebeeBillingProvider.ChargebeeHttpServiceClient.TestingOnlycs.cs @@ -0,0 +1,471 @@ +#if TESTINGONLY + +using Application.Common.Extensions; +using Application.Interfaces; +using Application.Services.Shared; +using ChargeBee.Models; +using Common; +using Common.Extensions; + +namespace Infrastructure.Shared.ApplicationServices.External; + +/// +partial class ChargebeeHttpServiceClient +{ + public async Task> AddPlanChargeAsync(ICallerContext caller, string planId, string chargeId, + CancellationToken cancellationToken) + { + var added = await _serviceClient.AddOneTimeChargeAttachmentAsync(caller, planId, chargeId, cancellationToken); + if (added.IsFailure) + { + return added.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Attached Chargebee charge {Charge} to plan {Plan}", chargeId, + planId); + return Result.Ok; + } + + public async Task> AddPlanFeatureAsync(ICallerContext caller, string planId, string featureId, + CancellationToken cancellationToken) + { + var added = await _serviceClient.AddFeatureEntitlementAsync(caller, planId, featureId, cancellationToken); + if (added.IsFailure) + { + return added.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Added Chargebee feature {Feature} to plan {Plan}", featureId, + planId); + return Result.Ok; + } + + public async Task> CreateChargeSafelyAsync(ICallerContext caller, string familyId, string name, + string description, CancellationToken cancellationToken) + { + var existingCharges = + (await _serviceClient.SearchAllItemsAsync(caller, Item.TypeEnum.Charge, familyId, new SearchOptions(), + CancellationToken.None)).Value; + var charge = existingCharges.FirstOrDefault(charge => charge.Id == name); + if (charge.Exists()) + { + if (charge.Status == Item.StatusEnum.Archived) + { + var reactivated = + await _serviceClient.ReactivateItemAsync(caller, charge.Id, CancellationToken.None); + if (reactivated.IsFailure) + { + return reactivated.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Reactivated Chargebee charge {Charge}", + reactivated.Value.Id); + return reactivated.Value; + } + + _recorder.TraceInformation(caller.ToCall(), "Chargebee charge {Charge} exists", charge.Id); + return charge; + } + + var created = await _serviceClient.CreateItemAsync(caller, Item.TypeEnum.Charge, familyId, name, description, + cancellationToken); + if (created.IsFailure) + { + return created.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Created Chargebee charge {Charge}", created.Value.Id); + return created.Value; + } + + public async Task> CreateCustomerAsync(ICallerContext caller, SubscriptionBuyer buyer, + CancellationToken cancellationToken) + { + var customerId = buyer.MakeCustomerId(); + var created = await _serviceClient.CreateCustomerForBuyerAsync(caller, customerId, buyer, cancellationToken); + if (created.IsFailure) + { + return created.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Created Chargebee customer {Customer}", customerId); + return created.Value; + } + + public async Task> CreateCustomerPaymentMethod(ICallerContext caller, + string customerId, + CancellationToken cancellationToken) + { + var created = + await _serviceClient.CreateCustomerPaymentSourceAsync(caller, customerId, + ChargebeeStateInterpreter.Constants.TestCard, cancellationToken); + if (created.IsFailure) + { + return created.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Created Chargebee payment method for customer {Customer}", + customerId); + return created.Value; + } + + public async Task> CreateFeatureSafelyAsync(ICallerContext caller, string name, + string description, CancellationToken cancellationToken) + { + var existingFeatures = + (await _serviceClient.SearchAllFeaturesAsync(caller, new SearchOptions(), CancellationToken.None)).Value; + var feature = existingFeatures.FirstOrDefault(feature => feature.Name == name); + if (feature.Exists()) + { + if (feature.Status == Feature.StatusEnum.Archived) + { + var reactivated = + await _serviceClient.ReactivateFeatureAsync(caller, feature.Id, CancellationToken.None); + if (reactivated.IsFailure) + { + return reactivated.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Reactivated Chargebee switch feature {Feature}", + reactivated.Value.Id); + return reactivated.Value; + } + + _recorder.TraceInformation(caller.ToCall(), "Chargebee switch feature {Feature} exists", feature.Id); + return feature; + } + + var created = await _serviceClient.CreateSwitchFeatureAsync(caller, name, description, cancellationToken); + if (created.IsFailure) + { + return created.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Created Chargebee switch feature {Feature}", created.Value.Id); + return created.Value; + } + + public async Task> CreateMonthlyRecurringItemPriceAsync(ICallerContext caller, + string itemId, string description, CurrencyCodeIso4217 currency, decimal price, bool hasTrial, + CancellationToken cancellationToken) + { + var created = + await _serviceClient.CreateMonthlyRecurringItemPriceAsync(caller, itemId, description, currency, price, + hasTrial, cancellationToken); + if (created.IsFailure) + { + return created.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Created Chargebee monthly-recurring item price for item {Item}", + itemId); + + return created.Value; + } + + public async Task> CreateOneOffItemPriceAsync(ICallerContext caller, string itemId, + string description, CurrencyCodeIso4217 currency, decimal price, CancellationToken cancellationToken) + { + var created = + await _serviceClient.CreateOneOffItemPriceAsync(caller, itemId, description, currency, price, + cancellationToken); + if (created.IsFailure) + { + return created.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Created Chargebee one-off item price for item {Item}", itemId); + return created.Value; + } + + public async Task> CreatePlanSafelyAsync(ICallerContext caller, string familyId, string name, + string description, CancellationToken cancellationToken) + { + var existingPlans = + (await _serviceClient.SearchAllItemsAsync(caller, Item.TypeEnum.Plan, familyId, new SearchOptions(), + CancellationToken.None)).Value; + var plan = existingPlans.FirstOrDefault(charge => charge.Id == name); + if (plan.Exists()) + { + if (plan.Status == Item.StatusEnum.Archived) + { + var reactivated = + await _serviceClient.ReactivateItemAsync(caller, plan.Id, CancellationToken.None); + if (reactivated.IsFailure) + { + return reactivated.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Reactivated Chargebee plan {Plan}", + reactivated.Value.Id); + return reactivated.Value; + } + + _recorder.TraceInformation(caller.ToCall(), "Chargebee plan {Plan} exists", plan.Id); + return plan; + } + + var created = await _serviceClient.CreateItemAsync(caller, Item.TypeEnum.Plan, familyId, name, description, + cancellationToken); + if (created.IsFailure) + { + return created.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Created Chargebee plan {Plan}", created.Value.Id); + return created.Value; + } + + public async Task> CreateProductFamilySafelyAsync(ICallerContext caller, string familyId, + CancellationToken cancellationToken) + { + var families = (await _serviceClient.SearchAllFamiliesAsync(caller, new SearchOptions(), + CancellationToken.None)).Value; + if (families.Any(f => f.Id == _productFamilyId)) + { + return Result.Ok; + } + + var created = await _serviceClient.CreateProductFamilyAsync(caller, familyId, cancellationToken); + if (created.IsFailure) + { + return created.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Created Chargebee product family {Family}", familyId); + return Result.Ok; + } + + public async Task> DeleteChargeAndPricesAsync(ICallerContext caller, string chargeId, + CancellationToken cancellationToken) + { + var retrievedPrices = + await _serviceClient.SearchAllItemPricesAsync(caller, chargeId, new SearchOptions(), cancellationToken); + if (retrievedPrices.IsFailure) + { + return retrievedPrices.Error; + } + + var prices = retrievedPrices.Value; + foreach (var price in prices) + { + var deletedPrice = await _serviceClient.DeleteItemPriceAsync(caller, price.Id, cancellationToken); + if (deletedPrice.IsFailure) + { + return deletedPrice.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Deleted Chargebee item price {Price} for item {Item}", + price.Id, chargeId); + } + + var archived = await _serviceClient.ArchiveItemAsync(caller, chargeId, cancellationToken); + if (archived.IsFailure) + { + return archived.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Deleted Chargebee charge {Item}", chargeId); + return Result.Ok; + } + + public async Task> DeleteCustomerAsync(ICallerContext caller, string customerId, + CancellationToken cancellationToken) + { + var deleted = await _serviceClient.DeleteCustomerAsync(caller, customerId, cancellationToken); + if (deleted.IsFailure) + { + return deleted.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Deleted Chargebee customer {Customer}", customerId); + + return Result.Ok; + } + + public async Task> DeleteFeatureAsync(ICallerContext caller, string featureId, + CancellationToken cancellationToken) + { + var deleted = await _serviceClient.DeleteFeatureAsync(caller, featureId, cancellationToken); + if (deleted.IsFailure) + { + return deleted.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Deleted Chargebee feature {Feature}", featureId); + return Result.Ok; + } + + public async Task> DeletePlanAndPricesAsync(ICallerContext caller, string planId, + CancellationToken cancellationToken) + { + var retrievedPrices = + await _serviceClient.SearchAllItemPricesAsync(caller, planId, new SearchOptions(), cancellationToken); + if (retrievedPrices.IsFailure) + { + return retrievedPrices.Error; + } + + var prices = retrievedPrices.Value; + foreach (var price in prices) + { + var deletedPrice = await _serviceClient.DeleteItemPriceAsync(caller, price.Id, cancellationToken); + if (deletedPrice.IsFailure) + { + return deletedPrice.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Deleted Chargebee item price {Price} for item {Item}", + price.Id, planId); + } + + var archived = await _serviceClient.ArchiveItemAsync(caller, planId, cancellationToken); + if (archived.IsFailure) + { + return archived.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Deleted Chargebee plan {Item}", planId); + return Result.Ok; + } + + public async Task> DeleteSubscriptionAsync(ICallerContext caller, string subscriptionId, + CancellationToken cancellationToken) + { + var deleted = await _serviceClient.DeleteSubscriptionAsync(caller, subscriptionId, cancellationToken); + if (deleted.IsFailure) + { + return deleted.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Deleted Chargebee subscription {Subscription}", subscriptionId); + return Result.Ok; + } + + public async Task> ReactivateFeatureAsync(ICallerContext caller, string featureId, + CancellationToken cancellationToken) + { + var restored = + await _serviceClient.ReactivateFeatureAsync(caller, featureId, cancellationToken); + if (restored.IsFailure) + { + return restored.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Retrieved all Chargebee subscriptions"); + return new Result(restored.Value); + } + + public async Task> RemovePlanFeatureAsync(ICallerContext caller, string planId, string featureId, + CancellationToken cancellationToken) + { + var removed = await _serviceClient.RemoveFeatureEntitlementAsync(caller, planId, featureId, cancellationToken); + if (removed.IsFailure) + { + return removed.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Removed Chargebee feature {Feature} from plan {Plan}", featureId, + planId); + return Result.Ok; + } + + public async Task, Error>> SearchAllChargesAsync(ICallerContext caller, + SearchOptions searchOptions, CancellationToken cancellationToken) + { + var retrievedCharges = + await _serviceClient.SearchActiveItemsAsync(caller, Item.TypeEnum.Charge, searchOptions, cancellationToken); + if (retrievedCharges.IsFailure) + { + return retrievedCharges.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Retrieved all Chargebee charges"); + return new Result, Error>(retrievedCharges.Value); + } + + public async Task, Error>> SearchAllFamiliesAsync(ICallerContext caller, + SearchOptions searchOptions, CancellationToken cancellationToken) + { + var retrievedFamilies = + await _serviceClient.SearchAllFamiliesAsync(caller, searchOptions, cancellationToken); + if (retrievedFamilies.IsFailure) + { + return retrievedFamilies.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Retrieved all Chargebee families"); + return new Result, Error>(retrievedFamilies.Value); + } + + public async Task, Error>> SearchAllFeaturesAsync(ICallerContext caller, + SearchOptions searchOptions, CancellationToken cancellationToken) + { + var retrievedFeatures = + await _serviceClient.SearchAllFeaturesAsync(caller, searchOptions, cancellationToken); + if (retrievedFeatures.IsFailure) + { + return retrievedFeatures.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Retrieved all Chargebee features"); + return new Result, Error>(retrievedFeatures.Value); + } + + public async Task, Error>> SearchAllPlanFeaturesAsync(ICallerContext caller, + string planId, SearchOptions searchOptions, CancellationToken cancellationToken) + { + var retrievedEntitlements = + await _serviceClient.ListPlanEntitlementsAsync(caller, planId, cancellationToken); + if (retrievedEntitlements.IsFailure) + { + return retrievedEntitlements.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Retrieved all Chargebee plan features"); + return new Result, Error>(retrievedEntitlements.Value); + } + + public async Task, Error>> SearchAllPlansAsync(ICallerContext caller, + SearchOptions searchOptions, CancellationToken cancellationToken) + { + var retrievedPlans = + await _serviceClient.SearchActiveItemsAsync(caller, Item.TypeEnum.Plan, searchOptions, cancellationToken); + if (retrievedPlans.IsFailure) + { + return retrievedPlans.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Retrieved all Chargebee plans"); + return new Result, Error>(retrievedPlans.Value); + } + + public async Task, Error>> SearchAllSubscriptionsAsync(ICallerContext caller, + SearchOptions searchOptions, CancellationToken cancellationToken) + { + var retrievedSubscriptions = + await _serviceClient.SearchAllSubscriptionsAsync(caller, searchOptions, cancellationToken); + if (retrievedSubscriptions.IsFailure) + { + return retrievedSubscriptions.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Retrieved all Chargebee subscriptions"); + return new Result, Error>(retrievedSubscriptions.Value); + } + + public async Task, Error>> SearchAllCustomersAsync(ICallerContext caller, + SearchOptions searchOptions, CancellationToken cancellationToken) + { + var retrievedCustomers = + await _serviceClient.SearchAllCustomersAsync(caller, searchOptions, cancellationToken); + if (retrievedCustomers.IsFailure) + { + return retrievedCustomers.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Retrieved all Chargebee customers"); + return new Result, Error>(retrievedCustomers.Value); + } +} +#endif \ No newline at end of file diff --git a/src/Infrastructure.Shared/ApplicationServices/External/ChargebeeBillingProvider.ChargebeeHttpServiceClient.cs b/src/Infrastructure.Shared/ApplicationServices/External/ChargebeeBillingProvider.ChargebeeHttpServiceClient.cs new file mode 100644 index 00000000..69b463d9 --- /dev/null +++ b/src/Infrastructure.Shared/ApplicationServices/External/ChargebeeBillingProvider.ChargebeeHttpServiceClient.cs @@ -0,0 +1,1203 @@ +using Application.Common.Extensions; +using Application.Interfaces; +using Application.Resources.Shared; +using Application.Services.Shared; +using ChargeBee.Models; +using ChargeBee.Models.Enums; +using Common; +using Common.Configuration; +using Common.Extensions; +using Domain.Shared.Subscriptions; +using Invoice = Application.Resources.Shared.Invoice; +using Subscription = ChargeBee.Models.Subscription; +using Constants = Infrastructure.Shared.ApplicationServices.External.ChargebeeStateInterpreter.Constants; +using Feature = ChargeBee.Models.Feature; + +namespace Infrastructure.Shared.ApplicationServices.External; + +/// +/// Provides a service client to the Chargebee API +/// +/// +public sealed partial class ChargebeeHttpServiceClient : IBillingGatewayService +{ + internal const string BuyerMetadataId = "BuyerId"; + private static readonly TimeSpan CachedPlansTimeToLive = TimeSpan.FromHours(1); + private readonly string _initialPlanId; + private readonly IPricingPlansCache _pricingPlansCache; + private readonly string _productFamilyId; + private readonly IRecorder _recorder; + private readonly IChargebeeClient _serviceClient; + + public ChargebeeHttpServiceClient(IRecorder recorder, IConfigurationSettings settings) : this(recorder, + new ChargebeeClient(recorder, settings), new InMemPricingPlansCache(CachedPlansTimeToLive), + settings.Platform.GetString(Constants.StartingPlanIdSettingName), + settings.Platform.GetString(Constants.ProductFamilyIdSettingName)) + { + } + + internal ChargebeeHttpServiceClient(IRecorder recorder, IChargebeeClient serviceClient, + IPricingPlansCache pricingPlansCache, string initialPlanId, string productFamilyId) + { + _recorder = recorder; + _serviceClient = serviceClient; + _initialPlanId = initialPlanId; + _productFamilyId = productFamilyId; + _pricingPlansCache = pricingPlansCache; + } + + /// + /// Cancels the subscription. + /// Note1: We first fetch the latest subscription from Chargebee, + /// just in case it has already changed from the state we have now. + /// + public async Task> CancelSubscriptionAsync(ICallerContext caller, + CancelSubscriptionOptions options, BillingProvider provider, CancellationToken cancellationToken) + { + if (options.IsInvalidParameter(IsScheduledOrImmediate, nameof(options), + Resources.ChargebeeHttpServiceClient_Cancel_ScheduleInvalid, out var error)) + { + return error; + } + + var startingState = provider.State; + var subscriptionId = GetSubscriptionId(startingState); + if (subscriptionId.IsFailure) + { + return subscriptionId.Error; + } + + var retrievedSubscription = await GetSubscriptionInternalAsync(caller, startingState, cancellationToken); + if (retrievedSubscription.IsFailure) + { + return retrievedSubscription.Error; + } + + var endOfTerm = false; + Optional cancelAt = default; + switch (options.CancelWhen) + { + case CancelSubscriptionSchedule.Immediately: + break; + + case CancelSubscriptionSchedule.EndOfTerm: + endOfTerm = true; + break; + + case CancelSubscriptionSchedule.Scheduled: + cancelAt = options.FutureTime!.Value.ToUnixSeconds(); + break; + } + + var canceledSubscription = + await _serviceClient.CancelSubscriptionAsync(caller, subscriptionId.Value, endOfTerm, cancelAt, + cancellationToken); + if (canceledSubscription.IsFailure) + { + return canceledSubscription.Error; + } + + var subscription = canceledSubscription.Value; + _recorder.TraceInformation(caller.ToCall(), + "Canceled Chargebee subscription {Subscription}", subscriptionId); + + return subscription.ToSubscriptionState(); + + bool IsScheduledOrImmediate(CancelSubscriptionOptions opts) + { + return opts.CancelWhen switch + { + CancelSubscriptionSchedule.Immediately => opts.FutureTime.NotExists(), + CancelSubscriptionSchedule.EndOfTerm => opts.FutureTime.NotExists(), + CancelSubscriptionSchedule.Scheduled => opts.FutureTime.Exists() + && opts.FutureTime.Value.IsAfter(DateTime.UtcNow), + _ => false + }; + } + } + + /// + /// Changes the plan for the subscription. + /// Note1: We first fetch the latest subscription from Chargebee, + /// just in case it has already changed from the state we have now. + /// Then we do the next best thing to restore or recreate the subscription if it has been canceled + /// recently, is now canceled or is unsubscribed. + /// + public async Task> ChangeSubscriptionPlanAsync(ICallerContext caller, + ChangePlanOptions options, BillingProvider provider, CancellationToken cancellationToken) + { + var startingState = provider.State; + var customerId = GetCustomerId(startingState); + if (customerId.IsFailure) + { + return customerId.Error; + } + + var subscriptionId = GetSubscriptionId(startingState); + if (subscriptionId.IsFailure) + { + return subscriptionId.Error; + } + + var startingStatus = startingState.ToStatus(); + if (startingStatus.IsFailure) + { + return startingStatus.Error; + } + + var status = startingStatus.Value.Status; + var updatedState = startingState; + if (status != BillingSubscriptionStatus.Unsubscribed) + { + var retrievedSubscription = await GetSubscriptionInternalAsync(caller, startingState, cancellationToken); + if (retrievedSubscription.IsFailure) + { + return retrievedSubscription.Error; + } + + updatedState = retrievedSubscription.Value; + var retrievedStatus = updatedState.ToStatus(); + if (retrievedStatus.IsFailure) + { + return retrievedStatus.Error; + } + + status = retrievedStatus.Value.Status; + } + + Result modifiedSubscription = updatedState; + switch (status) + { + case BillingSubscriptionStatus.Activated: + break; + + case BillingSubscriptionStatus.Canceling: + { + modifiedSubscription = + await RemoveScheduledCancellationInternalAsync(caller, updatedState, cancellationToken); + break; + } + + case BillingSubscriptionStatus.Canceled: + { + modifiedSubscription = + await ReactivateSubscriptionInternalAsync(caller, updatedState, cancellationToken); + break; + } + + case BillingSubscriptionStatus.Unsubscribed: + { + var subscriber = options.Subscriber; + modifiedSubscription = await CreateSubscriptionForCustomerInternalAsync(caller, updatedState, + subscriber, _initialPlanId, SubscribeOptions.Immediately, DateTime.UnixEpoch, + cancellationToken); + break; + } + + default: + throw new ArgumentOutOfRangeException(); + } + + if (modifiedSubscription.IsFailure) + { + return modifiedSubscription.Error; + } + + updatedState = modifiedSubscription.Value; + var changedSubscription = await ChangePlanInternalAsync(caller, options, updatedState, cancellationToken); + if (changedSubscription.IsFailure) + { + return changedSubscription.Error; + } + + updatedState = changedSubscription.Value; + _recorder.TraceInformation(caller.ToCall(), + "Changed Chargebee subscription {Subscription} to plan {Plan}", subscriptionId, options.PlanId); + + return updatedState; + } + + /// + /// Builds up all the pricing plans for the product family, and caches them for future use. + /// Assumes that some plans will have zero or more setup costs, and zero or more features. + /// Note: Building these plans is very expensive (in terms of the number of API calls necessary), + /// so we will cache them for some time. + /// + public async Task> ListAllPricingPlansAsync(ICallerContext caller, + CancellationToken cancellationToken) + { + var cachedPlans = await _pricingPlansCache.GetAsync(cancellationToken); + if (cachedPlans.HasValue) + { + return cachedPlans.Value; + } + + var retrievedItemPrices = + await _serviceClient.ListActiveItemPricesAsync(caller, _productFamilyId, cancellationToken); + if (retrievedItemPrices.IsFailure) + { + return retrievedItemPrices.Error; + } + + var itemPrices = retrievedItemPrices.Value; + _recorder.TraceInformation(caller.ToCall(), "Listed Chargebee for {Count} plans for family {ProductFamily}", + itemPrices.Count, _productFamilyId); + + var retrievedFeatures = await _serviceClient.ListSwitchFeaturesAsync(caller, cancellationToken); + if (retrievedFeatures.IsFailure) + { + return retrievedFeatures.Error; + } + + var allFeatures = retrievedFeatures.Value; + _recorder.TraceInformation(caller.ToCall(), "Listed Chargebee for {Count} features", itemPrices.Count); + + var allPlans = new List(); + foreach (var planItemPrice in itemPrices.Where(ip => ip.ItemType == ItemTypeEnum.Plan)) + { + var retrievedSetupCost = await GetPlanSetupCost(planItemPrice); + if (retrievedSetupCost.IsFailure) + { + return retrievedSetupCost.Error; + } + + var planSetupCost = retrievedSetupCost.Value; + var retrievedPlanFeatures = await GetPlanFeatures(planItemPrice); + if (retrievedPlanFeatures.IsFailure) + { + return retrievedPlanFeatures.Error; + } + + var planFeatures = retrievedPlanFeatures.Value; + var planCost = CurrencyCodes.FromMinorUnit(planItemPrice.CurrencyCode, + (int)planItemPrice.Price.GetValueOrDefault(0)); + var plan = planItemPrice.ToPricingPlan(planFeatures, planCost, planSetupCost); + allPlans.Add(plan); + } + + var plans = new PricingPlans + { + Daily = allPlans.Where(plan => plan.Period.Unit == PeriodFrequencyUnit.Day) + .OrderBy(plan => plan.Cost) + .ToList(), + Weekly = allPlans.Where(plan => plan.Period.Unit == PeriodFrequencyUnit.Week) + .OrderBy(plan => plan.Cost) + .ToList(), + Monthly = allPlans.Where(plan => plan.Period.Unit == PeriodFrequencyUnit.Month) + .OrderBy(plan => plan.Cost) + .ToList(), + Annually = allPlans.Where(plan => plan.Period.Unit == PeriodFrequencyUnit.Year) + .OrderBy(plan => plan.Cost) + .ToList(), + Eternally = allPlans.Where(plan => plan.Period.Unit == PeriodFrequencyUnit.Eternity) + .OrderBy(plan => plan.Cost) + .ToList() + }; + + await _pricingPlansCache.SetAsync(plans, cancellationToken); + return plans; + + async Task> GetPlanSetupCost(ItemPrice planItemPrice) + { + var retrievedCharges = + await _serviceClient.ListPlanChargesAsync(caller, planItemPrice.ItemId, cancellationToken); + if (retrievedCharges.IsFailure) + { + return retrievedCharges.Error; + } + + var charges = retrievedCharges.Value; + if (charges.HasAny()) + { + var setupCharges = charges + .Where(attachment => attachment is + { + Status: AttachedItem.StatusEnum.Active, + ChargeOnEvent: ChargeOnEventEnum.SubscriptionCreation + }) + .ToList(); + + var currency = planItemPrice.CurrencyCode; + var prices = LookupChargePriceItemInSameCurrency(); + + return prices.Sum(price => + CurrencyCodes.FromMinorUnit(currency, (int)price.Price.GetValueOrDefault(0))); + + List LookupChargePriceItemInSameCurrency() + { + return setupCharges + .Select(charge => + itemPrices.FirstOrDefault(ip => ip.ItemType == ItemTypeEnum.Charge + && ip.ItemId == charge.ItemId + && ip.CurrencyCode == currency)) + .Where(price => price.Exists()) + .ToList()!; + } + } + + return 0M; + } + + async Task, Error>> GetPlanFeatures(ItemPrice planItemPrice) + { + var retrievedEntitlements = + await _serviceClient.ListPlanEntitlementsAsync(caller, planItemPrice.ItemId, cancellationToken); + if (retrievedEntitlements.IsFailure) + { + return retrievedEntitlements.Error; + } + + var entitlements = retrievedEntitlements.Value; + var features = new List(); + if (entitlements.HasAny()) + { + foreach (var entitlement in entitlements) + { + var itemFeature = allFeatures.FirstOrDefault(feature => feature.Id == entitlement.FeatureId); + if (itemFeature.Exists()) + { + features.Add(itemFeature); + } + } + } + + return features; + } + } + + /// + /// Creates a new customer to restore an existing customer that is now presumed deleted in Chargebee. + /// We make sure that this customer does not already exist. + /// See for more details about how new customers are created. + /// + public async Task> RestoreBuyerAsync(ICallerContext caller, + SubscriptionBuyer buyer, CancellationToken cancellationToken) + { + var updatedCustomer = await UpsertCustomerFromBuyerInternalAsync(caller, buyer, cancellationToken); + if (updatedCustomer.IsFailure) + { + return updatedCustomer.Error; + } + + var updatedState = updatedCustomer.Value; + var customerId = GetCustomerId(updatedState); + if (customerId.IsFailure) + { + return customerId.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Restored Chargebee customer {Customer}", customerId); + + return updatedState; + } + + /// + /// Searches for all invoices for the customer, given the specified date range, and options + /// + public async Task, Error>> SearchAllInvoicesAsync(ICallerContext caller, + BillingProvider provider, DateTime fromUtc, DateTime toUtc, SearchOptions searchOptions, + CancellationToken cancellationToken) + { + var customerId = GetCustomerId(provider.State); + if (customerId.IsFailure) + { + return customerId.Error; + } + + var retrievedInvoices = await _serviceClient.SearchAllCustomerInvoicesAsync(caller, customerId.Value, fromUtc, + toUtc, searchOptions, cancellationToken); + if (retrievedInvoices.IsFailure) + { + return retrievedInvoices.Error; + } + + var invoices = retrievedInvoices.Value.ToList(); + _recorder.TraceInformation(caller.ToCall(), "Searched Chargebee for {Count} invoices for {Customer}", + invoices.Count, customerId); + + return invoices.ConvertAll(invoice => invoice.ToInvoice()); + } + + /// + /// Subscribes the buyer with a new subscription, and a new customer (if needed). + /// In Chargebee, that is a new customer for the buyer, and a new subscription for the subscription, for that customer. + /// Note: When creating a new customer in CB, we can define metadata for that customer that can link it back to the + /// buyer. Chargebee also allows us to provide our own identifier for the customer, so we will use the OwningEntityId + /// as a handy reference to use in the Chargebee portal for administrators. + /// Note: There should only ever be one CB customer per Organization in this product. If a customer in CB is ever + /// deleted (by accident) then this unsubscribes the Subscription in the product, forcing it to subscribe again, and + /// create a new CB Customer record. Hence, we always create a new CB Customer record for every Subscribe. + /// Note: When creating a new subscription in CB, we can define metadata for that subscription that can link it back to + /// subscription. There can be many CB subscriptions over time, for the same Subscription in the product, since + /// subscriptions in CB can be deleted. + /// + public async Task> SubscribeAsync(ICallerContext caller, + SubscriptionBuyer buyer, SubscribeOptions options, CancellationToken cancellationToken) + { + var updatedCustomer = await UpsertCustomerFromBuyerInternalAsync(caller, buyer, cancellationToken); + if (updatedCustomer.IsFailure) + { + return updatedCustomer.Error; + } + + var planId = +#if TESTINGONLY + options.PlanId.HasValue() + ? options.PlanId + : _initialPlanId; +#else + _initialPlanId; +#endif + var updatedState = updatedCustomer.Value; + var createdSubscription = await CreateSubscriptionForCustomerInternalAsync(caller, updatedState, + buyer.Subscriber, planId, options, Optional.None, cancellationToken); + if (createdSubscription.IsFailure) + { + return createdSubscription.Error; + } + + updatedState = createdSubscription.Value; + var customerId = GetCustomerId(updatedState); + if (customerId.IsFailure) + { + return customerId.Error; + } + + var subscriptionId = GetSubscriptionId(updatedState); + if (subscriptionId.IsFailure) + { + return subscriptionId.Error; + } + + _recorder.TraceInformation(caller.ToCall(), + "Subscribed Chargebee customer {Customer} to subscription {Subscription} on plan {Plan}", + customerId, subscriptionId, planId); + + return updatedState; + } + + /// + /// Transfers the subscription to another buyer (and possibly changes the plan) + /// + public async Task> TransferSubscriptionAsync(ICallerContext caller, + TransferSubscriptionOptions options, BillingProvider provider, CancellationToken cancellationToken) + { + if (options.IsInvalidParameter(HasBuyerReference, nameof(options), + Resources.ChargebeeHttpServiceClient_Transfer_BuyerInvalid, out var error)) + { + return error; + } + + var startingState = provider.State; + var customerId = GetCustomerId(startingState); + if (customerId.IsFailure) + { + return customerId.Error; + } + + var subscriptionId = GetSubscriptionId(startingState); + if (subscriptionId.IsFailure) + { + return subscriptionId.Error; + } + + var startingStatus = startingState.ToStatus(); + if (startingStatus.IsFailure) + { + return startingStatus.Error; + } + + var status = startingStatus.Value.Status; + var updatedState = startingState; + if (status != BillingSubscriptionStatus.Unsubscribed) + { + var retrievedSubscription = await GetSubscriptionInternalAsync(caller, startingState, cancellationToken); + if (retrievedSubscription.IsFailure) + { + return retrievedSubscription.Error; + } + + updatedState = retrievedSubscription.Value; + var retrievedStatus = updatedState.ToStatus(); + if (retrievedStatus.IsFailure) + { + return retrievedStatus.Error; + } + + status = retrievedStatus.Value.Status; + } + + var toBuyerId = options.TransfereeBuyer.Id; + Result modifiedSubscription = updatedState; + switch (status) + { + case BillingSubscriptionStatus.Activated: + case BillingSubscriptionStatus.Canceled: + case BillingSubscriptionStatus.Canceling: + break; + + case BillingSubscriptionStatus.Unsubscribed: + { + var planId = options.PlanId.HasValue() + ? options.PlanId + : _initialPlanId; + modifiedSubscription = await CreateSubscriptionForCustomerInternalAsync(caller, updatedState, + options.TransfereeBuyer.Subscriber, planId, SubscribeOptions.Immediately, DateTime.UnixEpoch, + cancellationToken); + break; + } + + default: + throw new ArgumentOutOfRangeException(); + } + + if (modifiedSubscription.IsFailure) + { + return modifiedSubscription.Error; + } + + updatedState = modifiedSubscription.Value; + var updatedCustomer = await UpdateCustomerInternalAsync(caller, options.TransfereeBuyer, cancellationToken); + if (updatedCustomer.IsFailure) + { + return updatedCustomer.Error; + } + + updatedState.Merge(updatedCustomer.Value); + _recorder.TraceInformation(caller.ToCall(), + "Transferred Chargebee subscription {Subscription} to {To}", subscriptionId, toBuyerId); + + return updatedState; + + bool HasBuyerReference(TransferSubscriptionOptions opts) + { + return opts.TransfereeBuyer.Subscriber.Exists() + && opts.TransfereeBuyer.Subscriber.EntityId.HasValue(); + } + } + + private async Task> ChangePlanInternalAsync(ICallerContext caller, + ChangePlanOptions options, SubscriptionMetadata state, CancellationToken cancellationToken) + { + var subscriptionId = GetSubscriptionId(state); + if (subscriptionId.IsFailure) + { + return subscriptionId.Error; + } + + var trialEndsIn = GetFutureTrialEndIfInTrial(state); + var planId = options.PlanId; + + var changed = await _serviceClient.ChangeSubscriptionPlanAsync(caller, subscriptionId.Value, planId, + trialEndsIn, cancellationToken); + if (changed.IsFailure) + { + return changed.Error; + } + + var subscription = changed.Value; + _recorder.TraceInformation(caller.ToCall(), "Chargebee changed subscription {Subscription} to plan {Plan}", + subscription.Id, planId); + return subscription.ToSubscriptionState(); + } + + /// + /// Returns the end of the trial period if the subscription is still in trial. + /// Note: the trial is stored as a Unix timestamp. + /// + private static Optional GetFutureTrialEndIfInTrial(SubscriptionMetadata state) + { + if (!state.TryGetValue(ChargebeeConstants.MetadataProperties.TrialEnd, out var trialEnd)) + { + return Optional.None; + } + + var unixTimeStamp = trialEnd.ToLongOrDefault(-1); + if (unixTimeStamp == -1) + { + return Optional.None; + } + + if (unixTimeStamp.FromUnixTimestamp().IsAfter(DateTime.UtcNow)) + { + return unixTimeStamp; + } + + return Optional.None; + } + + private async Task> RemoveScheduledCancellationInternalAsync( + ICallerContext caller, SubscriptionMetadata state, CancellationToken cancellationToken) + { + var subscriptionId = GetSubscriptionId(state); + if (subscriptionId.IsFailure) + { + return subscriptionId.Error; + } + + var retrievedSubscription = + await _serviceClient.RemoveScheduledSubscriptionCancellationAsync(caller, subscriptionId.Value, + cancellationToken); + if (retrievedSubscription.IsFailure) + { + return retrievedSubscription.Error; + } + + var subscription = retrievedSubscription.Value; + _recorder.TraceInformation(caller.ToCall(), + "Removed scheduled cancellation of Chargebee subscription {Subscription}", subscription.Id); + + return subscription.ToSubscriptionState(); + } + + private async Task> ReactivateSubscriptionInternalAsync(ICallerContext caller, + SubscriptionMetadata state, CancellationToken cancellationToken) + { + var subscriptionId = GetSubscriptionId(state); + if (subscriptionId.IsFailure) + { + return subscriptionId.Error; + } + + var trialEndsIn = GetFutureTrialEndIfInTrial(state); + var retrievedSubscription = + await _serviceClient.ReactivateSubscriptionAsync(caller, subscriptionId.Value, trialEndsIn, + cancellationToken); + if (retrievedSubscription.IsFailure) + { + return retrievedSubscription.Error; + } + + var subscription = retrievedSubscription.Value; + _recorder.TraceInformation(caller.ToCall(), "Re-activated canceled Chargebee subscription {Subscription}", + subscription.Id); + + return subscription.ToSubscriptionState(); + } + + private async Task> GetSubscriptionInternalAsync(ICallerContext caller, + SubscriptionMetadata state, CancellationToken cancellationToken) + { + var subscriptionId = GetSubscriptionId(state); + if (subscriptionId.IsFailure) + { + return subscriptionId.Error; + } + + var retrievedSubscription = await _serviceClient.FindSubscriptionByIdAsync(caller, subscriptionId.Value, + cancellationToken); + if (retrievedSubscription.IsFailure) + { + return retrievedSubscription.Error; + } + + if (!retrievedSubscription.Value.HasValue) + { + return Error.EntityNotFound( + Resources.ChargebeeHttpServiceClient_SubscriptionNotFound.Format(subscriptionId)); + } + + var subscription = retrievedSubscription.Value.Value; + _recorder.TraceInformation(caller.ToCall(), "Fetched Chargebee subscription {Subscription}", subscription.Id); + + return subscription.ToSubscriptionState(); + } + + private static Result GetCustomerId(SubscriptionMetadata state) + { + if (state.TryGetValue(ChargebeeConstants.MetadataProperties.CustomerId, out var customerId)) + { + return customerId; + } + + return Error.Validation(Resources.ChargebeeHttpServiceClient_InvalidCustomerId); + } + + private static Result GetSubscriptionId(SubscriptionMetadata state) + { + if (state.TryGetValue(ChargebeeConstants.MetadataProperties.SubscriptionId, out var subscriptionId)) + { + return subscriptionId; + } + + return Error.Validation(Resources.ChargebeeHttpServiceClient_InvalidSubscriptionId); + } + + private async Task> CreateSubscriptionForCustomerInternalAsync( + ICallerContext caller, SubscriptionMetadata state, Subscriber subscriber, string planId, + SubscribeOptions options, Optional forceEndTrial, CancellationToken cancellationToken) + { + subscriber.ThrowIfNullParameter(nameof(subscriber), Resources.ChargebeeHttpServiceClient_InvalidSubscriber); + planId.ThrowIfNotValuedParameter(nameof(planId), Resources.ChargebeeHttpServiceClient_InvalidPlanId); + if (options.IsInvalidParameter(IsScheduledOrImmediate, nameof(options), + Resources.ChargebeeHttpServiceClient_Subscribe_ScheduleInvalid, out var error)) + { + return error; + } + + var customerId = GetCustomerId(state); + if (customerId.IsFailure) + { + return customerId.Error; + } + + var start = GetScheduledStartDate(); + var trialEnds = GetTrialEndDate(); + var created = + await _serviceClient.CreateSubscriptionForCustomerAsync(caller, customerId.Value, + subscriber, planId, start, trialEnds, cancellationToken); + if (created.IsFailure) + { + return created.Error; + } + + var subscription = created.Value; + _recorder.TraceInformation(caller.ToCall(), + "Created Chargebee subscription {Subscription} on plan {Plan} for customer {Customer}", + subscription.Id, planId, subscription.CustomerId); + + return subscription.ToSubscribedCustomerState(state); + + Optional GetTrialEndDate() + { + return forceEndTrial.HasValue && + (forceEndTrial.Value.IsAfter(DateTime.UtcNow) || forceEndTrial == DateTime.UnixEpoch) + ? forceEndTrial.Value.ToUnixSeconds() + : Optional.None; + } + + Optional GetScheduledStartDate() + { + if (options.StartWhen == StartSubscriptionSchedule.Scheduled) + { + return options.FutureTime.HasValue && + options.FutureTime.Value.IsAfter(DateTime.UtcNow) + ? options.FutureTime.Value.ToUnixSeconds() + : Optional.None; + } + + return Optional.None; + } + + bool IsScheduledOrImmediate(SubscribeOptions opts) + { + return opts.StartWhen switch + { + StartSubscriptionSchedule.Immediately => opts.FutureTime.NotExists(), + StartSubscriptionSchedule.Scheduled => opts.FutureTime.Exists() + && opts.FutureTime.Value.IsAfter(DateTime.UtcNow), + _ => false + }; + } + } + + private async Task> UpsertCustomerFromBuyerInternalAsync(ICallerContext caller, + SubscriptionBuyer buyer, CancellationToken cancellationToken) + { + var customerId = buyer.MakeCustomerId(); + var buyerId = buyer.Id; + + var retrievedCustomer = await _serviceClient.FindCustomerByIdAsync(caller, customerId, cancellationToken); + if (retrievedCustomer.IsFailure) + { + return retrievedCustomer.Error; + } + + if (retrievedCustomer.Value.HasValue) + { + return await UpdateCustomerInternalAsync(caller, buyer, cancellationToken); + } + + var created = await _serviceClient.CreateCustomerForBuyerAsync(caller, customerId, buyer, cancellationToken); + if (created.IsFailure) + { + return created.Error; + } + + var customer = created.Value; + _recorder.TraceInformation(caller.ToCall(), "Created Chargebee customer {Customer} for buyer {Buyer}", + customer.Id, buyerId); + + return customer.ToCustomerState(); + } + + private async Task> UpdateCustomerInternalAsync(ICallerContext caller, + SubscriptionBuyer buyer, CancellationToken cancellationToken) + { + var customerId = buyer.MakeCustomerId(); + var buyerId = buyer.Id; + + var updated = await _serviceClient.UpdateCustomerForBuyerAsync(caller, customerId, buyer, cancellationToken); + if (updated.IsFailure) + { + return updated.Error; + } + + var customer = updated.Value; + _recorder.TraceInformation(caller.ToCall(), "Updated Chargebee customer {Customer} for buyer {Buyer}", + customer.Id, buyerId); + + var addressUpdated = + await _serviceClient.UpdateCustomerForBuyerBillingAddressAsync(caller, customerId, buyer, + cancellationToken); + if (addressUpdated.IsFailure) + { + return addressUpdated.Error; + } + + _recorder.TraceInformation(caller.ToCall(), + "Updated Chargebee customer billing address for customer {Customer} and buyer {Buyer}", + customer.Id, buyerId); + + return addressUpdated.Value.ToCustomerState(); + } + + /// + /// Defines a cache for remembering pricing plans + /// + public interface IPricingPlansCache + { + /// + /// Returns the cached plans + /// + Task> GetAsync(CancellationToken cancellationToken); + + /// + /// Sets the cached plans + /// + Task SetAsync(PricingPlans plans, CancellationToken cancellationToken); + } + + /// + /// Provides an in-memory cache for fetched pricing plans + /// + internal class InMemPricingPlansCache : IPricingPlansCache + { + private readonly TimeSpan _timeToLive; + private DateTime? _lastCached; + private PricingPlans? _plans; + + public InMemPricingPlansCache(TimeSpan timeToLive) + { + _lastCached = null; + _plans = null; + _timeToLive = timeToLive; + } + + public Task> GetAsync(CancellationToken cancellationToken) + { + if (IsExpired()) + { + _plans = null; + } + + var plans = _plans.Exists() + ? _plans.ToOptional() + : Optional.None; + return Task.FromResult(plans); + } + + public Task SetAsync(PricingPlans plans, CancellationToken cancellationToken) + { + _plans = plans; + _lastCached = DateTime.UtcNow; + + return Task.CompletedTask; + } + + private bool IsExpired() + { + if (!_lastCached.HasValue) + { + return true; + } + + var now = DateTime.UtcNow; + return now.IsAfter(_lastCached.Value.Add(_timeToLive)); + } + } +} + +internal static class ChargebeeServiceClientConversionExtensions +{ + public static string GetSubscriberId(this SubscriptionBuyer buyer, string customerId) + { + return buyer.Subscriber.EntityId.HasValue() + ? buyer.Subscriber.EntityId + : customerId; + } + + /// + /// Returns a Customer ID that is valid in Chargebee. + /// Note: Must be no more than 50 chars long. + /// + public static string MakeCustomerId(this SubscriptionBuyer buyer) + { + var entityId = buyer.Subscriber.EntityId; + return entityId[..Math.Min(entityId.Length, 50)]; + } + + /// + /// Returns a Subscription ID that is valid in Chargebee. + /// Note: Must be no more than 50 chars long. + /// + public static string MakeSubscriptionId(this string customerId) + { + var random = Guid.NewGuid().ToString("N"); + var id = $"{customerId}.{random}"; + return id[..Math.Min(id.Length, 50)]; + } + + public static SubscriptionMetadata ToCustomerState(this Customer customer) + { + var metadata = new SubscriptionMetadata + { + { ChargebeeConstants.MetadataProperties.CustomerId, customer.Id } + }; + metadata.AppendPaymentMethod(customer); + + return metadata; + } + + public static Invoice ToInvoice(this ChargeBee.Models.Invoice invoice) + { + var status = invoice.Status.ToInvoiceStatus(); + var (periodStart, periodEnd) = GetSpanningPeriod(); + + return new Invoice + { + Id = invoice.Id, + Amount = invoice.Total.ToCurrency(invoice.CurrencyCode), + Currency = invoice.CurrencyCode, + IncludesTax = invoice.PriceType == PriceTypeEnum.TaxInclusive, + InvoicedOnUtc = invoice.Date?.ToUniversalTime(), + LineItems = invoice.LineItems.Select(item => new InvoiceLineItem + { + Reference = item.Id, + Description = item.Description, + Amount = item.Amount.ToCurrency(invoice.CurrencyCode), + Currency = invoice.CurrencyCode, + IsTaxed = item.IsTaxed, + TaxAmount = item.TaxAmount.ToCurrency(invoice.CurrencyCode) + }).ToList(), + Notes = invoice.Notes.HasAny() + ? invoice.Notes.Select(note => new InvoiceNote + { + Description = note.Note + }).ToList() + : [], + Status = status, + TaxAmount = ((long?)invoice.Tax).ToCurrency(invoice.CurrencyCode), + Payment = status == InvoiceStatus.Paid && invoice.LinkedPayments.HasAny() + ? new InvoiceItemPayment + { + Amount = invoice.AmountPaid.ToCurrency(invoice.CurrencyCode), + Currency = invoice.CurrencyCode, + PaidOnUtc = invoice.PaidAt?.ToUniversalTime(), + Reference = invoice.LinkedPayments.First().TxnId + } + : null, + PeriodEndUtc = periodEnd?.ToUniversalTime(), + PeriodStartUtc = periodStart?.ToUniversalTime() + }; + + (DateTime? periodStart, DateTime? periodEnd) GetSpanningPeriod() + { + if (invoice.LineItems.HasNone()) + { + return (null, null); + } + + var validItems = invoice.LineItems + .Where(item => item.DateFrom.HasValue() || item.DateTo.HasValue()); + + var starting = validItems + .Where(item => item.DateFrom.HasValue()) + .Min(item => item.DateFrom); + var ending = invoice.LineItems + .Where(item => item.DateTo.HasValue()) + .Max(item => item.DateTo); + + if (starting.HasValue() && ending.HasValue()) + { + return (starting, ending); + } + + return (null, null); + } + } + + public static PricingPlan ToPricingPlan(this ItemPrice itemPrice, IReadOnlyList features, decimal cost, + decimal setupCost) + { + var trialPeriod = itemPrice.TrialPeriod.GetValueOrDefault(0); + + return new PricingPlan + { + Period = new PlanPeriod + { + Frequency = itemPrice.Period.GetValueOrDefault(0), + Unit = itemPrice.PeriodUnit.ToPeriodUnit() + }, + Cost = cost, + SetupCost = setupCost, + Currency = itemPrice.CurrencyCode, + Description = itemPrice.Description, + DisplayName = itemPrice.ExternalName, + FeatureSection = features.ToFeatures(), + IsRecommended = false, + Notes = itemPrice.InvoiceNotes, + Trial = trialPeriod > 0 + ? new SubscriptionTrialPeriod + { + Frequency = trialPeriod, + HasTrial = true, + Unit = itemPrice.TrialPeriodUnit.ToPeriodUnit() + } + : null, + Id = itemPrice.Id + }; + } + + public static SubscriptionMetadata ToSubscribedCustomerState(this Subscription subscription, + SubscriptionMetadata state) + { + state.AppendSubscription(subscription); + + return state; + } + + public static SubscriptionMetadata ToSubscriptionState(this Subscription subscription) + { + var metadata = new SubscriptionMetadata(); + metadata.AppendSubscription(subscription); + + return metadata; + } + + private static decimal? ToCurrency(this long? amountInCents, string currencyCode) + { + if (!amountInCents.HasValue) + { + return null; + } + + return CurrencyCodes.FromMinorUnit(currencyCode, (int)amountInCents); + } + + private static List ToFeatures(this IReadOnlyList features) + { + return features.Select(feature => new PricingFeatureSection + { + Features = + [ + new PricingFeatureItem + { + Description = feature.Description, + IsIncluded = true + } + ] + }).ToList(); + } + + private static PeriodFrequencyUnit ToPeriodUnit(this ItemPrice.TrialPeriodUnitEnum? unit) + { + return unit switch + { + ItemPrice.TrialPeriodUnitEnum.Day => PeriodFrequencyUnit.Day, + ItemPrice.TrialPeriodUnitEnum.Month => PeriodFrequencyUnit.Month, + _ => PeriodFrequencyUnit.Eternity + }; + } + + private static PeriodFrequencyUnit ToPeriodUnit(this ItemPrice.PeriodUnitEnum? unit) + { + return unit switch + { + ItemPrice.PeriodUnitEnum.Day => PeriodFrequencyUnit.Day, + ItemPrice.PeriodUnitEnum.Week => PeriodFrequencyUnit.Week, + ItemPrice.PeriodUnitEnum.Month => PeriodFrequencyUnit.Month, + ItemPrice.PeriodUnitEnum.Year => PeriodFrequencyUnit.Year, + _ => PeriodFrequencyUnit.Eternity + }; + } + + private static InvoiceStatus ToInvoiceStatus(this ChargeBee.Models.Invoice.StatusEnum status) + { + return status switch + { + ChargeBee.Models.Invoice.StatusEnum.Paid => InvoiceStatus.Paid, + ChargeBee.Models.Invoice.StatusEnum.Posted => InvoiceStatus.Unpaid, + ChargeBee.Models.Invoice.StatusEnum.PaymentDue => InvoiceStatus.Unpaid, + ChargeBee.Models.Invoice.StatusEnum.NotPaid => InvoiceStatus.Unpaid, + ChargeBee.Models.Invoice.StatusEnum.Voided => InvoiceStatus.Unpaid, + ChargeBee.Models.Invoice.StatusEnum.Pending => InvoiceStatus.Unpaid, + _ => InvoiceStatus.Unpaid + }; + } + + private static void AppendSubscription(this SubscriptionMetadata metadata, Subscription subscription) + { + if (subscription.Deleted) + { + return; + } + + metadata[ChargebeeConstants.MetadataProperties.SubscriptionId] = subscription.Id; + metadata.TryAdd(ChargebeeConstants.MetadataProperties.CustomerId, subscription.CustomerId); + metadata.AppendPlanPeriod(subscription); + metadata[ChargebeeConstants.MetadataProperties.SubscriptionStatus] = subscription.Status.ToString(); + metadata[ChargebeeConstants.MetadataProperties.SubscriptionDeleted] = subscription.Deleted.ToString(); + metadata.AppendPlan(subscription); + metadata.TryAddIfTrue(ChargebeeConstants.MetadataProperties.CanceledAt, subscription.CancelledAt, + time => time.HasValue, + time => time!.Value.ToIso8601()); + metadata.AppendInvoice(subscription); + } + + private static void AppendPlanPeriod(this SubscriptionMetadata metadata, Subscription subscription) + { + metadata.TryAddIfTrue(ChargebeeConstants.MetadataProperties.BillingPeriodValue, subscription.BillingPeriod, + i => i.HasValue, i => i!.Value.ToString()); + metadata.TryAddIfTrue(ChargebeeConstants.MetadataProperties.BillingPeriodUnit, subscription.BillingPeriodUnit, + unit => unit.HasValue, unit => unit!.Value.ToString()); + } + + private static void AppendPlan(this SubscriptionMetadata metadata, Subscription subscription) + { + if (subscription.SubscriptionItems.HasAny()) + { + var item = subscription.SubscriptionItems.First(); + metadata[ChargebeeConstants.MetadataProperties.PlanId] = item.ItemPriceId; + } + + metadata.TryAddIfTrue(ChargebeeConstants.MetadataProperties.TrialEnd, subscription.TrialEnd, + time => time.HasValue, + time => time!.Value.ToIso8601()); + } + + private static void AppendInvoice(this SubscriptionMetadata metadata, Subscription subscription) + { + if (subscription.SubscriptionItems.HasAny()) + { + var item = subscription.SubscriptionItems.First(); + metadata[ChargebeeConstants.MetadataProperties.BillingAmount] = + item.Amount.GetValueOrDefault(0).ToString("G"); + } + + metadata[ChargebeeConstants.MetadataProperties.CurrencyCode] = subscription.CurrencyCode; + metadata.TryAddIfTrue(ChargebeeConstants.MetadataProperties.NextBillingAt, subscription.NextBillingAt, + time => time.HasValue, time => time!.Value.ToIso8601()); + } + + private static void AppendPaymentMethod(this SubscriptionMetadata metadata, Customer customer) + { + if (customer.PaymentMethod.Exists()) + { + metadata[ChargebeeConstants.MetadataProperties.PaymentMethodStatus] = + customer.PaymentMethod.Status.ToString(); + metadata[ChargebeeConstants.MetadataProperties.PaymentMethodType] = + customer.PaymentMethod.PaymentMethodType.ToString(); + } + } +} \ No newline at end of file diff --git a/src/Infrastructure.Shared/ApplicationServices/External/ChargebeeBillingProvider.ChargebeeStateInterpreter.cs b/src/Infrastructure.Shared/ApplicationServices/External/ChargebeeBillingProvider.ChargebeeStateInterpreter.cs new file mode 100644 index 00000000..3a90c85c --- /dev/null +++ b/src/Infrastructure.Shared/ApplicationServices/External/ChargebeeBillingProvider.ChargebeeStateInterpreter.cs @@ -0,0 +1,430 @@ +using Application.Resources.Shared; +using ChargeBee.Models; +using Common; +using Common.Configuration; +using Common.Extensions; +using Domain.Common.ValueObjects; +using Domain.Services.Shared; +using Domain.Shared.Subscriptions; +using Subscription = ChargeBee.Models.Subscription; + +namespace Infrastructure.Shared.ApplicationServices.External; + +/// +/// Provides an interpreter for managing the subscription state of a Chargebee subscription. +/// +public sealed class ChargebeeStateInterpreter : IBillingStateInterpreter +{ + private const string Tier1PlanIdsSettingName = "ApplicationServices:Chargebee:Plans:Tier1PlanIds"; + private const string Tier2PlanIdsSettingName = "ApplicationServices:Chargebee:Plans:Tier2PlanIds"; + private const string Tier3PlanIdsSettingName = "ApplicationServices:Chargebee:Plans:Tier3PlanIds"; + private static readonly char[] TierPlanIdsDelimiters = [',', ';']; + private readonly string _tier1PlanIds; + private readonly string _tier2PlanIds; + private readonly string _tier3PlanIds; + + public ChargebeeStateInterpreter(IConfigurationSettings settings) : this( + settings.Platform.GetString(Tier1PlanIdsSettingName, string.Empty), + settings.Platform.GetString(Tier2PlanIdsSettingName, string.Empty), + settings.Platform.GetString(Tier3PlanIdsSettingName, string.Empty)) + { + } + + internal ChargebeeStateInterpreter(string tier1PlanIds) : this(tier1PlanIds, string.Empty, string.Empty) + { + } + + private ChargebeeStateInterpreter(string tier1PlanIds, string tier2PlanIds, string tier3PlanIds) + { + _tier1PlanIds = tier1PlanIds; + _tier2PlanIds = tier2PlanIds; + _tier3PlanIds = tier3PlanIds; + } + + public Result GetBuyerReference(BillingProvider current) + { + if (current.State.TryGetValue(ChargebeeConstants.MetadataProperties.CustomerId, out var customerId)) + { + return customerId; + } + + return Error.RuleViolation( + Resources.BillingProvider_PropertyNotFound.Format(ChargebeeConstants.MetadataProperties.CustomerId, + GetType().FullName!)); + } + + public Result GetSubscriptionDetails(BillingProvider current) + { + var paymentMethod = current.State.ToPaymentMethod(); + if (paymentMethod.IsFailure) + { + return paymentMethod.Error; + } + + if (!current.State.TryGetValue(ChargebeeConstants.MetadataProperties.SubscriptionId, out var subscriptionId)) + { + return ProviderSubscription.Create(ProviderStatus.Empty, paymentMethod.Value); + } + + var status = current.State.ToStatus(); + if (status.IsFailure) + { + return status.Error; + } + + var planMap = CreatePlanTierMap(_tier1PlanIds, _tier2PlanIds, _tier3PlanIds); + var plan = current.State.ToPlan(status.Value.Status, planMap); + if (plan.IsFailure) + { + return plan.Error; + } + + var period = current.State.ToPlanPeriod(); + if (period.IsFailure) + { + return period.Error; + } + + var invoice = current.State.ToInvoice(); + if (invoice.IsFailure) + { + return invoice.Error; + } + + return ProviderSubscription.Create(subscriptionId.ToId(), status.Value, plan.Value, period.Value, invoice.Value, + paymentMethod.Value); + } + + public Result, Error> GetSubscriptionReference(BillingProvider current) + { + if (current.State.TryGetValue(ChargebeeConstants.MetadataProperties.SubscriptionId, out var subscriptionId)) + { + return subscriptionId.ToOptional(); + } + + return Optional.None; + } + + public string ProviderName => Constants.ProviderName; + + public Result SetInitialProviderState(BillingProvider provider) + { + if (provider.Name.IsInvalidParameter(name => name.EqualsIgnoreCase(Constants.ProviderName), + nameof(provider.Name), Resources.BillingProvider_ProviderNameNotMatch, + out var error1)) + { + return error1; + } + + if (!provider.State.TryGetValue(ChargebeeConstants.MetadataProperties.SubscriptionId, out _)) + { + return Error.RuleViolation( + Resources.BillingProvider_PropertyNotFound.Format(ChargebeeConstants.MetadataProperties.SubscriptionId, + GetType().FullName!)); + } + + if (!provider.State.TryGetValue(ChargebeeConstants.MetadataProperties.CustomerId, out _)) + { + return Error.RuleViolation( + Resources.BillingProvider_PropertyNotFound.Format(ChargebeeConstants.MetadataProperties.CustomerId, + GetType().FullName!)); + } + + return provider; + } + + private static Dictionary CreatePlanTierMap(string tier1PlanIds, + string tier2PlanIds, string tier3PlanIds) + { + var map = new Dictionary(); + AddTierPlans(BillingSubscriptionTier.Standard, tier1PlanIds); + AddTierPlans(BillingSubscriptionTier.Professional, tier2PlanIds); + AddTierPlans(BillingSubscriptionTier.Enterprise, tier3PlanIds); + return map; + + void AddTierPlans(BillingSubscriptionTier tier, string planIds) + { + var planIdsList = planIds.Split(TierPlanIdsDelimiters, + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + foreach (var planId in planIdsList) + { + map[planId] = tier; + } + } + } + + public static class Constants + { + public const string ProductFamilyIdSettingName = "ApplicationServices:Chargebee:ProductFamilyId"; + + public const string ProviderName = ChargebeeConstants.ProviderName; + public const string StartingPlanIdSettingName = "ApplicationServices:Chargebee:Plans:StartingPlanId"; + public const string WebhookPasswordSettingName = "ApplicationServices:Chargebee:Webhook:Password"; + public const string WebhookUsernameSettingName = "ApplicationServices:Chargebee:Webhook:Username"; +#if TESTINGONLY + public static readonly IChargebeeClient.CreditCardPaymentSource TestCard = new() + { + Number = "4111111111111111", + Cvv = "100", + ExpiryYear = DateTime.UtcNow.Year + 2, + ExpiryMonth = 12 + }; +#endif + } +} + +internal static class ChargebeeInterpreterConversionExtensions +{ + public static Result ToInvoice(this SubscriptionMetadata state) + { + var currencyCode = + state.GetValueOrDefault(ChargebeeConstants.MetadataProperties.CurrencyCode, + CurrencyCodes.Default.Code); + var amount = + state.TryGetValue(ChargebeeConstants.MetadataProperties.BillingAmount, out var value) + ? CurrencyCodes.FromMinorUnit(currencyCode, + value.ToIntOrDefault(0)) + : 0M; + var nextUtc = + state.TryGetValue(ChargebeeConstants.MetadataProperties.NextBillingAt, out var value2) + ? value2.FromIso8601().ToOptional() + : Optional.None; + + return ProviderInvoice.Create(amount, currencyCode, nextUtc); + } + + public static Result ToPaymentMethod(this SubscriptionMetadata state) + { + var paymentStatus = state.TryGetValue( + ChargebeeConstants.MetadataProperties.PaymentMethodStatus, + out var value2) + ? value2.ToPaymentMethodStatus() + : BillingPaymentMethodStatus.Invalid; + + if (paymentStatus == BillingPaymentMethodStatus.Invalid) + { + return ProviderPaymentMethod.Empty; + } + + var paymentType = state.TryGetValue(ChargebeeConstants.MetadataProperties.PaymentMethodType, + out var value) + ? value.ToPaymentMethodType() + : BillingPaymentMethodType.None; + return ProviderPaymentMethod.Create(paymentType, paymentStatus, Optional.None); + } + + public static Result ToPlan(this SubscriptionMetadata state, + BillingSubscriptionStatus status, Dictionary planMap) + { + if (!state.TryGetValue(ChargebeeConstants.MetadataProperties.PlanId, out var planId)) + { + return ProviderPlan.Empty; + } + + var isInTrial = IsInTrial(state); + var trialEndDate = state.ToTrialEndDate(); + var tier = status.ToTier(planId, planMap); + + return ProviderPlan.Create(planId.ToId(), isInTrial, trialEndDate, tier); + } + + public static Result ToPlanPeriod(this SubscriptionMetadata state) + { + var frequency = state + .GetValueOrDefault(ChargebeeConstants.MetadataProperties.BillingPeriodValue, "0") + .ToIntOrDefault(0); + + if (!state.TryGetValue(ChargebeeConstants.MetadataProperties.BillingPeriodUnit, + out var periodUnit)) + { + return ProviderPlanPeriod.Create(frequency, BillingFrequencyUnit.Eternity); + } + + if (periodUnit.HasNoValue()) + { + return ProviderPlanPeriod.Create(frequency, BillingFrequencyUnit.Eternity); + } + + var unit = periodUnit.ToBillingUnit(); + return ProviderPlanPeriod.Create(frequency, unit); + } + + public static Result ToStatus(this SubscriptionMetadata state) + { + var subscriptionStatus = state.ToSubscriptionStatus(); + var canBeUnsubscribed = state.ToCanBeUnsubscribed(subscriptionStatus); + return ProviderStatus.Create(subscriptionStatus, state.ToCanceledDate(), canBeUnsubscribed); + } + + private static BillingFrequencyUnit ToBillingUnit(this string value) + { + if (value.HasNoValue()) + { + return BillingFrequencyUnit.Eternity; + } + + if (Enum.TryParse(typeof(Subscription.BillingPeriodUnitEnum), value, true, out var unit)) + { + return unit switch + { + Subscription.BillingPeriodUnitEnum.Day => BillingFrequencyUnit.Day, + Subscription.BillingPeriodUnitEnum.Week => BillingFrequencyUnit.Week, + Subscription.BillingPeriodUnitEnum.Month => BillingFrequencyUnit.Month, + Subscription.BillingPeriodUnitEnum.Year => BillingFrequencyUnit.Year, + _ => BillingFrequencyUnit.Eternity + }; + } + + return BillingFrequencyUnit.Eternity; + } + + private static BillingPaymentMethodStatus ToPaymentMethodStatus(this string value) + { + if (value.HasNoValue()) + { + return BillingPaymentMethodStatus.Invalid; + } + + if (Enum.TryParse(typeof(Customer.CustomerPaymentMethod.StatusEnum), value, true, out var status)) + { + return status switch + { + Customer.CustomerPaymentMethod.StatusEnum.Valid => BillingPaymentMethodStatus.Valid, + _ => BillingPaymentMethodStatus.Invalid + }; + } + + return BillingPaymentMethodStatus.Invalid; + } + + private static BillingPaymentMethodType ToPaymentMethodType(this string value) + { + if (value.HasNoValue()) + { + return BillingPaymentMethodType.Other; + } + + if (Enum.TryParse(typeof(Customer.CustomerPaymentMethod.TypeEnum), value, true, out var type)) + { + return type switch + { + Customer.CustomerPaymentMethod.TypeEnum.Card => BillingPaymentMethodType.Card, + _ => BillingPaymentMethodType.Other + }; + } + + return BillingPaymentMethodType.Other; + } + + private static BillingSubscriptionStatus ToSubscriptionStatus(this string value) + { + if (value.HasNoValue()) + { + return BillingSubscriptionStatus.Unsubscribed; + } + + if (Enum.TryParse(typeof(Subscription.StatusEnum), value, true, out var status)) + { + return status switch + { + Subscription.StatusEnum.Future + or Subscription.StatusEnum.InTrial + or Subscription.StatusEnum.Active + or Subscription.StatusEnum.Paused => + BillingSubscriptionStatus.Activated, + Subscription.StatusEnum.NonRenewing => BillingSubscriptionStatus.Canceling, + Subscription.StatusEnum.Cancelled => BillingSubscriptionStatus.Canceled, + _ => BillingSubscriptionStatus.Unsubscribed + }; + } + + return BillingSubscriptionStatus.Unsubscribed; + } + + private static BillingSubscriptionTier ToTier(this BillingSubscriptionStatus status, string planId, + Dictionary planMap) + { + if (status != BillingSubscriptionStatus.Activated + && status != BillingSubscriptionStatus.Canceling) + { + return BillingSubscriptionTier.Unsubscribed; + } + + return planMap.GetValueOrDefault(planId, BillingSubscriptionTier.Unsubscribed); + } + + private static Optional ToTrialEndDate(this SubscriptionMetadata state) + { + if (!state.TryGetValue(ChargebeeConstants.MetadataProperties.TrialEnd, out var trialEnd)) + { + return Optional.None; + } + + if (trialEnd.HasNoValue()) + { + return Optional.None; + } + + return trialEnd.FromIso8601().HasValue() + ? trialEnd.FromIso8601() + : Optional.None; + } + + private static BillingSubscriptionStatus ToSubscriptionStatus(this SubscriptionMetadata state) + { + if (state.TryGetValue(ChargebeeConstants.MetadataProperties.SubscriptionDeleted, + out var deleted)) + { + if (deleted.HasValue() && deleted.ToBool()) + { + return BillingSubscriptionStatus.Unsubscribed; + } + } + + return state.TryGetValue(ChargebeeConstants.MetadataProperties.SubscriptionStatus, + out var value) + ? value.ToSubscriptionStatus() + : BillingSubscriptionStatus.Unsubscribed; + } + + private static Optional ToCanceledDate(this SubscriptionMetadata state) + { + if (!state.TryGetValue(ChargebeeConstants.MetadataProperties.CanceledAt, out var canceledAt)) + { + return Optional.None; + } + + if (canceledAt.HasNoValue()) + { + return Optional.None; + } + + return canceledAt.FromIso8601().HasValue() + ? canceledAt.FromIso8601() + : Optional.None; + } + + private static bool ToCanBeUnsubscribed(this SubscriptionMetadata state, BillingSubscriptionStatus status) + { + var isInTrial = IsInTrial(state); + return status switch + { + BillingSubscriptionStatus.Unsubscribed => true, + BillingSubscriptionStatus.Canceled => true, + BillingSubscriptionStatus.Activated when isInTrial => true, + _ => false + }; + } + + private static bool IsInTrial(this SubscriptionMetadata state) + { + if (state.TryGetValue(ChargebeeConstants.MetadataProperties.SubscriptionStatus, + out var status)) + { + return status.HasValue() + && status == Subscription.StatusEnum.InTrial.ToString(); + } + + return false; + } +} \ No newline at end of file diff --git a/src/Infrastructure.Shared/ApplicationServices/External/ChargebeeBillingProvider.cs b/src/Infrastructure.Shared/ApplicationServices/External/ChargebeeBillingProvider.cs new file mode 100644 index 00000000..3af3ce7e --- /dev/null +++ b/src/Infrastructure.Shared/ApplicationServices/External/ChargebeeBillingProvider.cs @@ -0,0 +1,24 @@ +using Application.Services.Shared; +using Common; +using Common.Configuration; +using Domain.Services.Shared; + +namespace Infrastructure.Shared.ApplicationServices.External; + +/// +/// Provides a for integrating with Chargebee Billing. +/// +public sealed class ChargebeeBillingProvider : IBillingProvider +{ + public ChargebeeBillingProvider(IRecorder recorder, IConfigurationSettings settings) + { + GatewayService = new ChargebeeHttpServiceClient(recorder, settings); + StateInterpreter = new ChargebeeStateInterpreter(settings); + } + + public IBillingGatewayService GatewayService { get; } + + public string ProviderName => StateInterpreter.ProviderName; + + public IBillingStateInterpreter StateInterpreter { get; } +} \ No newline at end of file diff --git a/src/Infrastructure.Shared/ApplicationServices/External/ChargebeeHttpServiceClient.ChargebeeClient.cs b/src/Infrastructure.Shared/ApplicationServices/External/ChargebeeHttpServiceClient.ChargebeeClient.cs new file mode 100644 index 00000000..673262ea --- /dev/null +++ b/src/Infrastructure.Shared/ApplicationServices/External/ChargebeeHttpServiceClient.ChargebeeClient.cs @@ -0,0 +1,1360 @@ +using Application.Common.Extensions; +using Application.Interfaces; +using Application.Services.Shared; +using ChargeBee.Api; +using ChargeBee.Filters.Enums; +using ChargeBee.Models; +using ChargeBee.Models.Enums; +using Common; +using Common.Configuration; +using Common.Extensions; +using Newtonsoft.Json.Linq; + +namespace Infrastructure.Shared.ApplicationServices.External; + +public interface IChargebeeClient +{ + /// + /// Returns the added (and activated) entitlement of the feature to the plan + /// + Task> AddFeatureEntitlementAsync(ICallerContext caller, string planId, string featureId, + CancellationToken cancellationToken); + + /// + /// Returns the added attachment of the one-time charge to the plan + /// + Task> AddOneTimeChargeAttachmentAsync(ICallerContext caller, string planId, + string chargeId, + CancellationToken cancellationToken); + + /// + /// Archives the specified item + /// + Task> ArchiveItemAsync(ICallerContext caller, string itemId, CancellationToken cancellationToken); + + /// + /// Returns the canceled subscription + /// + Task> CancelSubscriptionAsync(ICallerContext caller, string subscriptionId, + bool endOfTerm, Optional cancelAt, CancellationToken cancellationToken); + + /// + /// Returns the changed subscription plan + /// + Task> ChangeSubscriptionPlanAsync(ICallerContext caller, string subscriptionId, + string planId, Optional trialEndsIn, CancellationToken cancellationToken); + + /// + /// Returns a new for the specified + /// + Task> CreateCustomerForBuyerAsync(ICallerContext caller, string customerId, + SubscriptionBuyer buyer, CancellationToken cancellationToken); + + /// + /// Returns a new for the specified customer + /// + Task> CreateCustomerPaymentSourceAsync(ICallerContext caller, string customerId, + CreditCardPaymentSource card, CancellationToken cancellationToken); + + /// + /// Returns a new of the specified + /// for the specified with the specified details + /// + Task> CreateItemAsync(ICallerContext caller, Item.TypeEnum type, string familyId, + string name, string description, CancellationToken cancellationToken); + + /// + /// Creates a new for the specified for a + /// monthly-recurring charging schedule, with the specified details + /// + Task> CreateMonthlyRecurringItemPriceAsync(ICallerContext caller, string itemId, + string description, CurrencyCodeIso4217 currency, decimal price, bool hasTrial, + CancellationToken cancellationToken); + + /// + /// Creates a new for the specified for a + /// one-off charge, with the specified details + /// + Task> CreateOneOffItemPriceAsync(ICallerContext caller, + string itemId, string description, CurrencyCodeIso4217 currency, decimal price, + CancellationToken cancellationToken); + + /// + /// Creates a new with the specified + /// + Task> CreateProductFamilyAsync(ICallerContext caller, string familyId, + CancellationToken cancellationToken); + + /// + /// Returns a new for the specified + /// Note: AutoCollection="on" so that the subscription automatically cancels after any trial period ends (if any). + /// + Task> CreateSubscriptionForCustomerAsync(ICallerContext caller, string customerId, + Subscriber subscriber, string planId, Optional start, Optional trialEnds, + CancellationToken cancellationToken); + + /// + /// Returns a new type with the specified details + /// + Task> CreateSwitchFeatureAsync(ICallerContext caller, string name, + string description, CancellationToken cancellationToken); + + /// + /// Deletes the specified customer + /// + Task> DeleteCustomerAsync(ICallerContext caller, string customerId, + CancellationToken cancellationToken); + + /// + /// Deletes the specified feature + /// + Task> DeleteFeatureAsync(ICallerContext caller, string featureId, + CancellationToken cancellationToken); + + /// + /// Deletes the specified item price + /// + Task> DeleteItemPriceAsync(ICallerContext caller, string itemPriceId, + CancellationToken cancellationToken); + + /// + /// Deletes the specified subscription + /// + Task> DeleteSubscriptionAsync(ICallerContext caller, string subscriptionId, + CancellationToken cancellationToken); + + /// + /// Returns a customer that matches the specified + /// + Task, Error>> FindCustomerByIdAsync(ICallerContext caller, string customerId, + CancellationToken cancellationToken); + + /// + /// Returns a subscription that matches the specified + /// + Task, Error>> FindSubscriptionByIdAsync(ICallerContext caller, + string subscriptionId, CancellationToken cancellationToken); + + /// + /// Returns all for all plans, charges and addOns, that are + /// + /// + Task, Error>> ListActiveItemPricesAsync(ICallerContext caller, + string productFamilyId, CancellationToken cancellationToken); + + /// + /// Returns the for charges attached to a specified plan + /// + Task, Error>> ListPlanChargesAsync(ICallerContext caller, + string planId, CancellationToken cancellationToken); + + /// + /// Returns all for the specified plan + /// + Task, Error>> ListPlanEntitlementsAsync(ICallerContext caller, + string planId, CancellationToken cancellationToken); + + /// + /// Returns all the optional (switch) + /// + Task, Error>> ListSwitchFeaturesAsync(ICallerContext caller, + CancellationToken cancellationToken); + + /// + /// Returns a reactivated feature, that was previously archived + /// + Task> ReactivateFeatureAsync(ICallerContext caller, string featureId, + CancellationToken cancellationToken); + + /// + /// Returns the reactivated item, that was previously archived + /// + Task> ReactivateItemAsync(ICallerContext caller, string itemId, CancellationToken none); + + /// + /// Returns a reactivated subscription, that may have been canceled. + /// + Task> ReactivateSubscriptionAsync(ICallerContext caller, string subscriptionId, + Optional trialEndsIn, CancellationToken cancellationToken); + + /// + /// Removes the specified from the specified + /// + Task> RemoveFeatureEntitlementAsync(ICallerContext caller, string planId, string featureId, + CancellationToken cancellationToken); + + /// + /// Returns an (uncanceled) subscription, that may be canceling (i.e. canceled before the end of the billing period) + /// + Task> RemoveScheduledSubscriptionCancellationAsync(ICallerContext caller, + string subscriptionId, CancellationToken cancellationToken); + + /// + /// Returns all of the specified that are + /// + Task, Error>> SearchActiveItemsAsync(ICallerContext caller, Item.TypeEnum type, + SearchOptions searchOptions, CancellationToken cancellationToken); + + /// + /// Returns all for the specified , + /// between the specified and , + /// using the specified + /// + Task, Error>> SearchAllCustomerInvoicesAsync( + ICallerContext caller, + string customerId, DateTime fromUtc, DateTime toUtc, SearchOptions searchOptions, + CancellationToken cancellationToken); + + /// + /// Returns all the customers + /// + Task, Error>> SearchAllCustomersAsync(ICallerContext caller, + SearchOptions searchOptions, CancellationToken cancellationToken); + + /// + /// Returns all the families + /// + Task, Error>> SearchAllFamiliesAsync(ICallerContext caller, + SearchOptions searchOptions, CancellationToken cancellationToken); + + /// + /// Returns all + /// + Task, Error>> SearchAllFeaturesAsync(ICallerContext caller, + SearchOptions searchOptions, CancellationToken cancellationToken); + + /// + /// Returns all the item prices for the specified item + /// + Task, Error>> SearchAllItemPricesAsync(ICallerContext caller, string itemId, + SearchOptions searchOptions, CancellationToken cancellationToken); + + /// + /// Returns all the items of the specified + /// + Task, Error>> SearchAllItemsAsync(ICallerContext caller, Item.TypeEnum type, + string familyId, + SearchOptions searchOptions, CancellationToken cancellationToken); + + /// + /// Returns all the subscriptions + /// + Task, Error>> SearchAllSubscriptionsAsync(ICallerContext caller, + SearchOptions searchOptions, CancellationToken cancellationToken); + + /// + /// Returns the updated + /// + Task> UpdateCustomerForBuyerAsync(ICallerContext caller, string customerId, + SubscriptionBuyer buyer, CancellationToken cancellationToken); + + /// + /// Returns the updated billing address for the specified + /// + Task> UpdateCustomerForBuyerBillingAddressAsync(ICallerContext caller, + string customerId, SubscriptionBuyer buyer, CancellationToken cancellationToken); + + /// + /// Defines a credit card payment source + /// + public class CreditCardPaymentSource + { + public required string Cvv { get; init; } + + public required int ExpiryMonth { get; init; } + + public required int ExpiryYear { get; init; } + + public required string Number { get; init; } + } +} + +/// +/// Provides a service client to the Chargebee API +/// +public sealed class ChargebeeClient : IChargebeeClient +{ + private const string ApiKeySettingName = "ApplicationServices:Chargebee:ApiKey"; + private const string BaseUrlSettingName = "ApplicationServices:Chargebee:BaseUrl"; + private const string SiteNameSettingName = "ApplicationServices:Chargebee:SiteName"; + private readonly IRecorder _recorder; + + public ChargebeeClient(IRecorder recorder, IConfigurationSettings settings) + { + _recorder = recorder; + + var siteName = settings.Platform.GetString(SiteNameSettingName); + var apiKey = settings.Platform.GetString(ApiKeySettingName); + ApiConfig.Configure(siteName, apiKey); +#if TESTINGONLY + var baseUrlOverride = settings.Platform.GetString(BaseUrlSettingName, string.Empty); + if (baseUrlOverride.HasValue()) + { + ApiConfig.SetBaseUrl(baseUrlOverride); + } +#endif + } + + public async Task> AddFeatureEntitlementAsync(ICallerContext caller, string planId, + string featureId, + CancellationToken cancellationToken) + { + try + { + await Entitlement.Create() + .Action(ActionEnum.Upsert) + .EntitlementFeatureId(0, featureId) + .EntitlementEntityId(0, planId) + .EntitlementEntityType(0, Entitlement.EntityTypeEnum.Plan) + .EntitlementValue(0, "true") + .RequestAsync(); + + //Note: this client library version (3.18.1) does not seem to return an Entitlement on the result for us to return or use + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Added entitlement of feature {Feature} to plan {Plan}", featureId, planId); + + var activated = await Feature.Activate(featureId) + .RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Activated feature {Feature} to plan {Plan}", featureId, planId); + + return activated.Feature; + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Adding entitlement of feature {Feature} to plan {Plan} failed with {Code}", + featureId, planId, ex.ApiErrorCode); + return ChargebeeError(ex); + } + } + + public async Task> AddOneTimeChargeAttachmentAsync(ICallerContext caller, string planId, + string chargeId, + CancellationToken cancellationToken) + { + try + { + var result = await AttachedItem.Create(planId) + .ItemId(chargeId) + .ChargeOnce(true) + .ChargeOnEvent(ChargeOnEventEnum.SubscriptionCreation) + .RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Added attachment {Attached} of charge {Charge} to plan {Plan}", + result.AttachedItem.Id, chargeId, planId); + return result.AttachedItem; + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Adding attachment of charge {Charge} to plan {Plan} failed with {Code}", + chargeId, planId, ex.ApiErrorCode); + return ChargebeeError(ex); + } + } + + public async Task> ArchiveItemAsync(ICallerContext caller, string itemId, + CancellationToken cancellationToken) + { + try + { + await Item.Delete(itemId) + .RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Deleted item {Item}", itemId); + return Result.Ok; + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Deleting item {Item} failed with {Code}", itemId, + ex.ApiErrorCode); + return ChargebeeError(ex); + } + } + + public async Task> CancelSubscriptionAsync(ICallerContext caller, + string subscriptionId, bool endOfTerm, Optional cancelAt, + CancellationToken cancellationToken) + { + try + { + var request = Subscription.CancelForItems(subscriptionId) + .EndOfTerm(endOfTerm); + if (cancelAt.HasValue) + { + request.CancelAt(cancelAt); + } + + var result = await request.RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Canceled subscription {Subscription}", result.Subscription.Id); + return result.Subscription; + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Cancelling subscription {Subscription} failed with {Code}", + subscriptionId, ex.ApiErrorCode); + return ChargebeeError(ex); + } + } + + public async Task> ChangeSubscriptionPlanAsync(ICallerContext caller, + string subscriptionId, string planId, Optional trialEndsIn, CancellationToken cancellationToken) + { + try + { + var request = Subscription.UpdateForItems(subscriptionId) + .SubscriptionItemItemPriceId(0, planId) + .SubscriptionItemQuantity(0, 1) + .ReplaceItemsList(true); + if (trialEndsIn.HasValue) + { + request.SubscriptionItemTrialEnd(0, trialEndsIn); + } + + var result = await request.RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Changed subscription {Subscription} to plan {Plan}", result.Subscription.Id, + planId); + return result.Subscription; + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Changing subscription {Subscription} to plan {Plan} failed with {Code}", + subscriptionId, + planId, ex.ApiErrorCode); + return ChargebeeError(ex); + } + } + + public async Task> CreateCustomerForBuyerAsync(ICallerContext caller, string customerId, + SubscriptionBuyer buyer, CancellationToken cancellationToken) + { + try + { + var subscriberType = $"{buyer.Subscriber.EntityType}Id"; + var subscriberId = buyer.Subscriber.EntityId; + + var request = Customer.Create() + .Id(customerId) + .FirstName(buyer.Name.FirstName) + .LastName(buyer.Name.LastName) + .Email(buyer.EmailAddress) + .Phone(buyer.PhoneNumber) + .Company(buyer.GetSubscriberId(customerId)) + .BillingAddressFirstName(buyer.Name.FirstName) + .BillingAddressLastName(buyer.Name.LastName) + .BillingAddressEmail(buyer.EmailAddress) + .BillingAddressLine1(buyer.Address.Line1) + .BillingAddressLine2(buyer.Address.Line2) + .BillingAddressLine3(buyer.Address.Line3) + .BillingAddressCity(buyer.Address.City) + .BillingAddressState(buyer.Address.State) + .BillingAddressZip(buyer.Address.Zip) + .BillingAddressCountry(CountryCodes.FindOrDefault(buyer.Address.CountryCode).Alpha2) + .MetaData(JToken.FromObject(new Dictionary + { + { subscriberType, subscriberId }, + { ChargebeeHttpServiceClient.BuyerMetadataId, buyer.Id } + })); + + var result = await request.RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), "Chargebee Client: Created new customer {Customer}", + result.Customer.Id); + return result.Customer; + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Creating customer {Customer} failed with {Code}", customerId, ex.ApiErrorCode); + return ChargebeeError(ex); + } + } + + public async Task> CreateCustomerPaymentSourceAsync(ICallerContext caller, + string customerId, IChargebeeClient.CreditCardPaymentSource card, CancellationToken cancellationToken) + { + try + { + var request = await PaymentSource.CreateCard() + .CustomerId(customerId) + .CardNumber(card.Number) + .CardCvv(card.Cvv) + .CardExpiryMonth(card.ExpiryMonth) + .CardExpiryYear(card.ExpiryYear) + .RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Created card payment source for customer {Customer}", customerId); + return request.PaymentSource; + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Creating card payment source for customer {Customer} failed with {Code}", + customerId, ex.ApiErrorCode); + return ChargebeeError(ex); + } + } + + public async Task> CreateItemAsync(ICallerContext caller, Item.TypeEnum type, + string familyId, string name, string description, + CancellationToken cancellationToken) + { + try + { + var result = await Item.Create() + .Id(name) + .Description(description) + .Name(name) + .ItemFamilyId(familyId) + .Type(type) + .EnabledInPortal(true) + .EnabledForCheckout(true) + .RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Created {Type} item for {Family} with name {Name}", type, familyId, name); + return result.Item; + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Creating {Type} item for family {Family} with name {Name} failed with {Code}", + type, familyId, name, + ex.ApiErrorCode); + return ChargebeeError(ex); + } + } + + public async Task> CreateMonthlyRecurringItemPriceAsync(ICallerContext caller, + string itemId, string description, CurrencyCodeIso4217 currency, decimal price, bool hasTrial, + CancellationToken cancellationToken) + { + var id = $"{itemId}-{currency.Code}-Monthly"; + var name = $"{itemId} {currency.Code} Monthly"; + var priceInCurrency = CurrencyCodes.ToMinorUnit(currency, price); + + try + { + var request = ItemPrice.Create() + .Id(id) + .ItemId(itemId) + .Name(name) + .PricingModel(PricingModelEnum.FlatFee) + .Price(priceInCurrency) + .Period(1) + .PeriodUnit(ItemPrice.PeriodUnitEnum.Month) + .ExternalName(itemId) + .Description(description) + .ShowDescriptionInInvoices(true) + .InvoiceNotes(description) + .ShowDescriptionInQuotes(true); + + if (hasTrial) + { + request.TrialPeriod(7) + .TrialPeriodUnit(ItemPrice.TrialPeriodUnitEnum.Day); + } + + var result = await request + .RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Created monthly-recurring item price for {Item}", itemId); + return result.ItemPrice; + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Creating monthly-recurring item price for item {Item} failed with {Code}", itemId, + ex.ApiErrorCode); + return ChargebeeError(ex); + } + } + + public async Task> CreateOneOffItemPriceAsync(ICallerContext caller, + string itemId, string description, CurrencyCodeIso4217 currency, decimal price, + CancellationToken cancellationToken) + { + var id = $"{itemId}-{currency.Code}"; + var name = $"{itemId} {currency.Code}"; + var priceInCurrency = CurrencyCodes.ToMinorUnit(currency, price); + + try + { + var result = await ItemPrice.Create() + .Id(id) + .ItemId(itemId) + .Name(name) + .PricingModel(PricingModelEnum.FlatFee) + .Price(priceInCurrency) + .ExternalName(itemId) + .Description(description) + .ShowDescriptionInInvoices(true) + .InvoiceNotes(description) + .ShowDescriptionInQuotes(true) + .RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Created one-off item price for {Item}", itemId); + return result.ItemPrice; + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Creating one-off item price for item {Item} failed with {Code}", itemId, + ex.ApiErrorCode); + return ChargebeeError(ex); + } + } + + public async Task> CreateProductFamilyAsync(ICallerContext caller, string familyId, + CancellationToken cancellationToken) + { + try + { + await ItemFamily.Create() + .Id(familyId) + .Name(familyId) + .RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Created item family {Family}", familyId); + return Result.Ok; + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Creating item family {Family} failed with {Code}", familyId, + ex.ApiErrorCode); + return ChargebeeError(ex); + } + } + + public async Task> CreateSubscriptionForCustomerAsync(ICallerContext caller, + string customerId, Subscriber subscriber, string planId, Optional start, + Optional trialEnds, CancellationToken cancellationToken) + { + try + { + var subscriberType = $"{subscriber.EntityType}Id"; + var subscriberId = subscriber.EntityId; + var subscriptionId = customerId.MakeSubscriptionId(); + var request = Subscription.CreateWithItems(customerId) + .Id(subscriptionId) + .AutoCollection(AutoCollectionEnum.On) + .SubscriptionItemItemPriceId(0, planId) + .SubscriptionItemQuantity(0, 1) + .MetaData(JToken.FromObject(new Dictionary + { + { subscriberType, subscriberId } + })); + if (trialEnds.HasValue) + { + request.SubscriptionItemTrialEnd(0, trialEnds.Value); + } + + if (start.HasValue) + { + request.StartDate(start.Value); + } + + var result = await request.RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Created new subscription {Subscription} for customer {Customer}", + result.Subscription.Id, customerId); + return result.Subscription; + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Creating subscription for customer {Customer} failed with {Code}", + customerId, ex.ApiErrorCode); + var error = ChargebeeError(ex); + + if (ex.ApiErrorCode == "payment_method_not_present") + { + return Error.PreconditionViolation(error.Message); + } + + return error; + } + } + + public async Task> CreateSwitchFeatureAsync(ICallerContext caller, string name, + string description, + CancellationToken cancellationToken) + { + try + { + var result = await Feature.Create() + .Name(name) + .Description(description) + .Type(Feature.TypeEnum.Switch) + .RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Created new feature {Feature}", result.Feature.Id); + return result.Feature; + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Creating feature failed with {Code}", ex.ApiErrorCode); + return ChargebeeError(ex); + } + } + + public async Task> DeleteCustomerAsync(ICallerContext caller, string customerId, + CancellationToken cancellationToken) + { + try + { + await Customer.Delete(customerId) + .RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Deleted customer {Customer}", customerId); + return Result.Ok; + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Deleting customer {Customer} failed with {Code}", customerId, + ex.ApiErrorCode); + return ChargebeeError(ex); + } + } + + public async Task> DeleteFeatureAsync(ICallerContext caller, string featureId, + CancellationToken cancellationToken) + { + try + { + await Feature.Archive(featureId) + .RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Archived feature {Feature}", featureId); + + await Feature.Delete(featureId) + .RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Deleted feature {Feature}", featureId); + return Result.Ok; + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Archiving and deleting feature {Feature} failed with {Code}", featureId, + ex.ApiErrorCode); + return ChargebeeError(ex); + } + } + + public async Task> DeleteItemPriceAsync(ICallerContext caller, string itemPriceId, + CancellationToken cancellationToken) + { + try + { + await ItemPrice.Delete(itemPriceId) + .RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Deleted item price {Price}", itemPriceId); + return Result.Ok; + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Deleting item price {Price} failed with {Code}", itemPriceId, + ex.ApiErrorCode); + return ChargebeeError(ex); + } + } + + public async Task> DeleteSubscriptionAsync(ICallerContext caller, string subscriptionId, + CancellationToken cancellationToken) + { + try + { + await Subscription.Delete(subscriptionId) + .RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Deleted subscription {Subscription}", subscriptionId); + return Result.Ok; + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Deleting subscription {Subscription} failed with {Code}", subscriptionId, + ex.ApiErrorCode); + return ChargebeeError(ex); + } + } + + public async Task, Error>> FindCustomerByIdAsync(ICallerContext caller, + string customerId, CancellationToken cancellationToken) + { + try + { + var request = await Customer.List() + .Id() + .Is(customerId) + .Limit(1) + .RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Searched for customer {Customer}, and found {Count}", customerId, + request.List.Count); + return request.List.HasNone() + ? Optional.None + : request.List.First().Customer.ToOptional(); + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Searching for customer {Customer} failed with {Code}", customerId, + ex.ApiErrorCode); + return ChargebeeError(ex); + } + } + + public async Task, Error>> FindSubscriptionByIdAsync(ICallerContext caller, + string subscriptionId, CancellationToken cancellationToken) + { + try + { + var request = await Subscription.List() + .Id() + .Is(subscriptionId) + .Limit(1) + .RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Searched for subscription {Subscription}, and found {Count}", subscriptionId, + request.List.Count); + return request.List.HasNone() + ? Optional.None + : request.List.First().Subscription.ToOptional(); + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Searching for subscription {Subscription} failed with {Code}", + subscriptionId, + ex.ApiErrorCode); + return ChargebeeError(ex); + } + } + + public async Task, Error>> ListActiveItemPricesAsync(ICallerContext caller, + string productFamilyId, CancellationToken cancellationToken) + { + try + { + var request = await ItemPrice.List() + .Status().Is(ItemPrice.StatusEnum.Active) + .ItemFamilyId().Is(productFamilyId) + .Limit(100) + .RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Searched for active item prices for family {ProductFamily}, and found {Count}", + request.List.Count, productFamilyId); + return request.List.Select(entry => entry.ItemPrice).ToList(); + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Searching for active item prices for family {ProductFamily} failed with {Code}", + productFamilyId, ex.ApiErrorCode); + return ChargebeeError(ex); + } + } + + public async Task, Error>> ListPlanChargesAsync(ICallerContext caller, + string planId, CancellationToken cancellationToken) + { + try + { + var request = await AttachedItem.List(planId) + .ItemType().Is(ItemTypeEnum.Charge) + .Limit(100) + .RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Searched for charges of plan {Plan}, and found {Count}", planId, + request.List.Count); + return request.List.Select(entry => entry.AttachedItem).ToList(); + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Searching for charges of plan {Plan} failed with {Code}", planId, + ex.ApiErrorCode); + return ChargebeeError(ex); + } + } + + public async Task, Error>> ListPlanEntitlementsAsync(ICallerContext caller, + string planId, CancellationToken cancellationToken) + { + try + { + var request = await Entitlement.List() + .EntityType().Is(Entitlement.EntityTypeEnum.Plan) + .EntityId().Is(planId) + .Limit(100) + .RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Searched for entitlements of plan {Plan}, and found {Count}", planId, + request.List.Count); + return request.List.Select(entry => entry.Entitlement).ToList(); + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Searching for entitlements of plan {Plan} failed with {Code}", planId, + ex.ApiErrorCode); + return ChargebeeError(ex); + } + } + + public async Task, Error>> ListSwitchFeaturesAsync(ICallerContext caller, + CancellationToken cancellationToken) + { + try + { + var request = await Feature.List() + .Status().Is(Feature.StatusEnum.Active) + .Type().Is(Feature.TypeEnum.Switch) + .Limit(100) + .RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Searched for active switch features, and found {Count}", request.List.Count); + return request.List.Select(entry => entry.Feature).ToList(); + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Searching for active switch features failed with {Code}", ex.ApiErrorCode); + return ChargebeeError(ex); + } + } + + public async Task> ReactivateFeatureAsync(ICallerContext caller, string featureId, + CancellationToken cancellationToken) + { + try + { + var result = await Feature.Reactivate(featureId) + .RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Reactivated feature {Feature}", result.Feature.Id); + return result.Feature; + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Reactivating feature {Feature} failed with {Code}", featureId, + ex.ApiErrorCode); + return ChargebeeError(ex); + } + } + + public async Task> ReactivateItemAsync(ICallerContext caller, string itemId, + CancellationToken none) + { + try + { + var result = await Item.Update(itemId) + .Status(Item.StatusEnum.Active) + .RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Reactivated item {Item}", result.Item.Id); + return result.Item; + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Reactivating item {Item} failed with {Code}", itemId, + ex.ApiErrorCode); + return ChargebeeError(ex); + } + } + + public async Task> ReactivateSubscriptionAsync(ICallerContext caller, + string subscriptionId, Optional trialEndsIn, CancellationToken cancellationToken) + { + try + { + var request = Subscription.Reactivate(subscriptionId); + if (trialEndsIn.HasValue) + { + request.TrialEnd(trialEndsIn); + } + + var result = await request.RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Reactivated subscription {Subscription}", result.Subscription.Id); + return result.Subscription; + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Reactivating subscription {Subscription} failed with {Code}", subscriptionId, + ex.ApiErrorCode); + return ChargebeeError(ex); + } + } + + public async Task> RemoveFeatureEntitlementAsync(ICallerContext caller, string planId, + string featureId, CancellationToken cancellationToken) + { + try + { + await Entitlement.Create() + .Action(ActionEnum.Remove) + .EntitlementFeatureId(0, featureId) + .EntitlementEntityId(0, planId) + .EntitlementEntityType(0, Entitlement.EntityTypeEnum.Plan) + .RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Removed feature entitlement {Feature} from plan {Plan}", featureId, planId); + return Result.Ok; + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Removing feature entitlement {Feature} from plan {Plan} failed with {Code}", + featureId, planId, + ex.ApiErrorCode); + return ChargebeeError(ex); + } + } + + public async Task> RemoveScheduledSubscriptionCancellationAsync( + ICallerContext caller, string subscriptionId, + CancellationToken cancellationToken) + { + try + { + var request = Subscription.RemoveScheduledCancellation(subscriptionId); + + var result = await request.RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Removed cancellation on subscription {Subscription}", result.Subscription.Id); + return result.Subscription; + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Removing cancellation on subscription {Subscription} failed with {Code}", + subscriptionId, ex.ApiErrorCode); + return ChargebeeError(ex); + } + } + + public async Task, Error>> SearchActiveItemsAsync(ICallerContext caller, + Item.TypeEnum type, SearchOptions searchOptions, CancellationToken cancellationToken) + { + try + { + var limit = searchOptions.Limit != 0 + ? searchOptions.Limit + : 100; + var request = await Item.List() + .Type().Is(type) + .Status().Is(Item.StatusEnum.Active) + .Limit(limit) + .RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Searched for all items of type {Type}, and found {Count}", type, + request.List.Count); + return request.List.Select(entry => entry.Item).ToList(); + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Searching for all items of type {Type} failed with {Code}", type, + ex.ApiErrorCode); + return ChargebeeError(ex); + } + } + + public async Task, Error>> SearchAllCustomerInvoicesAsync( + ICallerContext caller, string customerId, DateTime fromUtc, DateTime toUtc, + SearchOptions searchOptions, CancellationToken cancellationToken) + { + try + { + var limit = searchOptions.Limit != 0 + ? searchOptions.Limit + : 100; + var request = await Invoice.List() + .CustomerId().Is(customerId) + .Date().Between(fromUtc, toUtc) + .Limit(limit) + .SortByDate(SortOrderEnum.Asc) + .RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Searched for all invoices for {Customer}, and found {Count}", customerId, + request.List.Count); + return request.List.Select(entry => entry.Invoice).ToList(); + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Searching for all invoices for {Customer} failed with {Code}", customerId, + ex.ApiErrorCode); + return ChargebeeError(ex); + } + } + + public async Task, Error>> SearchAllCustomersAsync(ICallerContext caller, + SearchOptions searchOptions, CancellationToken cancellationToken) + { + try + { + var limit = searchOptions.Limit != 0 + ? searchOptions.Limit + : 100; + var request = await Customer.List() + .Limit(limit) + .RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Searched for all customers, and found {Count}", request.List.Count); + return request.List.Select(entry => entry.Customer).ToList(); + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Searching for all customers failed with {Code}", ex.ApiErrorCode); + return ChargebeeError(ex); + } + } + + public async Task, Error>> SearchAllFamiliesAsync(ICallerContext caller, + SearchOptions searchOptions, CancellationToken cancellationToken) + { + try + { + var limit = searchOptions.Limit != 0 + ? searchOptions.Limit + : 100; + var request = await ItemFamily.List() + .Limit(limit) + .RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Searched for all item families, and found {Count}", request.List.Count); + return request.List.Select(entry => entry.ItemFamily).ToList(); + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Searching for all item families failed with {Code}", ex.ApiErrorCode); + return ChargebeeError(ex); + } + } + + public async Task, Error>> SearchAllFeaturesAsync(ICallerContext caller, + SearchOptions searchOptions, CancellationToken cancellationToken) + { + try + { + var limit = searchOptions.Limit != 0 + ? searchOptions.Limit + : 100; + var request = await Feature.List() + .Limit(limit) + .RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Searched for all features, and found {Count}", request.List.Count); + return request.List.Select(entry => entry.Feature).ToList(); + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Searching for all features failed with {Code}", ex.ApiErrorCode); + return ChargebeeError(ex); + } + } + + public async Task, Error>> SearchAllItemPricesAsync(ICallerContext caller, + string itemId, SearchOptions searchOptions, + CancellationToken cancellationToken) + { + try + { + var limit = searchOptions.Limit != 0 + ? searchOptions.Limit + : 100; + var request = await ItemPrice.List() + .ItemId().Is(itemId) + .Limit(limit) + .RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Searched for all item prices for item {Item}, and found {Count}", itemId, + request.List.Count); + return request.List.Select(entry => entry.ItemPrice).ToList(); + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Searching for all item prices of item {Item} failed with {Code}", itemId, + ex.ApiErrorCode); + return ChargebeeError(ex); + } + } + + public async Task, Error>> SearchAllItemsAsync(ICallerContext caller, Item.TypeEnum type, + string familyId, SearchOptions searchOptions, + CancellationToken cancellationToken) + { + try + { + var limit = searchOptions.Limit != 0 + ? searchOptions.Limit + : 100; + var request = await Item.List() + .ItemFamilyId().Is(familyId) + .Type().Is(type) + .Limit(limit) + .RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Searched for all items of type {Type}, and found {Count}", type, request.List.Count); + return request.List.Select(entry => entry.Item).ToList(); + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Searching for all items of type {Type} failed with {Code}", type, ex.ApiErrorCode); + return ChargebeeError(ex); + } + } + + public async Task, Error>> SearchAllSubscriptionsAsync(ICallerContext caller, + SearchOptions searchOptions, CancellationToken cancellationToken) + { + try + { + var limit = searchOptions.Limit != 0 + ? searchOptions.Limit + : 100; + var request = await Subscription.List() + .Limit(limit) + .RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Searched for all subscriptions, and found {Count}", request.List.Count); + return request.List.Select(entry => entry.Subscription).ToList(); + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Searching for all subscriptions failed with {Code}", ex.ApiErrorCode); + return ChargebeeError(ex); + } + } + + public async Task> UpdateCustomerForBuyerAsync(ICallerContext caller, string customerId, + SubscriptionBuyer buyer, CancellationToken cancellationToken) + { + try + { + var subscriberType = $"{buyer.Subscriber.EntityType}Id"; + var subscriberId = buyer.Subscriber.EntityId; + + var request = Customer.Update(customerId) + .FirstName(buyer.Name.FirstName) + .LastName(buyer.Name.LastName) + .Email(buyer.EmailAddress) + .Phone(buyer.PhoneNumber) + .Company(buyer.GetSubscriberId(customerId)) + .MetaData(JToken.FromObject(new Dictionary + { + { subscriberType, subscriberId }, + { ChargebeeHttpServiceClient.BuyerMetadataId, buyer.Id } + })); + + var result = await request.RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), "Chargebee Client: Updated customer {Customer}", + result.Customer.Id); + return result.Customer; + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Updating customer {Customer} failed with {Code}", customerId, ex.ApiErrorCode); + return ChargebeeError(ex); + } + } + + public async Task> UpdateCustomerForBuyerBillingAddressAsync(ICallerContext caller, + string customerId, SubscriptionBuyer buyer, CancellationToken cancellationToken) + { + try + { + var request = Customer.UpdateBillingInfo(customerId) + .BillingAddressFirstName(buyer.Name.FirstName) + .BillingAddressLastName(buyer.Name.LastName) + .BillingAddressEmail(buyer.EmailAddress) + .BillingAddressLine1(buyer.Address.Line1) + .BillingAddressLine2(buyer.Address.Line2) + .BillingAddressLine3(buyer.Address.Line3) + .BillingAddressCity(buyer.Address.City) + .BillingAddressState(buyer.Address.State) + .BillingAddressZip(buyer.Address.Zip) + .BillingAddressCountry(CountryCodes.FindOrDefault(buyer.Address.CountryCode).Alpha2); + + var result = await request.RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Updated customer billing {Customer} billing address", customerId); + return result.Customer; + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Updating customer billing {Customer} failed with {Code}", customerId, + ex.ApiErrorCode); + return ChargebeeError(ex); + } + } + + private static Error ChargebeeError(ApiException ex) + { + var message = $"Chargebee failed with error: {ex.Message}, and code: {ex.ApiErrorCode}"; + return Error.Unexpected(message); + } +} \ No newline at end of file diff --git a/src/Infrastructure.Shared/Infrastructure.Shared.csproj b/src/Infrastructure.Shared/Infrastructure.Shared.csproj index 1580934e..0d1cb494 100644 --- a/src/Infrastructure.Shared/Infrastructure.Shared.csproj +++ b/src/Infrastructure.Shared/Infrastructure.Shared.csproj @@ -18,6 +18,7 @@ + diff --git a/src/Infrastructure.Shared/Resources.Designer.cs b/src/Infrastructure.Shared/Resources.Designer.cs index eed0e7f4..31a7644a 100644 --- a/src/Infrastructure.Shared/Resources.Designer.cs +++ b/src/Infrastructure.Shared/Resources.Designer.cs @@ -77,6 +77,78 @@ internal static string BillingProvider_ProviderNameNotMatch { } } + /// + /// Looks up a localized string similar to Cannot cancel a subscription for a customer that is not immediate or scheduled to cancel in the past. + /// + internal static string ChargebeeHttpServiceClient_Cancel_ScheduleInvalid { + get { + return ResourceManager.GetString("ChargebeeHttpServiceClient_Cancel_ScheduleInvalid", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The CustomerId is missing. + /// + internal static string ChargebeeHttpServiceClient_InvalidCustomerId { + get { + return ResourceManager.GetString("ChargebeeHttpServiceClient_InvalidCustomerId", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The PlanId is missing. + /// + internal static string ChargebeeHttpServiceClient_InvalidPlanId { + get { + return ResourceManager.GetString("ChargebeeHttpServiceClient_InvalidPlanId", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The Subscriber is missing. + /// + internal static string ChargebeeHttpServiceClient_InvalidSubscriber { + get { + return ResourceManager.GetString("ChargebeeHttpServiceClient_InvalidSubscriber", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The SubscriptionId is missing. + /// + internal static string ChargebeeHttpServiceClient_InvalidSubscriptionId { + get { + return ResourceManager.GetString("ChargebeeHttpServiceClient_InvalidSubscriptionId", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Cannot create a subscription for a customer that is not immediate or scheduled to start in the past. + /// + internal static string ChargebeeHttpServiceClient_Subscribe_ScheduleInvalid { + get { + return ResourceManager.GetString("ChargebeeHttpServiceClient_Subscribe_ScheduleInvalid", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to A subscription with ID: {0}, does not exist in Chargebee. + /// + internal static string ChargebeeHttpServiceClient_SubscriptionNotFound { + get { + return ResourceManager.GetString("ChargebeeHttpServiceClient_SubscriptionNotFound", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Cannot transfer a subscription to another buyer without the buyer information. + /// + internal static string ChargebeeHttpServiceClient_Transfer_BuyerInvalid { + get { + return ResourceManager.GetString("ChargebeeHttpServiceClient_Transfer_BuyerInvalid", resourceCulture); + } + } + /// /// Looks up a localized string similar to Failed to notify consumer: {0}, with event: {1} ({2}). /// @@ -123,7 +195,7 @@ internal static string InProcessInMemBillingGatewayService_BasicPlan_Feature1_De } /// - /// Looks up a localized string similar to This plan cannot be changed, nor cancelled.. + /// Looks up a localized string similar to This plan cannot be changed, nor canceled.. /// internal static string InProcessInMemBillingGatewayService_BasicPlan_Notes { get { diff --git a/src/Infrastructure.Shared/Resources.resx b/src/Infrastructure.Shared/Resources.resx index fbc17b09..68e97d2b 100644 --- a/src/Infrastructure.Shared/Resources.resx +++ b/src/Infrastructure.Shared/Resources.resx @@ -43,10 +43,34 @@ For everyone. Forever. - This plan cannot be changed, nor cancelled. + This plan cannot be changed, nor canceled. All features + + The CustomerId is missing + + + The SubscriptionId is missing + + + The PlanId is missing + + + The Subscriber is missing + + + Cannot create a subscription for a customer that is not immediate or scheduled to start in the past + + + A subscription with ID: {0}, does not exist in Chargebee + + + Cannot cancel a subscription for a customer that is not immediate or scheduled to cancel in the past + + + Cannot transfer a subscription to another buyer without the buyer information + \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeCancelSubscriptionRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeCancelSubscriptionRequest.cs new file mode 100644 index 00000000..4b4aff7f --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeCancelSubscriptionRequest.cs @@ -0,0 +1,17 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Chargebee; + +/// +/// Chargebee API: https://apidocs.chargebee.com/docs/api/subscriptions?prod_cat_ver=2#cancel_subscription_for_items +/// +[Route("/subscriptions/{Id}/cancel_for_items", OperationMethod.Post)] +public class ChargebeeCancelSubscriptionRequest : UnTenantedRequest +{ + public long? CancelAt { get; set; } + + public bool EndOfTerm { get; set; } + + public string? Id { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeCancelSubscriptionResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeCancelSubscriptionResponse.cs new file mode 100644 index 00000000..27487262 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeCancelSubscriptionResponse.cs @@ -0,0 +1,10 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Chargebee; + +public class ChargebeeCancelSubscriptionResponse : IWebResponse +{ + public ChargebeeCustomer? Customer { get; set; } + + public ChargebeeSubscription? Subscription { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeChangeSubscriptionPlanRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeChangeSubscriptionPlanRequest.cs new file mode 100644 index 00000000..1a637a7a --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeChangeSubscriptionPlanRequest.cs @@ -0,0 +1,34 @@ +using Infrastructure.Web.Api.Interfaces; +using JetBrains.Annotations; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Chargebee; + +/// +/// Chargebee API: https://apidocs.chargebee.com/docs/api/subscriptions?prod_cat_ver=2#update_subscription_for_items +/// +[Route("/subscriptions/{Id}/update_for_items", OperationMethod.Post)] +[UsedImplicitly] +public class ChargebeeChangeSubscriptionPlanRequest : UnTenantedRequest +{ + public string? Id { get; set; } + + public bool ReplaceItemsList { get; set; } + + public List SubscriptionItems { get; set; } = new(); +} + +public class ChargebeeSubscriptionItem +{ + public decimal Amount { get; set; } + + public string? ItemPriceId { get; set; } + + public string? ItemType { get; set; } + + public int Quantity { get; set; } + + public long? TrialEnd { get; set; } + + public decimal UnitPrice { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeChangeSubscriptionPlanResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeChangeSubscriptionPlanResponse.cs new file mode 100644 index 00000000..f1f1de53 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeChangeSubscriptionPlanResponse.cs @@ -0,0 +1,10 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Chargebee; + +public class ChargebeeChangeSubscriptionPlanResponse : IWebResponse +{ + public ChargebeeCustomer? Customer { get; set; } + + public ChargebeeSubscription? Subscription { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeCreateCustomerRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeCreateCustomerRequest.cs new file mode 100644 index 00000000..7f2c7c0e --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeCreateCustomerRequest.cs @@ -0,0 +1,31 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Chargebee; + +/// +/// Chargebee API: https://apidocs.chargebee.com/docs/api/customers?prod_cat_ver=2#create_a_customer +/// +[Route("/customers", OperationMethod.Post)] +public class + ChargebeeCreateCustomerRequest : UnTenantedRequest +{ + public ChargebeeAddress? BillingAddress { get; set; } + + public string? Company { get; set; } + + public string? Email { get; set; } + + public string? FirstName { get; set; } + + public string? Id { get; set; } + + public string? LastName { get; set; } + + public string? MetaData { get; set; } + + public string? Phone { get; set; } +} + +public class ChargebeeAddress +{ +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeCreateCustomerResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeCreateCustomerResponse.cs new file mode 100644 index 00000000..cc95c16f --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeCreateCustomerResponse.cs @@ -0,0 +1,8 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Chargebee; + +public class ChargebeeCreateCustomerResponse : IWebResponse +{ + public ChargebeeCustomer? Customer { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeCreateSubscriptionRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeCreateSubscriptionRequest.cs new file mode 100644 index 00000000..279e1714 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeCreateSubscriptionRequest.cs @@ -0,0 +1,23 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Chargebee; + +/// +/// Chargebee API: https://apidocs.chargebee.com/docs/api/subscriptions?prod_cat_ver=2#create_subscription_for_items +/// +[Route("/customers/{CustomerId}/subscription_for_items", OperationMethod.Post)] +public class ChargebeeCreateSubscriptionRequest : UnTenantedRequest +{ + public string? AutoCollection { get; set; } + + public string? CustomerId { get; set; } + + public string? Id { get; set; } + + public string? MetaData { get; set; } + + public long? StartDate { get; set; } + + public List SubscriptionItems { get; set; } = new(); +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeCreateSubscriptionResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeCreateSubscriptionResponse.cs new file mode 100644 index 00000000..5d342d58 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeCreateSubscriptionResponse.cs @@ -0,0 +1,48 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Chargebee; + +public class ChargebeeCreateSubscriptionResponse : IWebResponse +{ + public ChargebeeCustomer? Customer { get; set; } + + public ChargebeeSubscription? Subscription { get; set; } +} + +public class ChargebeeSubscription +{ + public int BillingPeriod { get; set; } + + public string? BillingPeriodUnit { get; set; } + + public long? CancelledAt { get; set; } + + public string? CurrencyCode { get; set; } + + public string? CustomerId { get; set; } + + public bool? Deleted { get; set; } + + public string? Id { get; set; } + + public long? NextBillingAt { get; set; } + + public string? Status { get; set; } + + public List SubscriptionItems { get; set; } = new(); + + public long? TrialEnd { get; set; } +} + +public class ChargebeeCustomer +{ + public string? Email { get; set; } + + public string? FirstName { get; set; } + + public string? Id { get; set; } + + public string? LastName { get; set; } + + public string? Phone { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeGetCustomerRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeGetCustomerRequest.cs new file mode 100644 index 00000000..84531fae --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeGetCustomerRequest.cs @@ -0,0 +1,12 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Chargebee; + +/// +/// Chargebee API: https://apidocs.chargebee.com/docs/api/customers?prod_cat_ver=2#retrieve_a_customer +/// +[Route("/customers/{Id}", OperationMethod.Get)] +public class ChargebeeGetCustomerRequest : UnTenantedRequest +{ + public string? Id { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeGetCustomerResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeGetCustomerResponse.cs new file mode 100644 index 00000000..18c8d38c --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeGetCustomerResponse.cs @@ -0,0 +1,8 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Chargebee; + +public class ChargebeeGetCustomerResponse : IWebResponse +{ + public ChargebeeCustomer? Customer { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeGetSubscriptionRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeGetSubscriptionRequest.cs new file mode 100644 index 00000000..5b5f197d --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeGetSubscriptionRequest.cs @@ -0,0 +1,16 @@ +using Infrastructure.Web.Api.Interfaces; +using JetBrains.Annotations; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Chargebee; + +/// +/// Chargebee API: https://apidocs.chargebee.com/docs/api/subscriptions?prod_cat_ver=1#retrieve_a_subscription +/// +[Route("/subscriptions/{Id}", OperationMethod.Get)] +[UsedImplicitly] +public class + ChargebeeGetSubscriptionRequest : UnTenantedRequest +{ + public string? Id { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeGetSubscriptionResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeGetSubscriptionResponse.cs new file mode 100644 index 00000000..1b36783c --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeGetSubscriptionResponse.cs @@ -0,0 +1,10 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Chargebee; + +public class ChargebeeGetSubscriptionResponse : IWebResponse +{ + public ChargebeeCustomer? Customer { get; set; } + + public ChargebeeSubscription? Subscription { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListAttachedItemsRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListAttachedItemsRequest.cs new file mode 100644 index 00000000..02021e51 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListAttachedItemsRequest.cs @@ -0,0 +1,44 @@ +using Infrastructure.Web.Api.Interfaces; +using JetBrains.Annotations; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Chargebee; + +/// +/// Chargebee API: https://apidocs.chargebee.com/docs/api/attached_items?prod_cat_ver=2#list_attached_items +/// +[Route("/items/{PlanId}/attached_items", OperationMethod.Get)] +[UsedImplicitly] +public class + ChargebeeListAttachedItemsRequest : UnTenantedRequest +{ + public ChargebeeFilterQuery? Filter { get; set; } + + public int? Limit { get; set; } + + public string? PlanId { get; set; } +} + +[UsedImplicitly] +public class ChargebeeFilterQuery : IParsable +{ + public IChargeBeeFilterQuery? In { get; set; } + + public IChargeBeeFilterQuery? Is { get; set; } + + static ChargebeeFilterQuery IParsable.Parse(string s, IFormatProvider? provider) + { + return new ChargebeeFilterQuery(); + } + + public static bool TryParse(string? s, IFormatProvider? provider, out ChargebeeFilterQuery result) + { + result = new ChargebeeFilterQuery(); + return true; + } +} + +public interface IChargeBeeFilterQuery +{ + string Status { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListAttachedItemsResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListAttachedItemsResponse.cs new file mode 100644 index 00000000..7b2f62a6 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListAttachedItemsResponse.cs @@ -0,0 +1,20 @@ +using Infrastructure.Web.Api.Interfaces; +using JetBrains.Annotations; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Chargebee; + +public class ChargebeeListAttachedItemsResponse : IWebResponse +{ + public List? List { get; set; } +} + +[UsedImplicitly] +public class ChargebeeAttachedItemList +{ + public ChargebeeAttachedItem? AttachedItem { get; set; } +} + +[UsedImplicitly] +public class ChargebeeAttachedItem +{ +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListFeaturesRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListFeaturesRequest.cs new file mode 100644 index 00000000..3c856ec2 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListFeaturesRequest.cs @@ -0,0 +1,15 @@ +using Infrastructure.Web.Api.Interfaces; +using JetBrains.Annotations; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Chargebee; + +/// +/// Chargebee API: https://apidocs.chargebee.com/docs/api/features?prod_cat_ver=2#list_features +/// +[Route("/features", OperationMethod.Get)] +[UsedImplicitly] +public class + ChargebeeListFeaturesRequest : UnTenantedRequest +{ + public int? Limit { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListFeaturesResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListFeaturesResponse.cs new file mode 100644 index 00000000..f8cc2415 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListFeaturesResponse.cs @@ -0,0 +1,20 @@ +using Infrastructure.Web.Api.Interfaces; +using JetBrains.Annotations; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Chargebee; + +public class ChargebeeListFeaturesResponse : IWebResponse +{ + public List? List { get; set; } +} + +[UsedImplicitly] +public class ChargebeeFeatureList +{ + public ChargebeeFeature? Feature { get; set; } +} + +[UsedImplicitly] +public class ChargebeeFeature +{ +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListInvoicesRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListInvoicesRequest.cs new file mode 100644 index 00000000..b37b775f --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListInvoicesRequest.cs @@ -0,0 +1,58 @@ +using Infrastructure.Web.Api.Interfaces; +using JetBrains.Annotations; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Chargebee; + +/// +/// Chargebee API: https://apidocs.chargebee.com/docs/api/invoices?prod_cat_ver=2#list_invoices +/// +[Route("/invoices", OperationMethod.Get)] +[UsedImplicitly] +public class + ChargebeeListInvoicesRequest : UnTenantedRequest +{ + public ChargebeeFilterQuery? Filter { get; set; } + + public int? Limit { get; set; } + + public ChargebeeSortBy? SortBy { get; set; } +} + +[UsedImplicitly] +public class ChargebeeSortBy : IParsable +{ + public ChargebeeSortByField By { get; set; } //values: date, updated_at + + public ChargebeeSortByOrder Order { get; set; } //values: asc, desc + + public static ChargebeeSortBy Parse(string s, IFormatProvider? provider) + { + return new ChargebeeSortBy + { + By = ChargebeeSortByField.Date, + Order = ChargebeeSortByOrder.Asc + }; + } + + public static bool TryParse(string? s, IFormatProvider? provider, out ChargebeeSortBy result) + { + result = new ChargebeeSortBy + { + By = ChargebeeSortByField.Date, + Order = ChargebeeSortByOrder.Asc + }; + return true; + } +} + +public enum ChargebeeSortByField +{ + Date, + UpdatedAt +} + +public enum ChargebeeSortByOrder +{ + Asc, + Desc +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListInvoicesResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListInvoicesResponse.cs new file mode 100644 index 00000000..ae0a67df --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListInvoicesResponse.cs @@ -0,0 +1,22 @@ +using Infrastructure.Web.Api.Interfaces; +using JetBrains.Annotations; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Chargebee; + +public class ChargebeeListInvoicesResponse : IWebResponse +{ + public List? List { get; set; } + + public string? NextOffset { get; set; } +} + +[UsedImplicitly] +public class ChargebeeInvoiceList +{ + public ChargebeeInvoice? Invoice { get; set; } +} + +[UsedImplicitly] +public class ChargebeeInvoice +{ +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListItemEntitlementsRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListItemEntitlementsRequest.cs new file mode 100644 index 00000000..3e142a03 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListItemEntitlementsRequest.cs @@ -0,0 +1,18 @@ +using Infrastructure.Web.Api.Interfaces; +using JetBrains.Annotations; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Chargebee; + +/// +/// Chargebee API: +/// https://apidocs.chargebee.com/docs/api/item_entitlements?prod_cat_ver=2#list_item_entitlements_for_an_item +/// +[Route("/items/{PlanId}/item_entitlements", OperationMethod.Get)] +[UsedImplicitly] +public class ChargebeeListItemEntitlementsRequest : UnTenantedRequest +{ + public int? Limit { get; set; } + + public string? PlanId { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListItemEntitlementsResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListItemEntitlementsResponse.cs new file mode 100644 index 00000000..3964d358 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListItemEntitlementsResponse.cs @@ -0,0 +1,20 @@ +using Infrastructure.Web.Api.Interfaces; +using JetBrains.Annotations; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Chargebee; + +public class ChargebeeListItemEntitlementsResponse : IWebResponse +{ + public List? List { get; set; } +} + +[UsedImplicitly] +public class ChargebeeItemEntitlementList +{ + public ChargebeeItemEntitlement? ItemEntitlement { get; set; } +} + +[UsedImplicitly] +public class ChargebeeItemEntitlement +{ +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListItemPricesRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListItemPricesRequest.cs new file mode 100644 index 00000000..8d9eee0b --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListItemPricesRequest.cs @@ -0,0 +1,17 @@ +using Infrastructure.Web.Api.Interfaces; +using JetBrains.Annotations; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Chargebee; + +/// +/// Chargebee API: https://apidocs.chargebee.com/docs/api/item_prices?prod_cat_ver=2#list_item_prices +/// +[Route("/item_prices", OperationMethod.Get)] +[UsedImplicitly] +public class + ChargebeeListItemPricesRequest : UnTenantedRequest +{ + public ChargebeeFilterQuery? Filter { get; set; } + + public int? Limit { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListItemPricesResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListItemPricesResponse.cs new file mode 100644 index 00000000..7f7f2407 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListItemPricesResponse.cs @@ -0,0 +1,46 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Chargebee; + +public class ChargebeeListItemPricesResponse : IWebResponse +{ + public List? List { get; set; } +} + +public class ChargebeeItemPriceList +{ + public ChargebeeItemPrice? ItemPrice { get; set; } +} + +public class ChargebeeItemPrice +{ + public string? CurrencyCode { get; set; } + + public string? Description { get; set; } + + public string? ExternalName { get; set; } + + public int FreeQuantity { get; set; } + + public string? Id { get; set; } + + public string? ItemFamilyId { get; set; } + + public string? ItemId { get; set; } + + public string? ItemType { get; set; } + + public int Period { get; set; } + + public string? PeriodUnit { get; set; } + + public int Price { get; set; } + + public string? PricingModel { get; set; } + + public string? Status { get; set; } + + public int? TrialPeriod { get; set; } + + public string? TrialPeriodUnit { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListSubscriptionsRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListSubscriptionsRequest.cs new file mode 100644 index 00000000..45c334e6 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListSubscriptionsRequest.cs @@ -0,0 +1,16 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Chargebee; + +/// +/// Chargebee API: https://apidocs.chargebee.com/docs/api/subscriptions?prod_cat_ver=2#list_subscriptions +/// +[Route("/subscriptions", OperationMethod.Get)] +public class + ChargebeeListSubscriptionsRequest : UnTenantedRequest +{ + public ChargebeeFilterQuery? Filter { get; set; } + + public int? Limit { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListSubscriptionsResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListSubscriptionsResponse.cs new file mode 100644 index 00000000..7905199e --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListSubscriptionsResponse.cs @@ -0,0 +1,18 @@ +using Infrastructure.Web.Api.Interfaces; +using JetBrains.Annotations; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Chargebee; + +[UsedImplicitly] +public class ChargebeeListSubscriptionsResponse : IWebResponse +{ + public List? List { get; set; } +} + +[UsedImplicitly] +public class ChargebeeSubscriptionList +{ + public ChargebeeCustomer? Customer { get; set; } + + public ChargebeeSubscription? Subscription { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeNotifyWebhookEventRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeNotifyWebhookEventRequest.cs new file mode 100644 index 00000000..475dbd9f --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeNotifyWebhookEventRequest.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; +using Application.Resources.Shared; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Chargebee; + +/// +/// Notifies a Chargebee event, via a webhook +/// +[Route("/webhooks/chargebee", OperationMethod.Post)] +public class ChargebeeNotifyWebhookEventRequest : IWebRequest +{ + public ChargebeeEventContent Content { get; set; } = new(); + + [JsonPropertyName("event_type")] public string? EventType { get; set; } + + public string? Id { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeReactivateSubscriptionRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeReactivateSubscriptionRequest.cs new file mode 100644 index 00000000..3bf336e4 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeReactivateSubscriptionRequest.cs @@ -0,0 +1,17 @@ +using Infrastructure.Web.Api.Interfaces; +using JetBrains.Annotations; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Chargebee; + +/// +/// Chargebee API: https://apidocs.chargebee.com/docs/api/subscriptions#reactivate_a_subscription +/// +[Route("/subscriptions/{Id}/reactivate", OperationMethod.Post)] +[UsedImplicitly] +public class ChargebeeReactivateSubscriptionRequest : UnTenantedRequest +{ + public string? Id { get; set; } + + public long? TrialEnd { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeReactivateSubscriptionResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeReactivateSubscriptionResponse.cs new file mode 100644 index 00000000..bf56a919 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeReactivateSubscriptionResponse.cs @@ -0,0 +1,10 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Chargebee; + +public class ChargebeeReactivateSubscriptionResponse : IWebResponse +{ + public ChargebeeCustomer? Customer { get; set; } + + public ChargebeeSubscription? Subscription { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeRemoveScheduledCancellationSubscriptionRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeRemoveScheduledCancellationSubscriptionRequest.cs new file mode 100644 index 00000000..1b6c7689 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeRemoveScheduledCancellationSubscriptionRequest.cs @@ -0,0 +1,16 @@ +using Infrastructure.Web.Api.Interfaces; +using JetBrains.Annotations; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Chargebee; + +/// +/// Chargebee API: https://apidocs.chargebee.com/docs/api/subscriptions#remove_scheduled_cancellation +/// +[Route("/subscriptions/{Id}/remove_scheduled_cancellation", OperationMethod.Post)] +[UsedImplicitly] +public class + ChargebeeRemoveScheduledCancellationSubscriptionRequest : UnTenantedRequest< + ChargebeeRemoveScheduledCancellationSubscriptionRequest, ChargebeeRemoveScheduledCancellationSubscriptionResponse> +{ + public string? Id { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeRemoveScheduledCancellationSubscriptionResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeRemoveScheduledCancellationSubscriptionResponse.cs new file mode 100644 index 00000000..d74fa0b8 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeRemoveScheduledCancellationSubscriptionResponse.cs @@ -0,0 +1,10 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Chargebee; + +public class ChargebeeRemoveScheduledCancellationSubscriptionResponse : IWebResponse +{ + public ChargebeeCustomer? Customer { get; set; } + + public ChargebeeSubscription? Subscription { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeUpdateCustomerBillingInfoRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeUpdateCustomerBillingInfoRequest.cs new file mode 100644 index 00000000..b09cc5e6 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeUpdateCustomerBillingInfoRequest.cs @@ -0,0 +1,17 @@ +using Infrastructure.Web.Api.Interfaces; +using JetBrains.Annotations; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Chargebee; + +/// +/// Chargebee API: https://apidocs.chargebee.com/docs/api/customers?prod_cat_ver=2#update_billing_info_for_a_customer +/// +[Route("/customers/{Id}/update_billing_info", OperationMethod.Post)] +[UsedImplicitly] +public class ChargebeeUpdateCustomerBillingInfoRequest : UnTenantedRequest +{ + public ChargebeeAddress? BillingAddress { get; set; } + + public string? Id { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeUpdateCustomerRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeUpdateCustomerRequest.cs new file mode 100644 index 00000000..54a6e0e0 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeUpdateCustomerRequest.cs @@ -0,0 +1,27 @@ +using Infrastructure.Web.Api.Interfaces; +using JetBrains.Annotations; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Chargebee; + +/// +/// Chargebee API: https://apidocs.chargebee.com/docs/api/customers?prod_cat_ver=2#update_a_customer +/// +[Route("/customers/{Id}", OperationMethod.Post)] +[UsedImplicitly] +public class + ChargebeeUpdateCustomerRequest : UnTenantedRequest +{ + public string? Company { get; set; } + + public string? Email { get; set; } + + public string? FirstName { get; set; } + + public string? Id { get; set; } + + public string? LastName { get; set; } + + public string? MetaData { get; set; } + + public string? Phone { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeUpdateCustomerResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeUpdateCustomerResponse.cs new file mode 100644 index 00000000..46492529 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeUpdateCustomerResponse.cs @@ -0,0 +1,8 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Chargebee; + +public class ChargebeeUpdateCustomerResponse : IWebResponse +{ + public ChargebeeCustomer? Customer { get; set; } +} \ No newline at end of file diff --git a/src/SaaStack.sln.DotSettings b/src/SaaStack.sln.DotSettings index d00dc37f..1a776731 100644 --- a/src/SaaStack.sln.DotSettings +++ b/src/SaaStack.sln.DotSettings @@ -1515,6 +1515,7 @@ public void When$condition$_Then$outcome$() True True True + True True True True @@ -1617,6 +1618,7 @@ public void When$condition$_Then$outcome$() True True True + True True True True @@ -1705,6 +1707,7 @@ public void When$condition$_Then$outcome$() True True True + True True True True @@ -1733,6 +1736,7 @@ public void When$condition$_Then$outcome$() True True True + True True True True @@ -1829,6 +1833,7 @@ public void When$condition$_Then$outcome$() True True True + True True True True diff --git a/src/SubscriptionsApplication.UnitTests/ChargebeeApplicationSpec.cs b/src/SubscriptionsApplication.UnitTests/ChargebeeApplicationSpec.cs new file mode 100644 index 00000000..5828ba3b --- /dev/null +++ b/src/SubscriptionsApplication.UnitTests/ChargebeeApplicationSpec.cs @@ -0,0 +1,717 @@ +using Application.Interfaces; +using Application.Resources.Shared; +using Application.Services.Shared; +using Common; +using Common.Extensions; +using Moq; +using UnitTesting.Common; +using Xunit; + +namespace SubscriptionsApplication.UnitTests; + +[Trait("Category", "Unit")] +public class ChargebeeApplicationSpec +{ + private readonly ChargebeeApplication _application; + private readonly Mock _caller; + private readonly Mock _subscriptionsApplication; + private readonly Mock _webhookNotificationAuditService; + + public ChargebeeApplicationSpec() + { + _caller = new Mock(); + var recorder = new Mock(); + _subscriptionsApplication = new Mock(); + _subscriptionsApplication.Setup(sa => sa.GetProviderStateForBuyerAsync(It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new SubscriptionMetadata + { + { "aname1", "avalue1" } + }); + _subscriptionsApplication.Setup(sa => sa.GetProviderStateForSubscriptionAsync(It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new SubscriptionMetadata + { + { "aname1", "avalue1" } + }); + _webhookNotificationAuditService = new Mock(); + _webhookNotificationAuditService.Setup(wns => wns.CreateAuditAsync(It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new WebhookNotificationAudit + { + Id = "anauditid", + Source = "asource", + EventId = "aneventid", + EventType = "aneventtype", + Status = WebhookNotificationStatus.Received + }); + _webhookNotificationAuditService.Setup(wns => wns.MarkAsProcessedAsync(It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(new WebhookNotificationAudit + { + Id = "anauditid", + Source = "asource", + EventId = "aneventid", + EventType = "aneventtype", + Status = WebhookNotificationStatus.Processed + }); + + _application = new ChargebeeApplication(recorder.Object, _subscriptionsApplication.Object, + _webhookNotificationAuditService.Object); + } + + [Fact] + public async Task WhenNotifyWebhookEventWithUnhandledEvent_ThenReturnsOk() + { + var result = await _application.NotifyWebhookEvent(_caller.Object, "aneventid", + ChargebeeEventType.Unknown.ToString(), new ChargebeeEventContent(), CancellationToken.None); + + result.Should().BeSuccess(); + _subscriptionsApplication.Verify(sa => + sa.GetProviderStateForSubscriptionAsync(It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + _subscriptionsApplication.Verify( + sa => sa.NotifyBuyerPaymentMethodChangedAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + _subscriptionsApplication.Verify( + sa => sa.NotifySubscriptionCancelledAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + _subscriptionsApplication.Verify( + sa => sa.NotifySubscriptionPlanChangedAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + _subscriptionsApplication.Verify( + sa => sa.NotifySubscriptionDeletedAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + _webhookNotificationAuditService.Verify( + wns => wns.MarkAsProcessedAsync(It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + _webhookNotificationAuditService.Verify(wns => wns.CreateAuditAsync(_caller.Object, + ChargebeeConstants.AuditSourceName, + "aneventid", ChargebeeEventType.Unknown.ToString(), "{}", It.IsAny())); + _webhookNotificationAuditService.Verify( + wns => wns.MarkAsProcessedAsync(_caller.Object, It.IsAny(), It.IsAny()), + Times.Never); + _webhookNotificationAuditService.Verify( + wns => wns.MarkAsFailedProcessingAsync(_caller.Object, It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task WhenNotifyWebhookEventWithPaymentMethodChangedAndNoSubscriptionState_ThenDoesNothing() + { + var content = new ChargebeeEventContent + { + Customer = new ChargebeeEventCustomer + { + Id = "acustomerid" + } + }; + _subscriptionsApplication.Setup(sa => + sa.GetProviderStateForBuyerAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(Error.EntityNotFound()); + + var result = await _application.NotifyWebhookEvent(_caller.Object, "aneventid", + ChargebeeEventType.PaymentSourceUpdated.ToString(), content, CancellationToken.None); + + result.Should().BeSuccess(); + _subscriptionsApplication.Verify(sa => + sa.GetProviderStateForBuyerAsync(_caller.Object, "acustomerid", It.IsAny())); + _subscriptionsApplication.Verify( + sa => sa.NotifyBuyerPaymentMethodChangedAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + _webhookNotificationAuditService.Verify( + wns => wns.MarkAsProcessedAsync(It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenNotifyWebhookEventWithPaymentMethodChangedAndStateNoDifferent_ThenDoesNothing() + { + var content = new ChargebeeEventContent + { + Customer = new ChargebeeEventCustomer + { + Id = "acustomerid", + PaymentMethod = new ChargebeePaymentMethod + { + Id = "apaymentmethodid", + Status = "apaymentstatus", + Type = "apaymenttype" + } + } + }; + _subscriptionsApplication.Setup(sa => sa.GetProviderStateForBuyerAsync(It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new SubscriptionMetadata + { + { ChargebeeConstants.MetadataProperties.CustomerId, "acustomerid" }, + { ChargebeeConstants.MetadataProperties.PaymentMethodStatus, "apaymentstatus" }, + { ChargebeeConstants.MetadataProperties.PaymentMethodType, "apaymenttype" } + }); + + var result = await _application.NotifyWebhookEvent(_caller.Object, "aneventid", + ChargebeeEventType.PaymentSourceUpdated.ToString(), content, CancellationToken.None); + + result.Should().BeSuccess(); + _subscriptionsApplication.Verify(sa => + sa.GetProviderStateForBuyerAsync(_caller.Object, "acustomerid", It.IsAny())); + _subscriptionsApplication.Verify( + sa => sa.NotifyBuyerPaymentMethodChangedAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + _webhookNotificationAuditService.Verify( + wns => wns.MarkAsProcessedAsync(It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenNotifyWebhookEventWithPaymentMethodChangedAndStateIsDifferent_ThenNotifies() + { + var content = new ChargebeeEventContent + { + Customer = new ChargebeeEventCustomer + { + Id = "acustomerid", + PaymentMethod = new ChargebeePaymentMethod + { + Id = "apaymentmethodid", + Status = "apaymentstatus", + Type = "apaymenttype" + } + } + }; + + var result = await _application.NotifyWebhookEvent(_caller.Object, "aneventid", + ChargebeeEventType.PaymentSourceUpdated.ToString(), content, CancellationToken.None); + + result.Should().BeSuccess(); + _subscriptionsApplication.Verify(sa => + sa.GetProviderStateForBuyerAsync(_caller.Object, "acustomerid", It.IsAny())); + _subscriptionsApplication.Verify( + sa => sa.NotifyBuyerPaymentMethodChangedAsync(_caller.Object, + ChargebeeConstants.ProviderName, + It.Is(metadata => + metadata.Count == 4 + && metadata["aname1"] == "avalue1" + && metadata[ChargebeeConstants.MetadataProperties.CustomerId] == "acustomerid" + && metadata[ChargebeeConstants.MetadataProperties.PaymentMethodStatus] == "apaymentstatus" + && metadata[ChargebeeConstants.MetadataProperties.PaymentMethodType] == "apaymenttype" + ), It.IsAny())); + _webhookNotificationAuditService.Verify(wns => + wns.MarkAsProcessedAsync(_caller.Object, "anauditid", It.IsAny())); + } + + [Fact] + public async Task WhenNotifyWebhookEventWithCustomerDeletedAndNoSubscriptionState_ThenDoesNothing() + { + var content = new ChargebeeEventContent + { + Customer = new ChargebeeEventCustomer + { + Id = "acustomerid" + } + }; + _subscriptionsApplication.Setup(sa => + sa.GetProviderStateForBuyerAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(Error.EntityNotFound()); + + var result = await _application.NotifyWebhookEvent(_caller.Object, "aneventid", + ChargebeeEventType.CustomerDeleted.ToString(), content, CancellationToken.None); + + result.Should().BeSuccess(); + _subscriptionsApplication.Verify(sa => + sa.GetProviderStateForBuyerAsync(_caller.Object, "acustomerid", It.IsAny())); + _subscriptionsApplication.Verify( + sa => sa.NotifyBuyerDeletedAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + _webhookNotificationAuditService.Verify( + wns => wns.MarkAsProcessedAsync(It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenNotifyWebhookEventWithCustomerDeletedAndStateIsDifferent_ThenNotifies() + { + var content = new ChargebeeEventContent + { + Customer = new ChargebeeEventCustomer + { + Id = "acustomerid", + PaymentMethod = new ChargebeePaymentMethod + { + Id = "apaymentmethodid", + Status = "apaymentstatus", + Type = "apaymenttype" + } + } + }; + + var result = await _application.NotifyWebhookEvent(_caller.Object, "aneventid", + ChargebeeEventType.CustomerDeleted.ToString(), content, CancellationToken.None); + + result.Should().BeSuccess(); + _subscriptionsApplication.Verify(sa => + sa.GetProviderStateForBuyerAsync(_caller.Object, "acustomerid", It.IsAny())); + _subscriptionsApplication.Verify( + sa => sa.NotifyBuyerDeletedAsync(_caller.Object, + ChargebeeConstants.ProviderName, + It.Is(metadata => + metadata.Count == 3 + && metadata[ChargebeeConstants.MetadataProperties.CustomerId] == "acustomerid" + && metadata[ChargebeeConstants.MetadataProperties.PaymentMethodStatus] == "apaymentstatus" + && metadata[ChargebeeConstants.MetadataProperties.PaymentMethodType] == "apaymenttype" + ), It.IsAny())); + _webhookNotificationAuditService.Verify(wns => + wns.MarkAsProcessedAsync(_caller.Object, "anauditid", It.IsAny())); + } + + [Fact] + public async Task WhenNotifyWebhookEventWithSubscriptionCancelledAndNoSubscriptionState_ThenDoesNothing() + { + var content = new ChargebeeEventContent + { + Subscription = new ChargebeeEventSubscription + { + Id = "asubscriptionid" + } + }; + _subscriptionsApplication.Setup(sa => + sa.GetProviderStateForSubscriptionAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(Error.EntityNotFound()); + + var result = await _application.NotifyWebhookEvent(_caller.Object, "aneventid", + ChargebeeEventType.SubscriptionCancelled.ToString(), content, CancellationToken.None); + + result.Should().BeSuccess(); + _subscriptionsApplication.Verify(sa => + sa.GetProviderStateForSubscriptionAsync(_caller.Object, "asubscriptionid", It.IsAny())); + _subscriptionsApplication.Verify( + sa => sa.NotifySubscriptionCancelledAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + _webhookNotificationAuditService.Verify( + wns => wns.MarkAsProcessedAsync(It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenNotifyWebhookEventWithSubscriptionCancelledAndStateNoDifferent_ThenDoesNothing() + { + var content = new ChargebeeEventContent + { + Subscription = new ChargebeeEventSubscription + { + Id = "asubscriptionid", + CustomerId = "acustomerid", + Status = "asubscriptionstatus", + Deleted = false, + CancelledAt = 1, + SubscriptionItems = + [ + new ChargebeeEventSubscriptionItem + { + Amount = 1.1M, + ItemPriceId = "anitempriceid" + } + ], + CurrencyCode = "acurrencycode", + NextBillingAt = 1, + BillingPeriod = 1, + BillingPeriodUnit = "abillingperiodunit", + TrialEnd = 1 + } + }; + _subscriptionsApplication.Setup(sa => sa.GetProviderStateForSubscriptionAsync(It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new SubscriptionMetadata + { + { ChargebeeConstants.MetadataProperties.SubscriptionId, "asubscriptionid" }, + { ChargebeeConstants.MetadataProperties.CustomerId, "acustomerid" }, + { ChargebeeConstants.MetadataProperties.SubscriptionStatus, "asubscriptionstatus" }, + { ChargebeeConstants.MetadataProperties.SubscriptionDeleted, "False" }, + { ChargebeeConstants.MetadataProperties.CanceledAt, DateTime.UnixEpoch.AddSeconds(1).ToIso8601() }, + { ChargebeeConstants.MetadataProperties.BillingAmount, "1.1" }, + { ChargebeeConstants.MetadataProperties.CurrencyCode, "acurrencycode" }, + { ChargebeeConstants.MetadataProperties.NextBillingAt, DateTime.UnixEpoch.AddSeconds(1).ToIso8601() }, + { ChargebeeConstants.MetadataProperties.BillingPeriodValue, "1" }, + { ChargebeeConstants.MetadataProperties.BillingPeriodUnit, "abillingperiodunit" }, + { ChargebeeConstants.MetadataProperties.PlanId, "anitempriceid" }, + { ChargebeeConstants.MetadataProperties.TrialEnd, DateTime.UnixEpoch.AddSeconds(1).ToIso8601() } + }); + + var result = await _application.NotifyWebhookEvent(_caller.Object, "aneventid", + ChargebeeEventType.SubscriptionCancelled.ToString(), content, CancellationToken.None); + + result.Should().BeSuccess(); + _subscriptionsApplication.Verify(sa => + sa.GetProviderStateForSubscriptionAsync(_caller.Object, "asubscriptionid", It.IsAny())); + _subscriptionsApplication.Verify( + sa => sa.NotifySubscriptionCancelledAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + _webhookNotificationAuditService.Verify( + wns => wns.MarkAsProcessedAsync(It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenNotifyWebhookEventWithSubscriptionCancelledAndStateIsDifferent_ThenNotifies() + { + var content = new ChargebeeEventContent + { + Subscription = new ChargebeeEventSubscription + { + Id = "asubscriptionid", + CustomerId = "acustomerid", + Status = "asubscriptionstatus", + Deleted = false, + CancelledAt = 1, + SubscriptionItems = + [ + new ChargebeeEventSubscriptionItem + { + Amount = 1.1M, + ItemPriceId = "anitempriceid" + } + ], + CurrencyCode = "acurrencycode", + NextBillingAt = 1, + BillingPeriod = 1, + BillingPeriodUnit = "abillingperiodunit", + TrialEnd = 1 + } + }; + + var result = await _application.NotifyWebhookEvent(_caller.Object, "aneventid", + ChargebeeEventType.SubscriptionCancelled.ToString(), content, CancellationToken.None); + + result.Should().BeSuccess(); + _subscriptionsApplication.Verify(sa => + sa.GetProviderStateForSubscriptionAsync(_caller.Object, "asubscriptionid", It.IsAny())); + _subscriptionsApplication.Verify( + sa => sa.NotifySubscriptionCancelledAsync(_caller.Object, ChargebeeConstants.ProviderName, + It.Is(metadata => + metadata.Count == 13 + && metadata["aname1"] == "avalue1" + && metadata[ChargebeeConstants.MetadataProperties.SubscriptionId] == "asubscriptionid" + && metadata[ChargebeeConstants.MetadataProperties.CustomerId] == "acustomerid" + && metadata[ChargebeeConstants.MetadataProperties.SubscriptionStatus] == "asubscriptionstatus" + && metadata[ChargebeeConstants.MetadataProperties.SubscriptionDeleted] == "False" + && metadata[ChargebeeConstants.MetadataProperties.CanceledAt] + == DateTime.UnixEpoch.AddSeconds(1).ToIso8601() + && metadata[ChargebeeConstants.MetadataProperties.BillingAmount] == "1.1" + && metadata[ChargebeeConstants.MetadataProperties.CurrencyCode] == "acurrencycode" + && metadata[ChargebeeConstants.MetadataProperties.NextBillingAt] + == DateTime.UnixEpoch.AddSeconds(1).ToIso8601() + && metadata[ChargebeeConstants.MetadataProperties.BillingPeriodValue] == "1" + && metadata[ChargebeeConstants.MetadataProperties.BillingPeriodUnit] == "abillingperiodunit" + && metadata[ChargebeeConstants.MetadataProperties.PlanId] == "anitempriceid" + && metadata[ChargebeeConstants.MetadataProperties.TrialEnd] + == DateTime.UnixEpoch.AddSeconds(1).ToIso8601() + ), It.IsAny())); + _webhookNotificationAuditService.Verify(wns => + wns.MarkAsProcessedAsync(_caller.Object, "anauditid", It.IsAny())); + } + + [Fact] + public async Task WhenNotifyWebhookEventWithSubscriptionPlanChangedAndNoSubscriptionState_ThenDoesNothing() + { + var content = new ChargebeeEventContent + { + Subscription = new ChargebeeEventSubscription + { + Id = "asubscriptionid" + } + }; + _subscriptionsApplication.Setup(sa => + sa.GetProviderStateForSubscriptionAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(Error.EntityNotFound()); + + var result = await _application.NotifyWebhookEvent(_caller.Object, "aneventid", + ChargebeeEventType.SubscriptionChanged.ToString(), content, CancellationToken.None); + + result.Should().BeSuccess(); + _subscriptionsApplication.Verify(sa => + sa.GetProviderStateForSubscriptionAsync(_caller.Object, "asubscriptionid", It.IsAny())); + _subscriptionsApplication.Verify( + sa => sa.NotifySubscriptionPlanChangedAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + _webhookNotificationAuditService.Verify( + wns => wns.MarkAsProcessedAsync(It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenNotifyWebhookEventWithSubscriptionPlanChangedAndStateNoDifferent_ThenDoesNothing() + { + var content = new ChargebeeEventContent + { + Subscription = new ChargebeeEventSubscription + { + Id = "asubscriptionid", + CustomerId = "acustomerid", + Status = "asubscriptionstatus", + Deleted = false, + CancelledAt = 1, + SubscriptionItems = + [ + new ChargebeeEventSubscriptionItem + { + Amount = 1.1M, + ItemPriceId = "anitempriceid" + } + ], + CurrencyCode = "acurrencycode", + NextBillingAt = 1, + BillingPeriod = 1, + BillingPeriodUnit = "abillingperiodunit", + TrialEnd = 1 + } + }; + _subscriptionsApplication.Setup(sa => sa.GetProviderStateForSubscriptionAsync(It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new SubscriptionMetadata + { + { ChargebeeConstants.MetadataProperties.SubscriptionId, "asubscriptionid" }, + { ChargebeeConstants.MetadataProperties.CustomerId, "acustomerid" }, + { ChargebeeConstants.MetadataProperties.SubscriptionStatus, "asubscriptionstatus" }, + { ChargebeeConstants.MetadataProperties.SubscriptionDeleted, "False" }, + { ChargebeeConstants.MetadataProperties.CanceledAt, DateTime.UnixEpoch.AddSeconds(1).ToIso8601() }, + { ChargebeeConstants.MetadataProperties.BillingAmount, "1.1" }, + { ChargebeeConstants.MetadataProperties.CurrencyCode, "acurrencycode" }, + { ChargebeeConstants.MetadataProperties.NextBillingAt, DateTime.UnixEpoch.AddSeconds(1).ToIso8601() }, + { ChargebeeConstants.MetadataProperties.BillingPeriodValue, "1" }, + { ChargebeeConstants.MetadataProperties.BillingPeriodUnit, "abillingperiodunit" }, + { ChargebeeConstants.MetadataProperties.PlanId, "anitempriceid" }, + { ChargebeeConstants.MetadataProperties.TrialEnd, DateTime.UnixEpoch.AddSeconds(1).ToIso8601() } + }); + + var result = await _application.NotifyWebhookEvent(_caller.Object, "aneventid", + ChargebeeEventType.SubscriptionChanged.ToString(), content, CancellationToken.None); + + result.Should().BeSuccess(); + _subscriptionsApplication.Verify(sa => + sa.GetProviderStateForSubscriptionAsync(_caller.Object, "asubscriptionid", It.IsAny())); + _subscriptionsApplication.Verify( + sa => sa.NotifySubscriptionPlanChangedAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + _webhookNotificationAuditService.Verify( + wns => wns.MarkAsProcessedAsync(It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenNotifyWebhookEventWithSubscriptionPlanChangedAndStateIsDifferent_ThenNotifies() + { + var content = new ChargebeeEventContent + { + Subscription = new ChargebeeEventSubscription + { + Id = "asubscriptionid", + CustomerId = "acustomerid", + Status = "asubscriptionstatus", + Deleted = false, + CancelledAt = 1, + SubscriptionItems = + [ + new ChargebeeEventSubscriptionItem + { + Amount = 1.1M, + ItemPriceId = "anitempriceid" + } + ], + CurrencyCode = "acurrencycode", + NextBillingAt = 1, + BillingPeriod = 1, + BillingPeriodUnit = "abillingperiodunit", + TrialEnd = 1 + } + }; + + var result = await _application.NotifyWebhookEvent(_caller.Object, "aneventid", + ChargebeeEventType.SubscriptionChanged.ToString(), content, CancellationToken.None); + + result.Should().BeSuccess(); + _subscriptionsApplication.Verify(sa => + sa.GetProviderStateForSubscriptionAsync(_caller.Object, "asubscriptionid", It.IsAny())); + _subscriptionsApplication.Verify( + sa => sa.NotifySubscriptionPlanChangedAsync(_caller.Object, + ChargebeeConstants.ProviderName, + It.Is(metadata => + metadata.Count == 13 + && metadata["aname1"] == "avalue1" + && metadata[ChargebeeConstants.MetadataProperties.SubscriptionId] == "asubscriptionid" + && metadata[ChargebeeConstants.MetadataProperties.CustomerId] == "acustomerid" + && metadata[ChargebeeConstants.MetadataProperties.SubscriptionStatus] == "asubscriptionstatus" + && metadata[ChargebeeConstants.MetadataProperties.SubscriptionDeleted] == "False" + && metadata[ChargebeeConstants.MetadataProperties.CanceledAt] + == DateTime.UnixEpoch.AddSeconds(1).ToIso8601() + && metadata[ChargebeeConstants.MetadataProperties.BillingAmount] == "1.1" + && metadata[ChargebeeConstants.MetadataProperties.CurrencyCode] == "acurrencycode" + && metadata[ChargebeeConstants.MetadataProperties.NextBillingAt] + == DateTime.UnixEpoch.AddSeconds(1).ToIso8601() + && metadata[ChargebeeConstants.MetadataProperties.BillingPeriodValue] == "1" + && metadata[ChargebeeConstants.MetadataProperties.BillingPeriodUnit] == "abillingperiodunit" + && metadata[ChargebeeConstants.MetadataProperties.PlanId] == "anitempriceid" + && metadata[ChargebeeConstants.MetadataProperties.TrialEnd] + == DateTime.UnixEpoch.AddSeconds(1).ToIso8601() + ), It.IsAny())); + _webhookNotificationAuditService.Verify(wns => + wns.MarkAsProcessedAsync(_caller.Object, "anauditid", It.IsAny())); + } + + [Fact] + public async Task WhenNotifyWebhookEventWithSubscriptionDeletedAndNoSubscriptionState_ThenDoesNothing() + { + var content = new ChargebeeEventContent + { + Subscription = new ChargebeeEventSubscription + { + Id = "asubscriptionid" + } + }; + _subscriptionsApplication.Setup(sa => + sa.GetProviderStateForSubscriptionAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(Error.EntityNotFound()); + + var result = await _application.NotifyWebhookEvent(_caller.Object, "aneventid", + ChargebeeEventType.SubscriptionDeleted.ToString(), content, CancellationToken.None); + + result.Should().BeSuccess(); + _subscriptionsApplication.Verify(sa => + sa.GetProviderStateForSubscriptionAsync(_caller.Object, "asubscriptionid", It.IsAny())); + _subscriptionsApplication.Verify( + sa => sa.NotifySubscriptionDeletedAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + _webhookNotificationAuditService.Verify( + wns => wns.MarkAsProcessedAsync(It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenNotifyWebhookEventWithSubscriptionDeletedAndStateNoDifferent_ThenDoesNothing() + { + var content = new ChargebeeEventContent + { + Subscription = new ChargebeeEventSubscription + { + Id = "asubscriptionid", + CustomerId = "acustomerid", + Status = "asubscriptionstatus", + Deleted = false, + CancelledAt = 1, + SubscriptionItems = + [ + new ChargebeeEventSubscriptionItem + { + Amount = 1.1M, + ItemPriceId = "anitempriceid" + } + ], + CurrencyCode = "acurrencycode", + NextBillingAt = 1, + BillingPeriod = 1, + BillingPeriodUnit = "abillingperiodunit", + TrialEnd = 1 + } + }; + _subscriptionsApplication.Setup(sa => sa.GetProviderStateForSubscriptionAsync(It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new SubscriptionMetadata + { + { ChargebeeConstants.MetadataProperties.SubscriptionId, "asubscriptionid" }, + { ChargebeeConstants.MetadataProperties.CustomerId, "acustomerid" }, + { ChargebeeConstants.MetadataProperties.SubscriptionStatus, "asubscriptionstatus" }, + { ChargebeeConstants.MetadataProperties.SubscriptionDeleted, "False" }, + { ChargebeeConstants.MetadataProperties.CanceledAt, DateTime.UnixEpoch.AddSeconds(1).ToIso8601() }, + { ChargebeeConstants.MetadataProperties.BillingAmount, "1.1" }, + { ChargebeeConstants.MetadataProperties.CurrencyCode, "acurrencycode" }, + { ChargebeeConstants.MetadataProperties.NextBillingAt, DateTime.UnixEpoch.AddSeconds(1).ToIso8601() }, + { ChargebeeConstants.MetadataProperties.BillingPeriodValue, "1" }, + { ChargebeeConstants.MetadataProperties.BillingPeriodUnit, "abillingperiodunit" }, + { ChargebeeConstants.MetadataProperties.PlanId, "anitempriceid" }, + { ChargebeeConstants.MetadataProperties.TrialEnd, DateTime.UnixEpoch.AddSeconds(1).ToIso8601() } + }); + + var result = await _application.NotifyWebhookEvent(_caller.Object, "aneventid", + ChargebeeEventType.SubscriptionDeleted.ToString(), content, CancellationToken.None); + + result.Should().BeSuccess(); + _subscriptionsApplication.Verify(sa => + sa.GetProviderStateForSubscriptionAsync(_caller.Object, "asubscriptionid", It.IsAny())); + _subscriptionsApplication.Verify( + sa => sa.NotifySubscriptionDeletedAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + _webhookNotificationAuditService.Verify( + wns => wns.MarkAsProcessedAsync(It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenNotifyWebhookEventWithSubscriptionDeletedAndStateIsDifferent_ThenNotifies() + { + var content = new ChargebeeEventContent + { + Subscription = new ChargebeeEventSubscription + { + Id = "asubscriptionid", + CustomerId = "acustomerid", + Status = "asubscriptionstatus", + Deleted = false, + CancelledAt = 1, + SubscriptionItems = + [ + new ChargebeeEventSubscriptionItem + { + Amount = 1.1M, + ItemPriceId = "anitempriceid" + } + ], + CurrencyCode = "acurrencycode", + NextBillingAt = 1, + BillingPeriod = 1, + BillingPeriodUnit = "abillingperiodunit", + TrialEnd = 1 + } + }; + + var result = await _application.NotifyWebhookEvent(_caller.Object, "aneventid", + ChargebeeEventType.SubscriptionDeleted.ToString(), content, CancellationToken.None); + + result.Should().BeSuccess(); + _subscriptionsApplication.Verify(sa => + sa.GetProviderStateForSubscriptionAsync(_caller.Object, "asubscriptionid", It.IsAny())); + _subscriptionsApplication.Verify( + sa => sa.NotifySubscriptionDeletedAsync(_caller.Object, ChargebeeConstants.ProviderName, + It.Is(metadata => + metadata.Count == 13 + && metadata["aname1"] == "avalue1" + && metadata[ChargebeeConstants.MetadataProperties.SubscriptionId] == "asubscriptionid" + && metadata[ChargebeeConstants.MetadataProperties.CustomerId] == "acustomerid" + && metadata[ChargebeeConstants.MetadataProperties.SubscriptionStatus] == "asubscriptionstatus" + && metadata[ChargebeeConstants.MetadataProperties.SubscriptionDeleted] == "False" + && metadata[ChargebeeConstants.MetadataProperties.CanceledAt] + == DateTime.UnixEpoch.AddSeconds(1).ToIso8601() + && metadata[ChargebeeConstants.MetadataProperties.BillingAmount] == "1.1" + && metadata[ChargebeeConstants.MetadataProperties.CurrencyCode] == "acurrencycode" + && metadata[ChargebeeConstants.MetadataProperties.NextBillingAt] + == DateTime.UnixEpoch.AddSeconds(1).ToIso8601() + && metadata[ChargebeeConstants.MetadataProperties.BillingPeriodValue] == "1" + && metadata[ChargebeeConstants.MetadataProperties.BillingPeriodUnit] == "abillingperiodunit" + && metadata[ChargebeeConstants.MetadataProperties.PlanId] == "anitempriceid" + && metadata[ChargebeeConstants.MetadataProperties.TrialEnd] + == DateTime.UnixEpoch.AddSeconds(1).ToIso8601() + ), It.IsAny())); + _webhookNotificationAuditService.Verify(wns => + wns.MarkAsProcessedAsync(_caller.Object, "anauditid", It.IsAny())); + } +} \ No newline at end of file diff --git a/src/SubscriptionsApplication/ChargebeeApplication.cs b/src/SubscriptionsApplication/ChargebeeApplication.cs new file mode 100644 index 00000000..8cb3991d --- /dev/null +++ b/src/SubscriptionsApplication/ChargebeeApplication.cs @@ -0,0 +1,417 @@ +using Application.Common.Extensions; +using Application.Interfaces; +using Application.Resources.Shared; +using Application.Services.Shared; +using Common; +using Common.Extensions; + +namespace SubscriptionsApplication; + +/// +/// We want to avoid raising errors for failed attempts here, so that Chargebee does not attempt to retry again. +/// Wed ont need to handle a transfer of ownership of the customer, since we dont yet store information about the +/// buyer, beyond just the Id of the Chargebee customer. +/// +public class ChargebeeApplication : IChargebeeApplication +{ + private readonly IRecorder _recorder; + private readonly ISubscriptionsApplication _subscriptionsApplication; + private readonly IWebhookNotificationAuditService _webHookNotificationAuditService; + + public ChargebeeApplication(IRecorder recorder, ISubscriptionsApplication subscriptionsApplication, + IWebhookNotificationAuditService webHookNotificationAuditService) + { + _recorder = recorder; + _subscriptionsApplication = subscriptionsApplication; + _webHookNotificationAuditService = webHookNotificationAuditService; + } + + public async Task> NotifyWebhookEvent(ICallerContext caller, string eventId, + string eventType, ChargebeeEventContent content, CancellationToken cancellationToken) + { + var @event = eventType.ToEnumOrDefault(ChargebeeEventType.Unknown); + + _recorder.TraceInformation(caller.ToCall(), "Chargebee webhook event received: {Event}", eventType); + + var created = await _webHookNotificationAuditService.CreateAuditAsync(caller, + ChargebeeConstants.AuditSourceName, + eventId, eventType, content.ToJson(false), cancellationToken); + if (created.IsFailure) + { + _recorder.TraceError(caller.ToCall(), + "Failed to audit Chargebee webhook event {Event} with {Code}: {Message}", eventType, created.Error.Code, + created.Error.Message); + return created.Error; + } + + var audit = created.Value; + switch (@event) + { + case ChargebeeEventType.CustomerDeleted: + { + var customerId = content.Customer!.Id!; + var newState = content.ToSubscriptionMetadata(); + return await NotifyBuyerDeletedAsync(caller, audit, customerId, newState, + cancellationToken); + } + + case ChargebeeEventType.CustomerChanged: + case ChargebeeEventType.PaymentSourceAdded: + case ChargebeeEventType.PaymentSourceDeleted: + case ChargebeeEventType.PaymentSourceUpdated: + case ChargebeeEventType.PaymentSourceExpired: + { + var customerId = content.Customer!.Id!; + var newState = content.ToSubscriptionMetadata(); + return await NotifyBuyerPaymentMethodChangedAsync(caller, audit, customerId, newState, + cancellationToken); + } + + case ChargebeeEventType.SubscriptionChanged: + case ChargebeeEventType.SubscriptionChangesScheduled: + case ChargebeeEventType.SubscriptionActivated: + case ChargebeeEventType.SubscriptionReactivated: + case ChargebeeEventType.SubscriptionTrialExtended: + case ChargebeeEventType.SubscriptionScheduledCancellationRemoved: + case ChargebeeEventType.SubscriptionScheduledChangesRemoved: + { + var subscriptionId = content.Subscription!.Id!; + var newState = content.ToSubscriptionMetadata(); + return await NotifySubscriptionPlanChangedAsync(caller, audit, subscriptionId, newState, + cancellationToken); + } + + case ChargebeeEventType.SubscriptionCancelled: + case ChargebeeEventType.SubscriptionCancellationScheduled: + { + var subscriptionId = content.Subscription!.Id!; + var newState = content.ToSubscriptionMetadata(); + return await NotifySubscriptionCancelledAsync(caller, audit, subscriptionId, newState, + cancellationToken); + } + + case ChargebeeEventType.SubscriptionDeleted: + { + var subscriptionId = content.Subscription!.Id!; + var newState = content.ToSubscriptionMetadata(); + return await NotifySubscriptionDeletedAsync(caller, audit, subscriptionId, newState, + cancellationToken); + } + + default: + _recorder.TraceInformation(caller.ToCall(), "Chargebee webhook event ignored: {Event}", + eventType); + return Result.Ok; + } + } + + private async Task> NotifyBuyerDeletedAsync(ICallerContext caller, WebhookNotificationAudit audit, + string customerId, SubscriptionMetadata newState, CancellationToken cancellationToken) + { + var retrievedState = await _subscriptionsApplication.GetProviderStateForBuyerAsync(caller, + customerId, cancellationToken); + if (retrievedState.IsFailure) + { + _recorder.TraceError(caller.ToCall(), + "Failed to find subscription for Chargebee buyer reference {Buyer}, with {Code}: {Message}", + customerId, retrievedState.Error.Code, retrievedState.Error.Message); + return Result.Ok; + } + + var notified = await _subscriptionsApplication.NotifyBuyerDeletedAsync(caller, + ChargebeeConstants.ProviderName, newState, cancellationToken); + if (notified.IsFailure) + { + _recorder.TraceError(caller.ToCall(), + "Failed to notify buyer deleted for Chargebee buyer reference {Buyer}, with {Code}: {Message}", + customerId, notified.Error.Code, notified.Error.Message); + + var updated = + await _webHookNotificationAuditService.MarkAsFailedProcessingAsync(caller, audit.Id, cancellationToken); + if (updated.IsFailure) + { + return updated.Error; + } + + return notified.Error; + } + + var saved = await _webHookNotificationAuditService.MarkAsProcessedAsync(caller, audit.Id, cancellationToken); + if (saved.IsFailure) + { + return saved.Error; + } + + return Result.Ok; + } + + private async Task> NotifyBuyerPaymentMethodChangedAsync(ICallerContext caller, + WebhookNotificationAudit audit, string customerId, + SubscriptionMetadata newState, CancellationToken cancellationToken) + { + var retrievedState = await _subscriptionsApplication.GetProviderStateForBuyerAsync(caller, + customerId, cancellationToken); + if (retrievedState.IsFailure) + { + _recorder.TraceError(caller.ToCall(), + "Failed to find subscription for Chargebee buyer reference {Buyer}, with {Code}: {Message}", + customerId, retrievedState.Error.Code, retrievedState.Error.Message); + return Result.Ok; + } + + var (isUnchanged, changedState) = MergeAndCompare(retrievedState.Value, newState); + if (isUnchanged) + { + return Result.Ok; + } + + var notified = await _subscriptionsApplication.NotifyBuyerPaymentMethodChangedAsync(caller, + ChargebeeConstants.ProviderName, changedState, cancellationToken); + if (notified.IsFailure) + { + _recorder.TraceError(caller.ToCall(), + "Failed to notify buyer payment method change for Chargebee buyer reference {Buyer}, with {Code}: {Message}", + customerId, notified.Error.Code, notified.Error.Message); + + var updated = + await _webHookNotificationAuditService.MarkAsFailedProcessingAsync(caller, audit.Id, cancellationToken); + if (updated.IsFailure) + { + return updated.Error; + } + + return notified.Error; + } + + var saved = await _webHookNotificationAuditService.MarkAsProcessedAsync(caller, audit.Id, cancellationToken); + if (saved.IsFailure) + { + return saved.Error; + } + + return Result.Ok; + } + + private async Task> NotifySubscriptionCancelledAsync(ICallerContext caller, + WebhookNotificationAudit audit, string subscriptionId, + SubscriptionMetadata newState, CancellationToken cancellationToken) + { + var retrievedState = await _subscriptionsApplication.GetProviderStateForSubscriptionAsync(caller, + subscriptionId, cancellationToken); + if (retrievedState.IsFailure) + { + _recorder.TraceError(caller.ToCall(), + "Failed to find subscription for Chargebee subscription reference {Subscription}, with {Code}: {Message}", + subscriptionId, retrievedState.Error.Code, retrievedState.Error.Message); + return Result.Ok; + } + + var (isUnchanged, changedState) = MergeAndCompare(retrievedState.Value, newState); + if (isUnchanged) + { + return Result.Ok; + } + + var notified = await _subscriptionsApplication.NotifySubscriptionCancelledAsync(caller, + ChargebeeConstants.ProviderName, changedState, cancellationToken); + if (notified.IsFailure) + { + _recorder.TraceError(caller.ToCall(), + "Failed to notify subscription cancelled for Chargebee subscription reference {Subscription}, with {Code}: {Message}", + subscriptionId, notified.Error.Code, notified.Error.Message); + + var updated = + await _webHookNotificationAuditService.MarkAsFailedProcessingAsync(caller, audit.Id, cancellationToken); + if (updated.IsFailure) + { + return updated.Error; + } + + return notified.Error; + } + + var saved = await _webHookNotificationAuditService.MarkAsProcessedAsync(caller, audit.Id, cancellationToken); + if (saved.IsFailure) + { + return saved.Error; + } + + return Result.Ok; + } + + private async Task> NotifySubscriptionDeletedAsync(ICallerContext caller, + WebhookNotificationAudit audit, string subscriptionId, + SubscriptionMetadata newState, CancellationToken cancellationToken) + { + var retrievedState = await _subscriptionsApplication.GetProviderStateForSubscriptionAsync(caller, + subscriptionId, cancellationToken); + if (retrievedState.IsFailure) + { + _recorder.TraceError(caller.ToCall(), + "Failed to find subscription for Chargebee subscription reference {Subscription}, with {Code}: {Message}", + subscriptionId, retrievedState.Error.Code, retrievedState.Error.Message); + return Result.Ok; + } + + var (isUnchanged, changedState) = MergeAndCompare(retrievedState.Value, newState); + if (isUnchanged) + { + return Result.Ok; + } + + var notified = await _subscriptionsApplication.NotifySubscriptionDeletedAsync(caller, + ChargebeeConstants.ProviderName, changedState, cancellationToken); + if (notified.IsFailure) + { + _recorder.TraceError(caller.ToCall(), + "Failed to notify subscription deleted for Chargebee subscription reference {Subscription}, with {Code}: {Message}", + subscriptionId, notified.Error.Code, notified.Error.Message); + + var updated = + await _webHookNotificationAuditService.MarkAsFailedProcessingAsync(caller, audit.Id, cancellationToken); + if (updated.IsFailure) + { + return updated.Error; + } + + return notified.Error; + } + + var saved = await _webHookNotificationAuditService.MarkAsProcessedAsync(caller, audit.Id, cancellationToken); + if (saved.IsFailure) + { + return saved.Error; + } + + return Result.Ok; + } + + private async Task> NotifySubscriptionPlanChangedAsync(ICallerContext caller, + WebhookNotificationAudit audit, string subscriptionId, + SubscriptionMetadata newState, CancellationToken cancellationToken) + { + var retrievedState = await _subscriptionsApplication.GetProviderStateForSubscriptionAsync(caller, + subscriptionId, cancellationToken); + if (retrievedState.IsFailure) + { + _recorder.TraceError(caller.ToCall(), + "Failed to find subscription for Chargebee subscription reference {Subscription}, with {Code}: {Message}", + subscriptionId, retrievedState.Error.Code, retrievedState.Error.Message); + return Result.Ok; + } + + var (isUnchanged, changedState) = MergeAndCompare(retrievedState.Value, newState); + if (isUnchanged) + { + return Result.Ok; + } + + var notified = await _subscriptionsApplication.NotifySubscriptionPlanChangedAsync(caller, + ChargebeeConstants.ProviderName, changedState, cancellationToken); + if (notified.IsFailure) + { + _recorder.TraceError(caller.ToCall(), + "Failed to notify subscription plan changed for Chargebee subscription reference {Subscription}, with {Code}: {Message}", + subscriptionId, notified.Error.Code, notified.Error.Message); + + var updated = + await _webHookNotificationAuditService.MarkAsFailedProcessingAsync(caller, audit.Id, cancellationToken); + if (updated.IsFailure) + { + return updated.Error; + } + + return notified.Error; + } + + var saved = await _webHookNotificationAuditService.MarkAsProcessedAsync(caller, audit.Id, cancellationToken); + if (saved.IsFailure) + { + return saved.Error; + } + + return Result.Ok; + } + + private static (bool IsSame, SubscriptionMetadata Merged) MergeAndCompare(SubscriptionMetadata oldState, + SubscriptionMetadata newState) + { + var beforeMergeCopy = new SubscriptionMetadata(oldState); + oldState.Merge(newState); + if (oldState.Equals(beforeMergeCopy)) + { + return (true, oldState); + } + + return (false, oldState); + } +} + +internal static class ChargebeeApplicationConversionExtensions +{ + public static SubscriptionMetadata ToSubscriptionMetadata(this ChargebeeEventContent content) + { + // EXTEND: Add other properties from Chargebee if needed + var metadata = new SubscriptionMetadata(); + + if (content.Customer.Exists()) + { + // Customer + metadata[ChargebeeConstants.MetadataProperties.CustomerId] = content.Customer.Id!; + // PaymentMethod + metadata!.TryAddIfTrue(ChargebeeConstants.MetadataProperties.PaymentMethodType, + content.Customer.PaymentMethod, method => method.Exists() && method.Type.HasValue(), + method => method!.Type); + metadata!.TryAddIfTrue(ChargebeeConstants.MetadataProperties.PaymentMethodStatus, + content.Customer.PaymentMethod, method => method.Exists() && method.Status.HasValue(), + method => method!.Status); + } + + if (content.Subscription.Exists()) + { + if (content.Subscription.Deleted.HasValue + && content.Subscription.Deleted.Value) + { + return metadata; + } + + // Subscription + metadata!.TryAddIfTrue(ChargebeeConstants.MetadataProperties.SubscriptionId, + content.Subscription.Id, id => id.HasValue(), id => id); + metadata[ChargebeeConstants.MetadataProperties.CustomerId] = + content.Subscription.CustomerId!; + metadata!.TryAddIfTrue(ChargebeeConstants.MetadataProperties.SubscriptionStatus, + content.Subscription.Status, status => status.HasValue(), status => status); + metadata.TryAddIfTrue(ChargebeeConstants.MetadataProperties.SubscriptionDeleted, + content.Subscription.Deleted, flag => flag.HasValue, + flag => flag.GetValueOrDefault(false).ToString()); + metadata.TryAddIfTrue(ChargebeeConstants.MetadataProperties.CanceledAt, + content.Subscription.CancelledAt, at => at.HasValue, + at => at.GetValueOrDefault(0).FromUnixTimestamp().ToIso8601()); + // Invoice + metadata.TryAddIfTrue(ChargebeeConstants.MetadataProperties.BillingAmount, + content.Subscription.SubscriptionItems, + items => items.HasAny(), items => items.First().Amount.GetValueOrDefault(0).ToString("G")); + metadata!.TryAddIfTrue(ChargebeeConstants.MetadataProperties.CurrencyCode, + content.Subscription.CurrencyCode, code => code.HasValue(), code => code); + metadata.TryAddIfTrue(ChargebeeConstants.MetadataProperties.NextBillingAt, + content.Subscription.NextBillingAt, at => at.HasValue, + at => at.GetValueOrDefault(0).FromUnixTimestamp().ToIso8601()); + // PlanPeriod + metadata.TryAddIfTrue(ChargebeeConstants.MetadataProperties.BillingPeriodValue, + content.Subscription.BillingPeriod, period => period.HasValue, + period => period.GetValueOrDefault(0).ToString()); + metadata!.TryAddIfTrue(ChargebeeConstants.MetadataProperties.BillingPeriodUnit, + content.Subscription.BillingPeriodUnit, unit => unit.HasValue(), unit => unit); + // Plan + metadata!.TryAddIfTrue(ChargebeeConstants.MetadataProperties.PlanId, + content.Subscription.SubscriptionItems, items => items.HasAny(), + items => items.First().ItemPriceId); + metadata.TryAddIfTrue(ChargebeeConstants.MetadataProperties.TrialEnd, + content.Subscription.TrialEnd, at => at.HasValue, + at => at.GetValueOrDefault(0).FromUnixTimestamp().ToIso8601()); + } + + return metadata; + } +} \ No newline at end of file diff --git a/src/SubscriptionsApplication/IChargebeeApplication.cs b/src/SubscriptionsApplication/IChargebeeApplication.cs new file mode 100644 index 00000000..064ffb0b --- /dev/null +++ b/src/SubscriptionsApplication/IChargebeeApplication.cs @@ -0,0 +1,11 @@ +using Application.Interfaces; +using Application.Resources.Shared; +using Common; + +namespace SubscriptionsApplication; + +public interface IChargebeeApplication +{ + Task> NotifyWebhookEvent(ICallerContext caller, string eventId, string eventType, + ChargebeeEventContent content, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/SubscriptionsInfrastructure.IntegrationTests/ChargebeeApiSpec.cs b/src/SubscriptionsInfrastructure.IntegrationTests/ChargebeeApiSpec.cs new file mode 100644 index 00000000..bb521771 --- /dev/null +++ b/src/SubscriptionsInfrastructure.IntegrationTests/ChargebeeApiSpec.cs @@ -0,0 +1,229 @@ +using System.Net; +using ApiHost1; +using Application.Resources.Shared; +using Application.Services.Shared; +using ChargeBee.Models; +using Common.Extensions; +using FluentAssertions; +using Infrastructure.Hosting.Common.Extensions; +using Infrastructure.Web.Api.Common.Extensions; +using Infrastructure.Web.Api.Operations.Shared._3rdParties.Chargebee; +using Infrastructure.Web.Api.Operations.Shared.Subscriptions; +using IntegrationTesting.WebApi.Common; +using Microsoft.Extensions.DependencyInjection; +using SubscriptionsInfrastructure.IntegrationTests.Stubs; +using Xunit; + +namespace SubscriptionsInfrastructure.IntegrationTests; + +[Trait("Category", "Integration.API")] +[Collection("API")] +public class ChargebeeApiSpec : WebApiSpec +{ + private readonly StubWebhookNotificationAuditService _stubAuditService; + + public ChargebeeApiSpec(WebApiSetup setup) : base(setup, OverrideDependencies) + { + EmptyAllRepositories(); + _stubAuditService = setup.GetRequiredService() + .As(); + _stubAuditService.Reset(); + } + + [Fact] + public async Task WhenNotifyWebhookEventWithUnknownEvent_ThenReturnsOk() + { + var result = await Api.PostAsync(new ChargebeeNotifyWebhookEventRequest + { + Id = "aneventid", + EventType = "aununknowneventtype", + Content = new ChargebeeEventContent() + }, req => req.SetBasicAuth("ausername")); + + result.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task WhenNotifyWebhookEventWithCustomerChangedEventAndCustomerNotExist_ThenReturnsOk() + { + var result = await Api.PostAsync(new ChargebeeNotifyWebhookEventRequest + { + Id = "aneventid", + EventType = ChargebeeEventType.CustomerChanged.ToString(), + Content = new ChargebeeEventContent + { + Customer = new ChargebeeEventCustomer + { + Id = "acustomerid", + PaymentMethod = new ChargebeePaymentMethod + { + Id = "apaymentmethodid", + Status = Customer.CustomerPaymentMethod.StatusEnum.Valid.ToString().ToCamelCase(), + Type = Customer.CustomerPaymentMethod.TypeEnum.Card.ToString().ToCamelCase() + } + } + } + }, req => req.SetBasicAuth("ausername")); + + result.StatusCode.Should().Be(HttpStatusCode.OK); + _stubAuditService.LastProcessed.Should().BeNull(); + } + + [Fact] + public async Task WhenNotifyWebhookEventWithCustomerChangedEvent_ThenReturnsOk() + { + var login = await LoginUserAsync(); + var subscription = (await Api.GetAsync(new GetSubscriptionRequest + { + Id = login.DefaultOrganizationId + }, req => req.SetJWTBearerToken(login.AccessToken))) + .Content.Value.Subscription!; + + var customerId = subscription.BuyerReference; + var result = await Api.PostAsync(new ChargebeeNotifyWebhookEventRequest + { + Id = "aneventid", + EventType = ChargebeeEventType.CustomerChanged.ToString(), + Content = new ChargebeeEventContent + { + Customer = new ChargebeeEventCustomer + { + Id = customerId, + PaymentMethod = new ChargebeePaymentMethod + { + Id = "apaymentmethodid", + Status = Customer.CustomerPaymentMethod.StatusEnum.Valid.ToString().ToCamelCase(), + Type = Customer.CustomerPaymentMethod.TypeEnum.Card.ToString().ToCamelCase() + } + } + } + }, req => req.SetBasicAuth("ausername")); + + result.StatusCode.Should().Be(HttpStatusCode.OK); + _stubAuditService.LastProcessed!.EventType.Should().Be(ChargebeeEventType.CustomerChanged.ToString()); + } + + [Fact] + public async Task WhenNotifyWebhookEventWithCustomerDeletedEvent_ThenReturnsOk() + { + var login = await LoginUserAsync(); + var subscription = (await Api.GetAsync(new GetSubscriptionRequest + { + Id = login.DefaultOrganizationId + }, req => req.SetJWTBearerToken(login.AccessToken))) + .Content.Value.Subscription!; + + var customerId = subscription.BuyerReference; + var result = await Api.PostAsync(new ChargebeeNotifyWebhookEventRequest + { + Id = "aneventid", + EventType = ChargebeeEventType.CustomerDeleted.ToString(), + Content = new ChargebeeEventContent + { + Customer = new ChargebeeEventCustomer + { + Id = customerId + } + } + }, req => req.SetBasicAuth("ausername")); + + result.StatusCode.Should().Be(HttpStatusCode.OK); + _stubAuditService.LastProcessed!.EventType.Should().Be(ChargebeeEventType.CustomerDeleted.ToString()); + } + + [Fact] + public async Task WhenNotifyWebhookEventWithSubscriptionChangedEvent_ThenReturnsOk() + { + var login = await LoginUserAsync(); + var subscription = (await Api.GetAsync(new GetSubscriptionRequest + { + Id = login.DefaultOrganizationId + }, req => req.SetJWTBearerToken(login.AccessToken))) + .Content.Value.Subscription!; + + var subscriptionId = subscription.SubscriptionReference; + var result = await Api.PostAsync(new ChargebeeNotifyWebhookEventRequest + { + Id = "aneventid", + EventType = ChargebeeEventType.SubscriptionChanged.ToString(), + Content = new ChargebeeEventContent + { + Subscription = new ChargebeeEventSubscription + { + Id = subscriptionId, + CustomerId = "acustomerid", + Status = "active" + } + } + }, req => req.SetBasicAuth("ausername")); + + result.StatusCode.Should().Be(HttpStatusCode.OK); + _stubAuditService.LastProcessed!.EventType.Should().Be(ChargebeeEventType.SubscriptionChanged.ToString()); + } + + [Fact] + public async Task WhenNotifyWebhookEventWithSubscriptionCancelledEvent_ThenReturnsOk() + { + var login = await LoginUserAsync(); + var subscription = (await Api.GetAsync(new GetSubscriptionRequest + { + Id = login.DefaultOrganizationId + }, req => req.SetJWTBearerToken(login.AccessToken))) + .Content.Value.Subscription!; + + var subscriptionId = subscription.SubscriptionReference; + var result = await Api.PostAsync(new ChargebeeNotifyWebhookEventRequest + { + Id = "aneventid", + EventType = ChargebeeEventType.SubscriptionCancelled.ToString(), + Content = new ChargebeeEventContent + { + Subscription = new ChargebeeEventSubscription + { + Id = subscriptionId, + CustomerId = "acustomerid", + Status = "active" + } + } + }, req => req.SetBasicAuth("ausername")); + + result.StatusCode.Should().Be(HttpStatusCode.OK); + _stubAuditService.LastProcessed!.EventType.Should().Be(ChargebeeEventType.SubscriptionCancelled.ToString()); + } + + [Fact] + public async Task WhenNotifyWebhookEventWithSubscriptionDeletedEvent_ThenReturnsOk() + { + var login = await LoginUserAsync(); + var subscription = (await Api.GetAsync(new GetSubscriptionRequest + { + Id = login.DefaultOrganizationId + }, req => req.SetJWTBearerToken(login.AccessToken))) + .Content.Value.Subscription!; + + var subscriptionId = subscription.SubscriptionReference; + var result = await Api.PostAsync(new ChargebeeNotifyWebhookEventRequest + { + Id = "aneventid", + EventType = ChargebeeEventType.SubscriptionDeleted.ToString(), + Content = new ChargebeeEventContent + { + Subscription = new ChargebeeEventSubscription + { + Id = subscriptionId, + CustomerId = "acustomerid", + Status = "active" + } + } + }, req => req.SetBasicAuth("ausername")); + + result.StatusCode.Should().Be(HttpStatusCode.OK); + _stubAuditService.LastProcessed!.EventType.Should().Be(ChargebeeEventType.SubscriptionDeleted.ToString()); + } + + private static void OverrideDependencies(IServiceCollection services) + { + services.AddSingleton(); + services.AddPerHttpRequest(); + } +} \ No newline at end of file diff --git a/src/SubscriptionsInfrastructure.IntegrationTests/MigrationsApiSpec.cs b/src/SubscriptionsInfrastructure.IntegrationTests/MigrationsApiSpec.cs index 5af915fa..b5b9edd2 100644 --- a/src/SubscriptionsInfrastructure.IntegrationTests/MigrationsApiSpec.cs +++ b/src/SubscriptionsInfrastructure.IntegrationTests/MigrationsApiSpec.cs @@ -1,7 +1,9 @@ using System.Net; using ApiHost1; +using Application.Resources.Shared; using Application.Services.Shared; using FluentAssertions; +using Infrastructure.Hosting.Common.Extensions; using Infrastructure.Shared.ApplicationServices; using Infrastructure.Web.Api.Common.Extensions; using Infrastructure.Web.Api.Operations.Shared.Subscriptions; @@ -9,6 +11,7 @@ using JetBrains.Annotations; using Microsoft.Extensions.DependencyInjection; using SubscriptionsDomain; +using SubscriptionsInfrastructure.IntegrationTests.Stubs; using Xunit; namespace SubscriptionsInfrastructure.IntegrationTests; @@ -79,4 +82,68 @@ private static void OverrideDependencies(IServiceCollection services) services.AddSingleton(); } } + + [Trait("Category", "Integration.API")] + [Collection("API")] + public class GivenChargebeeBillingProvider : WebApiSpec + { + public GivenChargebeeBillingProvider(WebApiSetup setup) : base(setup, OverrideDependencies) + { + EmptyAllRepositories(); + } + + [Fact] + public async Task WhenExportSubscriptionsToBeMigrated_ThenReturnsSubscriptions() + { + var login = await LoginUserAsync(); + + var request = new ExportSubscriptionsToMigrateRequest(); + var result = (await Api.GetAsync(request, + req => req.SetHMACAuth(request, "asecret"))) + .Content.Value.Subscriptions!; + + result.Count.Should().Be(1); + result[0].BuyerId.Should().Be(login.Profile!.UserId); + result[0].OwningEntityId.Should().Be(login.DefaultOrganizationId); + result[0].ProviderName.Should().Be(ChargebeeConstants.ProviderName); + result[0].ProviderState.Should().NotBeEmpty(); + result[0].Buyer[nameof(SubscriptionBuyer.Id)].Should().Be(login.Profile!.UserId); + result[0].Buyer[nameof(SubscriptionBuyer.Name)].Should() + .Be("{\"FirstName\":\"persona\",\"LastName\":\"alastname\"}"); + result[0].Buyer[nameof(SubscriptionBuyer.EmailAddress)].Should().Be(login.Profile!.EmailAddress); + result[0].Buyer[nameof(SubscriptionBuyer.Subscriber)].Should() + .Be($"{{\"EntityId\":\"{login.DefaultOrganizationId}\",\"EntityType\":\"Organization\"}}"); + result[0].Buyer[nameof(SubscriptionBuyer.Address)].Should().Be( + "{\"City\":\"\",\"CountryCode\":\"USA\",\"Line1\":\"\",\"Line2\":\"\",\"Line3\":\"\",\"State\":\"\",\"Zip\":\"\"}"); + } + + [Fact] + public async Task WhenMigrateSubscriptionWithInstalledProvider_ThenReturnsError() + { + var login = await LoginUserAsync(); + + var organizationId = login.DefaultOrganizationId!; + var request = new MigrateSubscriptionRequest + { + Id = organizationId, + ProviderName = "anunknownprovider", + ProviderState = new Dictionary + { + { "aname", "avalue" } + } + }; + + var result = + await Api.PutAsync(request, + req => req.SetHMACAuth(request, "asecret")); + + result.StatusCode.Should().Be(HttpStatusCode.BadRequest); + result.Content.Error.Detail.Should().Be(Resources.SubscriptionRoot_InstalledProviderMismatch); + } + + private static void OverrideDependencies(IServiceCollection services) + { + services.AddPerHttpRequest(); + } + } } \ No newline at end of file diff --git a/src/SubscriptionsInfrastructure.IntegrationTests/PricingApiSpec.cs b/src/SubscriptionsInfrastructure.IntegrationTests/PricingApiSpec.cs index 5d1791b6..673e9656 100644 --- a/src/SubscriptionsInfrastructure.IntegrationTests/PricingApiSpec.cs +++ b/src/SubscriptionsInfrastructure.IntegrationTests/PricingApiSpec.cs @@ -2,11 +2,13 @@ using Application.Resources.Shared; using Application.Services.Shared; using FluentAssertions; +using Infrastructure.Hosting.Common.Extensions; using Infrastructure.Shared.ApplicationServices; using Infrastructure.Web.Api.Operations.Shared.Subscriptions; using IntegrationTesting.WebApi.Common; using JetBrains.Annotations; using Microsoft.Extensions.DependencyInjection; +using SubscriptionsInfrastructure.IntegrationTests.Stubs; using Xunit; namespace SubscriptionsInfrastructure.IntegrationTests; @@ -44,4 +46,30 @@ private static void OverrideDependencies(IServiceCollection services) services.AddSingleton(); } } + + [Trait("Category", "Integration.API")] + [Collection("API")] + public class GivenChargebeeBillingProvider : WebApiSpec + { + public GivenChargebeeBillingProvider(WebApiSetup setup) : base(setup, OverrideDependencies) + { + EmptyAllRepositories(); + } + + [Fact] + public async Task WhenListPricingPlans_ThenReturnsPlans() + { + var result = (await Api.GetAsync(new ListPricingPlansRequest())).Content.Value.Plans!; + + result.Eternally.Count.Should().Be(0); + result.Monthly.Count.Should().Be(0); + result.Weekly.Count.Should().Be(0); + result.Daily.Count.Should().Be(0); + } + + private static void OverrideDependencies(IServiceCollection services) + { + services.AddPerHttpRequest(); + } + } } \ No newline at end of file diff --git a/src/SubscriptionsInfrastructure.IntegrationTests/Stubs/StubChargebeeBillingProvider.cs b/src/SubscriptionsInfrastructure.IntegrationTests/Stubs/StubChargebeeBillingProvider.cs new file mode 100644 index 00000000..a2838412 --- /dev/null +++ b/src/SubscriptionsInfrastructure.IntegrationTests/Stubs/StubChargebeeBillingProvider.cs @@ -0,0 +1,138 @@ +using Application.Interfaces; +using Application.Resources.Shared; +using Application.Services.Shared; +using Common; +using Common.Configuration; +using Common.Extensions; +using Domain.Services.Shared; +using Domain.Shared.Subscriptions; +using Infrastructure.Shared.ApplicationServices.External; +using Subscription = ChargeBee.Models.Subscription; + +namespace SubscriptionsInfrastructure.IntegrationTests.Stubs; + +/// +/// In this stub we can use the real , +/// but we have to stub out the behaviour of the for Chargebee +/// +public class StubChargebeeBillingProvider : IBillingProvider +{ + public StubChargebeeBillingProvider(IConfigurationSettings settings) + { + StateInterpreter = new ChargebeeStateInterpreter(settings); + GatewayService = new StubBillingGatewayService(); + } + + public IBillingGatewayService GatewayService { get; } + + public string ProviderName => StateInterpreter.ProviderName; + + public IBillingStateInterpreter StateInterpreter { get; } +} + +public class StubBillingGatewayService : IBillingGatewayService +{ + private const string InitialPlanId = "apaidtrial"; //see appsettings.Testing.json + + public Task> CancelSubscriptionAsync(ICallerContext caller, + CancelSubscriptionOptions options, BillingProvider provider, + CancellationToken cancellationToken) + { + var now = DateTime.UtcNow.ToNearestSecond(); + var cancellationDate = options.CancelWhen == CancelSubscriptionSchedule.Immediately + ? now + : now.AddMonths(1); + var status = options.CancelWhen == CancelSubscriptionSchedule.Immediately + ? Subscription.StatusEnum.Cancelled + : Subscription.StatusEnum.NonRenewing; + var metadata = new SubscriptionMetadata(provider.State) + { + [ChargebeeConstants.MetadataProperties.SubscriptionStatus] = status.ToString().ToCamelCase(), + [ChargebeeConstants.MetadataProperties.CanceledAt] = cancellationDate.ToIso8601() + }; + + return Task.FromResult>(metadata); + } + + public Task> ChangeSubscriptionPlanAsync(ICallerContext caller, + ChangePlanOptions options, BillingProvider provider, CancellationToken cancellationToken) + { + var metadata = new SubscriptionMetadata(provider.State) + { + [ChargebeeConstants.MetadataProperties.PlanId] = options.PlanId, + [ChargebeeConstants.MetadataProperties.SubscriptionStatus] = + Subscription.StatusEnum.Active.ToString().ToCamelCase() + }; + metadata.Remove(ChargebeeConstants.MetadataProperties.CanceledAt); + metadata.Remove(ChargebeeConstants.MetadataProperties.TrialEnd); + metadata.Remove(ChargebeeConstants.MetadataProperties.SubscriptionDeleted); + + return Task.FromResult>(metadata); + } + + public Task> ListAllPricingPlansAsync(ICallerContext caller, + CancellationToken cancellationToken) + { + return Task.FromResult>(new PricingPlans()); + } + + public Task> RestoreBuyerAsync(ICallerContext caller, SubscriptionBuyer buyer, + CancellationToken cancellationToken) + { + return Task.FromResult>(new SubscriptionMetadata + { + { ChargebeeConstants.MetadataProperties.CustomerId, buyer.Subscriber.EntityId } + }); + } + + public Task, Error>> SearchAllInvoicesAsync(ICallerContext caller, BillingProvider provider, + DateTime fromUtc, DateTime toUtc, SearchOptions searchOptions, CancellationToken cancellationToken) + { + return Task.FromResult, Error>>(new List()); + } + + public Task> SubscribeAsync(ICallerContext caller, + SubscriptionBuyer buyer, SubscribeOptions options, CancellationToken cancellationToken) + { + return Task.FromResult>(new SubscriptionMetadata + { + { ChargebeeConstants.MetadataProperties.CustomerId, buyer.Subscriber.EntityId }, + { ChargebeeConstants.MetadataProperties.SubscriptionId, CreateSubscriptionId() }, +#if TESTINGONLY + { ChargebeeConstants.MetadataProperties.PlanId, options.PlanId ?? InitialPlanId }, +#endif + { + ChargebeeConstants.MetadataProperties.SubscriptionStatus, + Subscription.StatusEnum.Active.ToString().ToCamelCase() + }, + { + ChargebeeConstants.MetadataProperties.BillingPeriodUnit, + Subscription.BillingPeriodUnitEnum.Month.ToString().ToCamelCase() + }, + { ChargebeeConstants.MetadataProperties.BillingPeriodValue, "1" } + }); + } + + public Task> TransferSubscriptionAsync(ICallerContext caller, + TransferSubscriptionOptions options, BillingProvider provider, CancellationToken cancellationToken) + { + var planId = options.PlanId ?? provider.State[ChargebeeConstants.MetadataProperties.PlanId]; + + var metadata = new SubscriptionMetadata(provider.State) + { + [ChargebeeConstants.MetadataProperties.PlanId] = planId, + [ChargebeeConstants.MetadataProperties.SubscriptionStatus] = + Subscription.StatusEnum.Active.ToString().ToCamelCase() + }; + metadata.Remove(ChargebeeConstants.MetadataProperties.CanceledAt); + metadata.Remove(ChargebeeConstants.MetadataProperties.TrialEnd); + metadata.Remove(ChargebeeConstants.MetadataProperties.SubscriptionDeleted); + + return Task.FromResult>(metadata); + } + + private static string CreateSubscriptionId() + { + return Guid.NewGuid().ToString("N"); + } +} \ No newline at end of file diff --git a/src/SubscriptionsInfrastructure.IntegrationTests/SubscriptionsApiSpec.cs b/src/SubscriptionsInfrastructure.IntegrationTests/SubscriptionsApiSpec.cs index 3d3e073d..c71530b4 100644 --- a/src/SubscriptionsInfrastructure.IntegrationTests/SubscriptionsApiSpec.cs +++ b/src/SubscriptionsInfrastructure.IntegrationTests/SubscriptionsApiSpec.cs @@ -2,17 +2,23 @@ using ApiHost1; using Application.Resources.Shared; using Application.Services.Shared; +using ChargeBee.Models; using Common; +using Common.Extensions; using Domain.Interfaces.Authorization; +using Domain.Interfaces.Validations; using FluentAssertions; +using Infrastructure.Hosting.Common.Extensions; using Infrastructure.Shared.ApplicationServices; using Infrastructure.Web.Api.Common.Extensions; +using Infrastructure.Web.Api.Operations.Shared._3rdParties.Chargebee; using Infrastructure.Web.Api.Operations.Shared.Organizations; using Infrastructure.Web.Api.Operations.Shared.Subscriptions; using IntegrationTesting.WebApi.Common; using JetBrains.Annotations; using Microsoft.Extensions.DependencyInjection; using SubscriptionsDomain; +using SubscriptionsInfrastructure.IntegrationTests.Stubs; using Xunit; namespace SubscriptionsInfrastructure.IntegrationTests; @@ -308,4 +314,319 @@ private static void OverrideDependencies(IServiceCollection services) services.AddSingleton(); } } + + [Trait("Category", "Integration.API")] + [Collection("API")] + public class GivenChargebeeBillingProvider : WebApiSpec + { + public GivenChargebeeBillingProvider(WebApiSetup setup) : base(setup, OverrideDependencies) + { + EmptyAllRepositories(); + } + + [Fact] + public async Task WhenListPricingPlans_ThenReturnsPlans() + { + var result = (await Api.GetAsync(new ListPricingPlansRequest())).Content.Value.Plans!; + + result.Eternally.Count.Should().Be(0); + } + + [Fact] + public async Task WhenGetSubscription_ThenReturns() + { + var login = await LoginUserAsync(); + + var result = (await Api.GetAsync(new GetSubscriptionRequest + { + Id = login.DefaultOrganizationId! + }, req => req.SetJWTBearerToken(login.AccessToken))).Content.Value.Subscription!; + + result.BuyerId.Should().Be(login.Profile!.UserId); + result.ProviderName.Should().Be(ChargebeeConstants.ProviderName); + result.OwningEntityId.Should().Be(login.DefaultOrganizationId); + result.BuyerReference.Should().Be(login.DefaultOrganizationId); + result.SubscriptionReference.Should().MatchRegex(CommonValidations.GuidN.Expression); + result.ProviderState.Should().NotBeEmpty(); + } + + [Fact] + public async Task WhenUpgradePlanByBuyerAndNoPaymentMethod_ThenReturnsError() + { + var login = await LoginUserAsync(); + var organizationId = login.DefaultOrganizationId!; + + var result = await Api.PutAsync(new ChangeSubscriptionPlanRequest + { + Id = organizationId, + PlanId = "apaid2" + }, req => req.SetJWTBearerToken(login.AccessToken)); + + result.StatusCode.Should().Be(HttpStatusCode.PaymentRequired); + } + + [Fact] + public async Task WhenUpgradePlanByBuyerWithPaymentMethod_ThenUpgrades() + { + var login = await LoginUserAsync(); + var organizationId = login.DefaultOrganizationId!; + await AddPaymentMethod(login); + + var result = (await Api.PutAsync(new ChangeSubscriptionPlanRequest + { + Id = organizationId, + PlanId = "apaid2" + }, req => req.SetJWTBearerToken(login.AccessToken))) + .Content.Value.Subscription!; + + result.BuyerId.Should().Be(login.User.Id); + result.OwningEntityId.Should().Be(organizationId); + result.BuyerReference.Should().Be(login.DefaultOrganizationId); + result.SubscriptionReference.Should().MatchRegex(CommonValidations.GuidN.Expression); + result.ProviderName.Should().Be(ChargebeeConstants.ProviderName); + result.Status.Should().Be(SubscriptionStatus.Activated); + result.CanceledDateUtc.Should().BeNull(); + result.Plan.Id.Should().Be("apaid2"); + result.Plan.IsTrial.Should().BeFalse(); + result.Plan.TrialEndDateUtc.Should().BeNull(); + result.Plan.Tier.Should().Be(SubscriptionTier.Professional); + result.Period.Frequency.Should().Be(1); + result.Period.Unit.Should().Be(PeriodFrequencyUnit.Month); + result.Invoice.Amount.Should().Be(0); + result.Invoice.Currency.Should().Be(CurrencyCodes.Default.Code); + result.Invoice.NextUtc.Should().BeNull(); + } + + [Fact] + public async Task WhenUpgradePlanByOtherBillingAdminAndNotCanceledNorUnsubscribed_ThenBadRequest() + { + var loginA = await LoginUserAsync(); + var (login, organizationId) = await SetupOrganization(loginA); + loginA = login; + var loginB = await LoginUserAsync(LoginUser.PersonB); + await SetupBillingAdminAsync(loginA, loginB, organizationId); + loginB = await ReAuthenticateUserAsync(loginB); + await AddPaymentMethod(loginB); + + var result = await Api.PutAsync(new ChangeSubscriptionPlanRequest + { + Id = organizationId, + PlanId = SinglePlanBillingStateInterpreter.Constants.DefaultPlanId + }, req => req.SetJWTBearerToken(loginB.AccessToken)); + + result.StatusCode.Should().Be(HttpStatusCode.BadRequest); + result.Content.Error.Detail.Should().Be(Resources.SubscriptionRoot_ChangePlan_NotClaimable); + } + + [Fact] + public async Task WhenUpgradePlanByOtherBillingAdminAndUnsubscribed_ThenTransfers() + { + var loginA = await LoginUserAsync(); + var (login, organizationId) = await SetupOrganization(loginA); + loginA = login; + var loginB = await LoginUserAsync(LoginUser.PersonB); + await SetupBillingAdminAsync(loginA, loginB, organizationId); + loginB = await ReAuthenticateUserAsync(loginB); + await AddPaymentMethod(loginB); + + var @operator = await LoginUserAsync(LoginUser.Operator); + await Api.DeleteAsync(new ForceCancelSubscriptionRequest + { + Id = organizationId + }, req => req.SetJWTBearerToken(@operator.AccessToken)); + + await PropagateDomainEventsAsync(); + var result = (await Api.PutAsync(new ChangeSubscriptionPlanRequest + { + Id = organizationId, + PlanId = "apaid2" + }, req => req.SetJWTBearerToken(loginB.AccessToken))).Content.Value.Subscription!; + + result.BuyerId.Should().Be(loginB.User.Id); + result.OwningEntityId.Should().Be(organizationId); + result.BuyerReference.Should().Be(login.DefaultOrganizationId); + result.SubscriptionReference.Should().MatchRegex(CommonValidations.GuidN.Expression); + result.ProviderName.Should().Be(ChargebeeConstants.ProviderName); + result.Status.Should().Be(SubscriptionStatus.Activated); + result.CanceledDateUtc.Should().BeNull(); + result.Plan.Id.Should().Be("apaid2"); + result.Plan.IsTrial.Should().BeFalse(); + result.Plan.TrialEndDateUtc.Should().BeNull(); + result.Plan.Tier.Should().Be(SubscriptionTier.Professional); + result.Period.Frequency.Should().Be(1); + result.Period.Unit.Should().Be(PeriodFrequencyUnit.Month); + result.Invoice.Amount.Should().Be(0); + result.Invoice.Currency.Should().Be(CurrencyCodes.Default.Code); + result.Invoice.NextUtc.Should().BeNull(); + } + + [Fact] + public async Task WhenCancel_ThenUnsubscribes() + { + var loginA = await LoginUserAsync(); + var (login, organizationId) = await SetupOrganization(loginA); + loginA = login; + + var result = (await Api.DeleteAsync(new CancelSubscriptionRequest + { + Id = organizationId + }, req => req.SetJWTBearerToken(loginA.AccessToken))).Content.Value.Subscription!; + + result.OwningEntityId.Should().Be(organizationId); + result.BuyerReference.Should().Be(login.DefaultOrganizationId); + result.SubscriptionReference.Should().MatchRegex(CommonValidations.GuidN.Expression); + result.ProviderName.Should().Be(ChargebeeConstants.ProviderName); + result.Status.Should().Be(SubscriptionStatus.Canceling); + result.CanceledDateUtc.Should().BeCloseTo(DateTime.UtcNow.AddMonths(1), TimeSpan.FromMinutes(1)); + result.Plan.Id.Should().Be("apaidtrial"); + result.Plan.IsTrial.Should().BeFalse(); + result.Plan.TrialEndDateUtc.Should().BeNull(); + result.Plan.Tier.Should().Be(SubscriptionTier.Standard); + result.Period.Frequency.Should().Be(1); + result.Period.Unit.Should().Be(PeriodFrequencyUnit.Month); + result.Invoice.Amount.Should().Be(0); + result.Invoice.Currency.Should().Be(CurrencyCodes.Default.Code); + result.Invoice.NextUtc.Should().BeNull(); + } + + [Fact] + public async Task WhenForceCancel_ThenUnsubscribes() + { + var login = await LoginUserAsync(); + var (_, organizationId) = await SetupOrganization(login); + + var @operator = await LoginUserAsync(LoginUser.Operator); + var result = (await Api.DeleteAsync(new ForceCancelSubscriptionRequest + { + Id = organizationId + }, req => req.SetJWTBearerToken(@operator.AccessToken))).Content.Value.Subscription!; + + result.OwningEntityId.Should().Be(organizationId); + result.BuyerReference.Should().Be(organizationId); + result.SubscriptionReference.Should().MatchRegex(CommonValidations.GuidN.Expression); + result.ProviderName.Should().Be(ChargebeeConstants.ProviderName); + result.Status.Should().Be(SubscriptionStatus.Canceled); + result.CanceledDateUtc.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromMinutes(1)); + result.Plan.Id.Should().Be("apaidtrial"); + result.Plan.IsTrial.Should().BeFalse(); + result.Plan.TrialEndDateUtc.Should().BeNull(); + result.Plan.Tier.Should().Be(SubscriptionTier.Unsubscribed); + result.Period.Frequency.Should().Be(1); + result.Period.Unit.Should().Be(PeriodFrequencyUnit.Month); + result.Invoice.Amount.Should().Be(0); + result.Invoice.Currency.Should().Be(CurrencyCodes.Default.Code); + result.Invoice.NextUtc.Should().BeNull(); + } + + [Fact] + public async Task WhenListBillingHistory_ThenReturnsHistory() + { + var loginA = await LoginUserAsync(); + var (login, organizationId) = await SetupOrganization(loginA); + loginA = login; + + var fromUtc = new DateTime(2024, 06, 01, 0, 0, 0, DateTimeKind.Utc); + var result = (await Api.GetAsync(new SearchSubscriptionHistoryRequest + { + Id = organizationId, + FromUtc = fromUtc, + ToUtc = null + }, req => req.SetJWTBearerToken(loginA.AccessToken))).Content.Value.Invoices!; + + result.Count.Should().Be(0); + } + + [Fact] + public async Task WhenTransferSubscriptionToOtherBillingAdmin_ThenTransfers() + { + var loginA = await LoginUserAsync(); + var (login, organizationId) = await SetupOrganization(loginA); + loginA = login; + var loginB = await LoginUserAsync(LoginUser.PersonB); + await SetupBillingAdminAsync(loginA, loginB, organizationId); + loginB = await ReAuthenticateUserAsync(loginB); + await AddPaymentMethod(loginB); + + var result = (await Api.PutAsync(new TransferSubscriptionRequest + { + Id = organizationId, + UserId = loginB.User.Id + }, req => req.SetJWTBearerToken(loginA.AccessToken))).Content.Value.Subscription!; + + result.BuyerId.Should().Be(loginB.User.Id); + result.OwningEntityId.Should().Be(organizationId); + result.BuyerReference.Should().Be(login.DefaultOrganizationId); + result.SubscriptionReference.Should().MatchRegex(CommonValidations.GuidN.Expression); + result.ProviderName.Should().Be(ChargebeeConstants.ProviderName); + result.Status.Should().Be(SubscriptionStatus.Activated); + result.CanceledDateUtc.Should().BeNull(); + result.Plan.Id.Should().Be("apaidtrial"); + result.Plan.IsTrial.Should().BeFalse(); + result.Plan.TrialEndDateUtc.Should().BeNull(); + result.Plan.Tier.Should().Be(SubscriptionTier.Standard); + result.Period.Frequency.Should().Be(1); + result.Period.Unit.Should().Be(PeriodFrequencyUnit.Month); + result.Invoice.Amount.Should().Be(0); + result.Invoice.Currency.Should().Be(CurrencyCodes.Default.Code); + result.Invoice.NextUtc.Should().BeNull(); + } + + private async Task SetupBillingAdminAsync(LoginDetails loginA, LoginDetails loginB, string organizationId) + { + await Api.PostAsync(new InviteMemberToOrganizationRequest + { + Id = organizationId, + UserId = loginB.User.Id + }, req => req.SetJWTBearerToken(loginA.AccessToken)); + + await PropagateDomainEventsAsync(); + await Api.PutAsync(new AssignRolesToOrganizationRequest + { + Id = organizationId, + UserId = loginB.User.Id, + Roles = [TenantRoles.BillingAdmin.Name] + }, req => req.SetJWTBearerToken(loginA.AccessToken)); + await PropagateDomainEventsAsync(); + } + + private async Task<(LoginDetails login, string OrganizationId)> SetupOrganization(LoginDetails login) + { + var organization = await Api.PostAsync(new CreateOrganizationRequest + { + Name = "aname" + }, req => req.SetJWTBearerToken(login.AccessToken)); + + var organizationId = organization.Content.Value.Organization!.Id; + login = await ReAuthenticateUserAsync(login); + + return (login, organizationId); + } + + private async Task AddPaymentMethod(LoginDetails login) + { + await Api.PostAsync(new ChargebeeNotifyWebhookEventRequest + { + Id = "aneventid", + EventType = ChargebeeEventType.PaymentSourceAdded.ToString(), + Content = new ChargebeeEventContent + { + Customer = new ChargebeeEventCustomer + { + Id = login.DefaultOrganizationId, + PaymentMethod = new ChargebeePaymentMethod + { + Id = "apaymentmethodid", + Status = Customer.CustomerPaymentMethod.StatusEnum.Valid.ToString().ToCamelCase(), + Type = Customer.CustomerPaymentMethod.TypeEnum.Card.ToString().ToCamelCase() + } + } + } + }, req => req.SetBasicAuth("ausername")); + } + + private static void OverrideDependencies(IServiceCollection services) + { + services.AddPerHttpRequest(); + } + } } \ No newline at end of file diff --git a/src/SubscriptionsInfrastructure.IntegrationTests/appsettings.Testing.json b/src/SubscriptionsInfrastructure.IntegrationTests/appsettings.Testing.json index 1f175195..80f36363 100644 --- a/src/SubscriptionsInfrastructure.IntegrationTests/appsettings.Testing.json +++ b/src/SubscriptionsInfrastructure.IntegrationTests/appsettings.Testing.json @@ -11,6 +11,18 @@ "LocalMachineJsonFileStore": { "RootPath": "./saastack/testing/subscriptions" } + }, + "Chargebee": { + "Plans": { + "StartingPlanId": "apaidtrial", + "Tier1PlanIds": "apaidtrial", + "Tier2PlanIds": "apaid2", + "Tier3PlanIds": "apaid3" + }, + "Webhook": { + "Username": "ausername", + "Password": "" + } } } } \ No newline at end of file diff --git a/src/SubscriptionsInfrastructure.UnitTests/Api/3rdParties/ChargebeeApiSpec.cs b/src/SubscriptionsInfrastructure.UnitTests/Api/3rdParties/ChargebeeApiSpec.cs new file mode 100644 index 00000000..51f47b35 --- /dev/null +++ b/src/SubscriptionsInfrastructure.UnitTests/Api/3rdParties/ChargebeeApiSpec.cs @@ -0,0 +1,219 @@ +using Application.Interfaces; +using Application.Resources.Shared; +using Common; +using Common.Configuration; +using FluentAssertions; +using Infrastructure.Interfaces; +using Infrastructure.Shared.ApplicationServices.External; +using Infrastructure.Web.Api.Interfaces; +using Infrastructure.Web.Api.Operations.Shared._3rdParties.Chargebee; +using Microsoft.AspNetCore.Http; +using Moq; +using SubscriptionsApplication; +using SubscriptionsInfrastructure.Api._3rdParties; +using UnitTesting.Common; +using Xunit; + +namespace SubscriptionsInfrastructure.UnitTests.Api._3rdParties; + +[Trait("Category", "Unit")] +public class ChargebeeApiSpec +{ + private readonly ChargebeeApi _api; + private readonly Mock _caller; + private readonly Mock _chargebeeApplication; + private readonly Mock _httpContextAccessor; + private readonly Mock _recorder; + + public ChargebeeApiSpec() + { + _recorder = new Mock(); + _httpContextAccessor = new Mock(); + _httpContextAccessor.Setup(hca => hca.HttpContext) + .Returns(new DefaultHttpContext + { + Request = + { + IsHttps = true, + Headers = + { + [HttpConstants.Headers.Authorization] = + $"Basic {Convert.ToBase64String("ausername:"u8.ToArray())}" + } + } + }); + _caller = new Mock(); + _caller.Setup(cc => cc.CallId).Returns("acallid"); + var callerFactory = new Mock(); + callerFactory.Setup(cf => cf.Create()) + .Returns(_caller.Object); + _chargebeeApplication = new Mock(); + var settings = new Mock(); + settings.Setup(s => + s.Platform.GetString(ChargebeeStateInterpreter.Constants.WebhookUsernameSettingName, + It.IsAny())) + .Returns("ausername"); + settings.Setup(s => + s.Platform.GetString(ChargebeeStateInterpreter.Constants.WebhookPasswordSettingName, + It.IsAny())) + .Returns(string.Empty); + + _api = new ChargebeeApi(_recorder.Object, _httpContextAccessor.Object, callerFactory.Object, settings.Object, + _chargebeeApplication.Object); + } + + [Fact] + public void WhenAuthenticateRequestAndNotHttps_ThenReturnsError() + { + _httpContextAccessor.Setup(hca => hca.HttpContext) + .Returns(new DefaultHttpContext + { + Request = { IsHttps = false } + }); + + var result = ChargebeeApi.AuthenticateRequest(_recorder.Object, _caller.Object, _httpContextAccessor.Object, + "ausername", ""); + + result.Should().BeError(ErrorCode.NotAuthenticated); + } + + [Fact] + public void WhenAuthenticateRequestAndMissingUsername_ThenReturnsError() + { + var result = + ChargebeeApi.AuthenticateRequest(_recorder.Object, _caller.Object, _httpContextAccessor.Object, "", ""); + + result.Should().BeError(ErrorCode.NotAuthenticated); + } + + [Fact] + public void WhenAuthenticateRequestAndMismatchedUsername_ThenReturnsError() + { + var result = ChargebeeApi.AuthenticateRequest(_recorder.Object, _caller.Object, _httpContextAccessor.Object, + "anotheruser", ""); + + result.Should().BeError(ErrorCode.NotAuthenticated); + } + + [Fact] + public void WhenAuthenticateRequestAndMismatchedPassword_ThenReturnsError() + { + _httpContextAccessor.Setup(hca => hca.HttpContext) + .Returns(new DefaultHttpContext + { + Request = + { + IsHttps = true, + Headers = + { + [HttpConstants.Headers.Authorization] = + $"Basic {Convert.ToBase64String("ausername:apassword"u8.ToArray())}" + } + } + }); + + var result = ChargebeeApi.AuthenticateRequest(_recorder.Object, _caller.Object, _httpContextAccessor.Object, + "ausername", "anotherpassword"); + + result.Should().BeError(ErrorCode.NotAuthenticated); + } + + [Fact] + public void WhenAuthenticateRequestAndMissingPassword_ThenReturnsError() + { + _httpContextAccessor.Setup(hca => hca.HttpContext) + .Returns(new DefaultHttpContext + { + Request = + { + IsHttps = true, + Headers = + { + [HttpConstants.Headers.Authorization] = + $"Basic {Convert.ToBase64String("ausername:apassword"u8.ToArray())}" + } + } + }); + + var result = ChargebeeApi.AuthenticateRequest(_recorder.Object, _caller.Object, _httpContextAccessor.Object, + "ausername", ""); + + result.Should().BeError(ErrorCode.NotAuthenticated); + } + + [Fact] + public void WhenAuthenticateRequestAndPasswordNotRequiredButProvided_ThenAuthenticates() + { + _httpContextAccessor.Setup(hca => hca.HttpContext) + .Returns(new DefaultHttpContext + { + Request = + { + IsHttps = true, + Headers = + { + [HttpConstants.Headers.Authorization] = + $"Basic {Convert.ToBase64String("ausername:"u8.ToArray())}" + } + } + }); + + var result = ChargebeeApi.AuthenticateRequest(_recorder.Object, _caller.Object, _httpContextAccessor.Object, + "ausername", "apassword"); + + result.Should().BeSuccess(); + } + + [Fact] + public void WhenAuthenticateRequestAndPasswordNotRequiredAndNotProvided_ThenAuthenticates() + { + _httpContextAccessor.Setup(hca => hca.HttpContext) + .Returns(new DefaultHttpContext + { + Request = + { + IsHttps = true, + Headers = + { + [HttpConstants.Headers.Authorization] = + $"Basic {Convert.ToBase64String("ausername:"u8.ToArray())}" + } + } + }); + + var result = ChargebeeApi.AuthenticateRequest(_recorder.Object, _caller.Object, _httpContextAccessor.Object, + "ausername", ""); + + result.Should().BeSuccess(); + } + + [Fact] + public async Task WhenNotifyWebhookEventAndAnyEvent_ThenNotifies() + { + var content = new ChargebeeEventContent + { + Customer = new ChargebeeEventCustomer + { + Id = "acustomerid", + PaymentMethod = new ChargebeePaymentMethod + { + Id = "apaymentmethodid", + Status = "apaymentstatus", + Type = "apaymenttype" + } + } + }; + var result = await _api.NotifyWebhookEvent(new ChargebeeNotifyWebhookEventRequest + { + Id = "aneventid", + EventType = ChargebeeEventType.CustomerChanged.ToString(), + Content = content + }, CancellationToken.None); + + result().Value.Should().BeOfType(); + _chargebeeApplication.Verify(app => app.NotifyWebhookEvent(It.Is(cc => cc.CallId == "acallid"), + "aneventid", ChargebeeEventType.CustomerChanged.ToString(), It.Is(c => + c == content + ), It.IsAny())); + } +} \ No newline at end of file diff --git a/src/SubscriptionsInfrastructure/Api/3rdParties/ChargebeeApi.cs b/src/SubscriptionsInfrastructure/Api/3rdParties/ChargebeeApi.cs new file mode 100644 index 00000000..bd633a55 --- /dev/null +++ b/src/SubscriptionsInfrastructure/Api/3rdParties/ChargebeeApi.cs @@ -0,0 +1,122 @@ +using Application.Common; +using Application.Common.Extensions; +using Application.Interfaces; +using Common; +using Common.Configuration; +using Common.Extensions; +using Infrastructure.Interfaces; +using Infrastructure.Shared.ApplicationServices.External; +using Infrastructure.Web.Api.Common.Extensions; +using Infrastructure.Web.Api.Interfaces; +using Infrastructure.Web.Api.Operations.Shared._3rdParties.Chargebee; +using Microsoft.AspNetCore.Http; +using SubscriptionsApplication; + +namespace SubscriptionsInfrastructure.Api._3rdParties; + +public class ChargebeeApi : IWebApiService +{ + private readonly ICallerContextFactory _callerFactory; + private readonly IChargebeeApplication _chargebeeApplication; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IRecorder _recorder; + private readonly string _webhookPassword; + private readonly string _webhookUsername; + + public ChargebeeApi(IRecorder recorder, IHttpContextAccessor httpContextAccessor, + ICallerContextFactory callerFactory, IConfigurationSettings settings, + IChargebeeApplication chargebeeApplication) + { + _recorder = recorder; + _httpContextAccessor = httpContextAccessor; + _callerFactory = callerFactory; + _chargebeeApplication = chargebeeApplication; + _webhookUsername = settings.Platform.GetString(ChargebeeStateInterpreter.Constants.WebhookUsernameSettingName); + _webhookPassword = settings.Platform.GetString(ChargebeeStateInterpreter.Constants.WebhookPasswordSettingName, + string.Empty); + } + + public async Task NotifyWebhookEvent(ChargebeeNotifyWebhookEventRequest request, + CancellationToken cancellationToken) + { + var caller = _callerFactory.Create(); + var authenticated = + AuthenticateRequest(_recorder, caller, _httpContextAccessor, _webhookUsername, _webhookPassword); + if (authenticated.IsFailure) + { + return () => authenticated.Error; + } + + if (request.EventType.NotExists()) + { + return () => new EmptyResponse(); + } + + var maintenance = Caller.CreateAsMaintenance(caller.CallId); + var notified = + await _chargebeeApplication.NotifyWebhookEvent(maintenance, request.Id!, request.EventType, request.Content, + cancellationToken); + + return () => notified.Match(() => new EmptyResponse(), + error => new Result(error)); + } + + /// + /// Authenticates the request with Chargebee Basic auth, with only username, or with password + /// See + /// + internal static Result AuthenticateRequest(IRecorder recorder, ICallerContext caller, + IHttpContextAccessor httpContextAccessor, string webhookUsername, string webhookPassword) + { + var httpContext = httpContextAccessor.HttpContext!; + if (!httpContext.Request.IsHttps) + { + recorder.TraceWarning(caller.ToCall(), "ChargebeeApi authentication is not secured with HTTPS"); + return Error.NotAuthenticated(); + } + + if (webhookUsername.HasNoValue()) + { + recorder.TraceWarning(caller.ToCall(), "ChargebeeApi authentication is misconfigured"); + return Error.NotAuthenticated(); + } + + var basicAuth = httpContext.Request.GetBasicAuth(); + if (!basicAuth.Username.HasValue) + { + recorder.Audit(caller.ToCall(), Audits.ChargebeeApi_WebhookAuthentication_Failed, + "Chargebee webhook failed authentication"); + return Error.NotAuthenticated(); + } + + var username = basicAuth.Username.Value; + if (username.NotEqualsOrdinal(webhookUsername)) + { + recorder.Audit(caller.ToCall(), Audits.ChargebeeApi_WebhookAuthentication_Failed, + "Chargebee webhook failed authentication"); + return Error.NotAuthenticated(); + } + + if (!basicAuth.Password.HasValue) + { + return Result.Ok; + } + + if (webhookPassword.HasNoValue()) + { + recorder.Audit(caller.ToCall(), Audits.ChargebeeApi_WebhookAuthentication_Failed, + "Chargebee webhook failed authentication"); + return Error.NotAuthenticated(); + } + + var password = basicAuth.Password.Value; + if (password.EqualsOrdinal(webhookPassword)) + { + return Result.Ok; + } + + recorder.Audit(caller.ToCall(), Audits.ChargebeeApi_WebhookAuthentication_Failed, + "Chargebee webhook failed authentication"); + return Error.NotAuthenticated(); + } +} \ No newline at end of file diff --git a/src/SubscriptionsInfrastructure/SubscriptionsModule.cs b/src/SubscriptionsInfrastructure/SubscriptionsModule.cs index 279fa472..93cd8236 100644 --- a/src/SubscriptionsInfrastructure/SubscriptionsModule.cs +++ b/src/SubscriptionsInfrastructure/SubscriptionsModule.cs @@ -46,6 +46,7 @@ public Action RegisterServices { services.AddSingleton(); + services.AddPerHttpRequest(); services .AddPerHttpRequest(); services.AddPerHttpRequest(); diff --git a/src/TestingStubApiHost/Api/StubChargebeeApi.cs b/src/TestingStubApiHost/Api/StubChargebeeApi.cs new file mode 100644 index 00000000..4aed559f --- /dev/null +++ b/src/TestingStubApiHost/Api/StubChargebeeApi.cs @@ -0,0 +1,430 @@ +using ChargeBee.Models; +using ChargeBee.Models.Enums; +using Common; +using Common.Configuration; +using Common.Extensions; +using Infrastructure.Web.Api.Interfaces; +using Infrastructure.Web.Api.Operations.Shared._3rdParties.Chargebee; + +namespace TestingStubApiHost.Api; + +[WebService("/chargebee")] +public sealed class StubChargebeeApi : StubApiBase +{ + private const string ItemFamilyId = "afamilyid"; + private static readonly List Customers = []; + private static readonly List Subscriptions = []; + private static readonly TimeSpan TrialPeriod = TimeSpan.FromDays(14); + + public StubChargebeeApi(IRecorder recorder, IConfigurationSettings settings) : base(recorder, settings) + { + } + + public async Task> CancelSubscription( + ChargebeeCancelSubscriptionRequest request, CancellationToken cancellationToken) + { + await Task.CompletedTask; + Recorder.TraceInformation(null, + "StubChargebee: Cancelling Subscription Plan via Chargebee: for: {Subscription}", + request.Id!); + + var subscription = Subscriptions.Find(s => s.Id == request.Id); + if (subscription.NotExists()) + { + return () => Error.EntityNotFound($"StubChargebee: Subscription {request.Id} not found"); + } + + var customer = Customers.Find(c => c.Id == subscription.CustomerId); + if (customer.NotExists()) + { + return () => Error.EntityNotFound($"StubChargebee: Customer {subscription.CustomerId} not found"); + } + + if (request.EndOfTerm) + { + subscription.CancelledAt = subscription.NextBillingAt; + subscription.Status = Subscription.StatusEnum.Cancelled.ToString(true); + } + + return () => + new PostResult(new ChargebeeCancelSubscriptionResponse + { + Customer = customer, + Subscription = subscription + }); + } + + public async Task> ChangeSubscription( + ChargebeeChangeSubscriptionPlanRequest request, CancellationToken cancellationToken) + { + await Task.CompletedTask; + var firstItem = request.SubscriptionItems.Single(); + + Recorder.TraceInformation(null, + "StubChargebee: Changing Subscription Plan via Chargebee: for: {Subscription} and {Plan}", + request.Id!, firstItem.ItemPriceId!); + + var subscription = Subscriptions.Find(s => s.Id == request.Id); + if (subscription.NotExists()) + { + return () => Error.EntityNotFound($"StubChargebee: Subscription {request.Id} not found"); + } + + var customer = Customers.Find(c => c.Id == subscription.CustomerId); + if (customer.NotExists()) + { + return () => Error.EntityNotFound($"StubChargebee: Customer {subscription.CustomerId} not found"); + } + + if (request.ReplaceItemsList) + { + subscription.SubscriptionItems.Clear(); + subscription.SubscriptionItems.AddRange(request.SubscriptionItems); + } + + subscription.Deleted = null; + subscription.CancelledAt = null; + + return () => + new PostResult(new ChargebeeChangeSubscriptionPlanResponse + { + Customer = customer, + Subscription = subscription + }); + } + + public async Task> CreateCustomer( + ChargebeeCreateCustomerRequest request, CancellationToken cancellationToken) + { + await Task.CompletedTask; + Recorder.TraceInformation(null, + "StubChargebee: Creating Customer via Chargebee: with {Id}: for: {FirstName} {LastName}, and {EmailAddress}", + request.Id!, request.FirstName!, request.LastName!, request.Email!); + var customer = new ChargebeeCustomer + { + Id = $"cus_{GenerateRandomIdentifier()}", + FirstName = request.FirstName, + LastName = request.LastName, + Email = request.Email, + Phone = request.Phone + }; + Customers.Add(customer); + + return () => + new PostResult(new ChargebeeCreateCustomerResponse + { + Customer = customer + }); + } + + public async Task> CreateSubscription( + ChargebeeCreateSubscriptionRequest request, CancellationToken cancellationToken) + { + await Task.CompletedTask; + var firstItem = request.SubscriptionItems.Single(); + var isTrialPlan = firstItem.ItemPriceId!.Contains("trial", StringComparison.InvariantCultureIgnoreCase); + + Recorder.TraceInformation(null, + "StubChargebee: Creating Subscription via Chargebee: for: {Customer} and {Plan}", + request.CustomerId!, firstItem.ItemPriceId); + + var customer = Customers.Find(c => c.Id == request.CustomerId); + if (customer.NotExists()) + { + return () => Error.EntityNotFound($"StubChargebee: Customer {request.CustomerId} not found"); + } + + var subscription = new ChargebeeSubscription + { + Id = $"sub_{GenerateRandomIdentifier()}", + BillingPeriod = 1, + BillingPeriodUnit = PeriodUnitEnum.Month.ToString(true), + CurrencyCode = CurrencyCodes.Default.Code, + CustomerId = customer.Id, + NextBillingAt = isTrialPlan + ? DateTime.UtcNow.Add(TrialPeriod).ToUnixSeconds() + : DateTime.UtcNow.AddMonths(1).ToUnixSeconds(), + Status = isTrialPlan + ? Subscription.StatusEnum.InTrial.ToString(true) + : Subscription.StatusEnum.Future.ToString(true), + SubscriptionItems = + [ + new ChargebeeSubscriptionItem + { + ItemPriceId = firstItem.ItemPriceId, + ItemType = firstItem.ItemType, + UnitPrice = firstItem.UnitPrice, + Quantity = firstItem.Quantity, + Amount = firstItem.Amount, + TrialEnd = firstItem.TrialEnd + } + ], + TrialEnd = isTrialPlan + ? DateTime.UtcNow.Add(TrialPeriod).ToUnixSeconds() + : 0 + }; + Subscriptions.Add(subscription); + + return () => + new PostResult(new ChargebeeCreateSubscriptionResponse + { + Customer = customer, + Subscription = subscription + }); + } + + public async Task> GetCustomer( + ChargebeeGetCustomerRequest request, CancellationToken cancellationToken) + { + await Task.CompletedTask; + Recorder.TraceInformation(null, + "StubChargebee: Fetching Customer via Chargebee: for: {Customer}", + request.Id!); + + var customer = Customers.Find(cst => cst.Id == request.Id); + if (customer.NotExists()) + { + return () => Error.EntityNotFound($"StubChargebee: Customer {request.Id} not found"); + } + + return () => new Result(new ChargebeeGetCustomerResponse + { + Customer = customer + }); + } + + public async Task> GetListItemPrices( + ChargebeeListItemPricesRequest request, CancellationToken cancellationToken) + { + await Task.CompletedTask; + Recorder.TraceInformation(null, + "StubChargebee: Listing all Plans via Chargebee"); + + return () => new Result(new ChargebeeListItemPricesResponse + { + List = Subscriptions + .DistinctBy(subscription => subscription.SubscriptionItems[0].ItemPriceId) + .Select(sub => + { + var firstItem = sub.SubscriptionItems.First(); + return new ChargebeeItemPriceList + { + ItemPrice = new ChargebeeItemPrice + { + Id = firstItem.ItemPriceId, + CurrencyCode = sub.CurrencyCode, + Description = "A stubbed plan", + ExternalName = firstItem.ItemPriceId, + FreeQuantity = 0, + ItemFamilyId = ItemFamilyId, + ItemId = firstItem.ItemPriceId, + ItemType = "plan", + Period = 1, + PeriodUnit = "month", + Price = 30, + PricingModel = "flat_fee", + Status = Subscription.StatusEnum.Active.ToString(true), + TrialPeriod = 0, + TrialPeriodUnit = "month" + } + }; + }).ToList() + }); + } + + public async Task> GetSubscription( + ChargebeeGetSubscriptionRequest request, CancellationToken cancellationToken) + { + await Task.CompletedTask; + Recorder.TraceInformation(null, + "StubChargebee: Fetching Subscription via Chargebee: for: {Subscription}", + request.Id!); + + var subscription = Subscriptions.Find(s => s.Id == request.Id); + if (subscription.NotExists()) + { + return () => Error.EntityNotFound($"StubChargebee: Subscription {request.Id} not found"); + } + + var customer = Customers.Find(c => c.Id == subscription.CustomerId); + if (customer.NotExists()) + { + return () => Error.EntityNotFound($"StubChargebee: Customer {subscription.CustomerId} not found"); + } + + return () => new Result(new ChargebeeGetSubscriptionResponse + { + Customer = customer, + Subscription = subscription + }); + } + + public async Task> ListAttachedItems( + ChargebeeListAttachedItemsRequest request, CancellationToken cancellationToken) + { + await Task.CompletedTask; + Recorder.TraceInformation(null, + "StubChargebee: Listing all AttachedItems via Chargebee"); + + return () => new Result(new ChargebeeListAttachedItemsResponse + { + List = new List() + }); + } + + public async Task> ListFeatures( + ChargebeeListFeaturesRequest request, CancellationToken cancellationToken) + { + await Task.CompletedTask; + Recorder.TraceInformation(null, + "StubChargebee: Listing all Features via Chargebee"); + + return () => new Result(new ChargebeeListFeaturesResponse + { + List = new List() + }); + } + + public async Task> ListInvoices( + ChargebeeListInvoicesRequest request, CancellationToken cancellationToken) + { + await Task.CompletedTask; + Recorder.TraceInformation(null, + "StubChargebee: Listing all Invoices via Chargebee"); + + return () => new Result(new ChargebeeListInvoicesResponse + { + List = new List() + }); + } + + public async Task> ListItemEntitlements( + ChargebeeListItemEntitlementsRequest request, CancellationToken cancellationToken) + { + await Task.CompletedTask; + Recorder.TraceInformation(null, + "StubChargebee: Listing all Item Entitlements via Chargebee"); + + return () => new Result(new ChargebeeListItemEntitlementsResponse + { + List = new List() + }); + } + + public async Task> ReactivateSubscription( + ChargebeeReactivateSubscriptionRequest request, CancellationToken cancellationToken) + { + await Task.CompletedTask; + Recorder.TraceInformation(null, + "StubChargebee: Reactivating Subscription via Chargebee: for: {Subscription}", + request.Id!); + + var subscription = Subscriptions.Find(sub => sub.Id == request.Id); + if (subscription.NotExists()) + { + return () => Error.EntityNotFound($"StubChargebee: Subscription {request.Id} not found"); + } + + var customer = Customers.Find(c => c.Id == subscription.CustomerId); + if (customer.NotExists()) + { + return () => Error.EntityNotFound($"StubChargebee: Customer {subscription.CustomerId} not found"); + } + + subscription.CancelledAt = null; + subscription.Deleted = null; + subscription.Status = Subscription.StatusEnum.Active.ToString(true); + + return () => + new PostResult(new ChargebeeReactivateSubscriptionResponse + { + Customer = customer, + Subscription = subscription + }); + } + + public async Task> + RemoveScheduledCancellationSubscription(ChargebeeRemoveScheduledCancellationSubscriptionRequest request, + CancellationToken cancellationToken) + { + await Task.CompletedTask; + Recorder.TraceInformation(null, + "StubChargebee: Removing cancellation for Subscription via Chargebee: for: {Subscription}", + request.Id!); + + var subscription = Subscriptions.Find(sub => sub.Id == request.Id); + if (subscription.NotExists()) + { + return () => Error.EntityNotFound($"StubChargebee: Subscription {request.Id} not found"); + } + + var customer = Customers.Find(cst => cst.Id == subscription.CustomerId); + if (customer.NotExists()) + { + return () => Error.EntityNotFound($"StubChargebee: Customer {subscription.CustomerId} not found"); + } + + subscription.CancelledAt = null; + subscription.Deleted = null; + subscription.Status = Subscription.StatusEnum.Active.ToString(true); + + return () => + new PostResult( + new ChargebeeRemoveScheduledCancellationSubscriptionResponse + { + Subscription = subscription, + Customer = customer + }); + } + + public async Task> UpdateCustomer( + ChargebeeUpdateCustomerRequest request, CancellationToken cancellationToken) + { + await Task.CompletedTask; + Recorder.TraceInformation(null, + "StubChargebee: Updating Customer via Chargebee: for: {Customer}", + request.Id!); + + var customer = Customers.Find(cst => cst.Id == request.Id); + if (customer.NotExists()) + { + return () => Error.EntityNotFound($"StubChargebee: Customer {request.Id} not found"); + } + + customer = request.Convert(); + + return () => + new PostResult(new ChargebeeUpdateCustomerResponse + { + Customer = customer + }); + } + + public async Task> UpdateCustomerBillingInfo( + ChargebeeUpdateCustomerBillingInfoRequest request, CancellationToken cancellationToken) + { + await Task.CompletedTask; + Recorder.TraceInformation(null, + "StubChargebee: Updating Customer billing info via Chargebee: for: {Customer}", + request.Id!); + + var customer = Customers.Find(cst => cst.Id == request.Id); + if (customer.NotExists()) + { + return () => Error.EntityNotFound($"StubChargebee: Customer {request.Id} not found"); + } + + customer = request.Convert(); + + return () => + new PostResult(new ChargebeeUpdateCustomerResponse + { + Customer = customer + }); + } + + private static string GenerateRandomIdentifier() + { + return Guid.NewGuid().ToString("N").ToLowerInvariant(); + } +} \ No newline at end of file