From 2c060d178701e115e09a6efc22e1efba59d594e4 Mon Sep 17 00:00:00 2001 From: Jezz Santos Date: Sun, 30 Jun 2024 19:21:51 +1200 Subject: [PATCH] Added adapter and tests --- src/ApiHost1/appsettings.json | 18 +- .../ChargebeeHttpServiceClientSpec.cs | 375 +++++ .../appsettings.Testing.json | 13 +- ...Provider.ChargebeeHttpServiceClientSpec.cs | 1194 +++++++++++++++ ...gProvider.ChargebeeStateInterpreterSpec.cs | 456 ++++++ ...erviceClient.InMemPricingPlansCacheSpec.cs | 49 + ...hargebeeHttpServiceClient.TestingOnlycs.cs | 457 ++++++ ...lingProvider.ChargebeeHttpServiceClient.cs | 1136 ++++++++++++++ ...llingProvider.ChargebeeStateInterpreter.cs | 437 ++++++ .../External/ChargebeeBillingProvider.cs | 24 + ...rgebeeHttpServiceClient.ChargebeeClient.cs | 1319 +++++++++++++++++ .../Infrastructure.Shared.csproj | 1 + .../Resources.Designer.cs | 74 +- src/Infrastructure.Shared/Resources.resx | 26 +- .../ChargebeeCancelSubscriptionRequest.cs | 16 + .../ChargebeeCancelSubscriptionResponse.cs | 10 + .../ChargebeeChangeSubscriptionPlanRequest.cs | 33 + ...ChargebeeChangeSubscriptionPlanResponse.cs | 10 + .../ChargebeeCreateCustomerRequest.cs | 30 + .../ChargebeeCreateCustomerResponse.cs | 8 + .../ChargebeeCreateSubscriptionRequest.cs | 22 + .../ChargebeeCreateSubscriptionResponse.cs | 48 + .../Chargebee/ChargebeeGetCustomerRequest.cs | 12 + .../Chargebee/ChargebeeGetCustomerResponse.cs | 8 + .../ChargebeeGetSubscriptionRequest.cs | 14 + .../ChargebeeGetSubscriptionResponse.cs | 10 + .../ChargebeeListAttachedItemsRequest.cs | 23 + .../ChargebeeListAttachedItemsResponse.cs | 20 + .../Chargebee/ChargebeeListFeaturesRequest.cs | 14 + .../ChargebeeListFeaturesResponse.cs | 20 + .../Chargebee/ChargebeeListInvoicesRequest.cs | 23 + .../ChargebeeListInvoicesResponse.cs | 22 + .../ChargebeeListItemEntitlementsRequest.cs | 17 + .../ChargebeeListItemEntitlementsResponse.cs | 20 + .../ChargebeeListItemPricesRequest.cs | 16 + .../ChargebeeListItemPricesResponse.cs | 46 + .../ChargebeeListSubscriptionsRequest.cs | 14 + .../ChargebeeListSubscriptionsResponse.cs | 18 + .../ChargebeeReactivateSubscriptionRequest.cs | 16 + ...ChargebeeReactivateSubscriptionResponse.cs | 10 + ...cheduledCancellationSubscriptionRequest.cs | 16 + ...heduledCancellationSubscriptionResponse.cs | 10 + ...argebeeUpdateCustomerBillingInfoRequest.cs | 16 + .../ChargebeeUpdateCustomerRequest.cs | 26 + .../ChargebeeUpdateCustomerResponse.cs | 8 + .../Api/StubChargebeeApi.cs | 430 ++++++ 46 files changed, 6572 insertions(+), 13 deletions(-) create mode 100644 src/Infrastructure.Shared.IntegrationTests/ApplicationServices/External/ChargebeeHttpServiceClientSpec.cs create mode 100644 src/Infrastructure.Shared.UnitTests/ApplicationServices/External/ChargebeeBillingProvider.ChargebeeHttpServiceClientSpec.cs create mode 100644 src/Infrastructure.Shared.UnitTests/ApplicationServices/External/ChargebeeBillingProvider.ChargebeeStateInterpreterSpec.cs create mode 100644 src/Infrastructure.Shared.UnitTests/ApplicationServices/External/ChargebeeHttpServiceClient.InMemPricingPlansCacheSpec.cs create mode 100644 src/Infrastructure.Shared/ApplicationServices/External/ChargebeeBillingProvider.ChargebeeHttpServiceClient.TestingOnlycs.cs create mode 100644 src/Infrastructure.Shared/ApplicationServices/External/ChargebeeBillingProvider.ChargebeeHttpServiceClient.cs create mode 100644 src/Infrastructure.Shared/ApplicationServices/External/ChargebeeBillingProvider.ChargebeeStateInterpreter.cs create mode 100644 src/Infrastructure.Shared/ApplicationServices/External/ChargebeeBillingProvider.cs create mode 100644 src/Infrastructure.Shared/ApplicationServices/External/ChargebeeHttpServiceClient.ChargebeeClient.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeCancelSubscriptionRequest.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeCancelSubscriptionResponse.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeChangeSubscriptionPlanRequest.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeChangeSubscriptionPlanResponse.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeCreateCustomerRequest.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeCreateCustomerResponse.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeCreateSubscriptionRequest.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeCreateSubscriptionResponse.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeGetCustomerRequest.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeGetCustomerResponse.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeGetSubscriptionRequest.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeGetSubscriptionResponse.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListAttachedItemsRequest.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListAttachedItemsResponse.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListFeaturesRequest.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListFeaturesResponse.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListInvoicesRequest.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListInvoicesResponse.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListItemEntitlementsRequest.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListItemEntitlementsResponse.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListItemPricesRequest.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListItemPricesResponse.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListSubscriptionsRequest.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListSubscriptionsResponse.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeReactivateSubscriptionRequest.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeReactivateSubscriptionResponse.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeRemoveScheduledCancellationSubscriptionRequest.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeRemoveScheduledCancellationSubscriptionResponse.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeUpdateCustomerBillingInfoRequest.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeUpdateCustomerRequest.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeUpdateCustomerResponse.cs create mode 100644 src/TestingStubApiHost/Api/StubChargebeeApi.cs diff --git a/src/ApiHost1/appsettings.json b/src/ApiHost1/appsettings.json index bcc72ab2..a970e227 100644 --- a/src/ApiHost1/appsettings.json +++ b/src/ApiHost1/appsettings.json @@ -24,20 +24,32 @@ "SenderEmailAddress": "noreply@saastack.com", "SenderDisplayName": "Support" }, + "EventNotifications": { + "SubscriptionName": "ApiHost1" + }, "SSOProvidersService": { "SSOUserTokens": { "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" + } + }, "Flagsmith": { "BaseUrl": "https://localhost:5656/flagsmith/", "EnvironmentKey": "" }, "Gravatar": { "BaseUrl": "https://localhost:5656/gravatar/" - }, - "EventNotifications": { - "SubscriptionName": "ApiHost1" } }, "Hosts": { 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..744b892b --- /dev/null +++ b/src/Infrastructure.Shared.IntegrationTests/ApplicationServices/External/ChargebeeHttpServiceClientSpec.cs @@ -0,0 +1,375 @@ +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; + +namespace Infrastructure.Shared.IntegrationTests.ApplicationServices.External; + +public abstract class ChargebeeHttpServiceClientSetupSpec : ExternalApiSpec +{ + private const string TestCustomerIdPrefix = "testcustomerid"; + 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) + ]; + private static bool _isInitialized; + + protected ChargebeeHttpServiceClientSetupSpec(ExternalApiSetup setup) : base(setup) + { + Caller = new TestCaller(); + var settings = setup.GetRequiredService(); + ServiceClient = new ChargebeeHttpServiceClient(NoOpRecorder.Instance, settings); + ProductFamilyId = settings.Platform.GetString(ChargebeeHttpServiceClient.ProductFamilyIdSettingName); + if (!_isInitialized) + { + _isInitialized = true; + SetupTestingSandboxAsync().GetAwaiter().GetResult(); + } + } + + protected ICallerContext Caller { get; } + + protected string ProductFamilyId { get; } + + protected ChargebeeHttpServiceClient ServiceClient { get; } + + protected static SubscriptionBuyer CreateBuyer() + { + return new SubscriptionBuyer + { + Address = new ProfileAddress + { + CountryCode = CountryCodes.Default.ToString() + }, + CompanyReference = GenerateRandomOrganizationId(), + 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() + { + 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); + } + + /// + /// 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(); + (await ServiceClient.CreateCustomerPaymentMethod(Caller, customer.Id, CancellationToken.None)) + .ThrowOnError(); + + var options = SubscribeOptions.Immediately; + if (planId.HasValue()) + { + options.PlanId = planId; + } + + var subscribed = await ServiceClient.SubscribeAsync(Caller, buyer, + options, CancellationToken.None); + return BillingProvider.Create(Constants.ProviderName, subscribed.Value) + .Value; + } + + private async Task SetupTestingSandboxAsync() + { + var caller = Caller; +#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(); + } + + 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 (reactivated 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 + } + + private static string GenerateRandomOrganizationId() + { + var random = Guid.NewGuid().ToString("N").Substring(0, 16).ToLowerInvariant(); + return $"{TestCustomerIdPrefix}_{random}"; + } + + 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("Trial-USD-Monthly"); + monthlyPlan1.Cost.Should().Be(50M); + monthlyPlan1.Currency.Should().Be("USD"); + monthlyPlan1.Description.Should().Be("Trial plan"); + monthlyPlan1.DisplayName.Should().Be("Trial"); + monthlyPlan1.FeatureSection.Count.Should().Be(0); + monthlyPlan1.IsRecommended.Should().BeFalse(); + monthlyPlan1.Notes.Should().Be("Trial plan"); + 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("PaidWithSetup-USD-Monthly"); + monthlyPlan2.Cost.Should().Be(100M); + monthlyPlan2.Currency.Should().Be("USD"); + monthlyPlan2.Description.Should().Be("PaidWithSetup plan"); + monthlyPlan2.DisplayName.Should().Be("PaidWithSetup"); + 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("A feature1"); + monthlyPlan2.IsRecommended.Should().BeFalse(); + monthlyPlan2.Notes.Should().Be("PaidWithSetup plan"); + monthlyPlan2.Period.Unit.Should().Be(PeriodFrequencyUnit.Month); + monthlyPlan2.Period.Frequency.Should().Be(1); + monthlyPlan2.SetupCost.Should().Be(10); + monthlyPlan2.Trial.Should().BeNull(); + + var monthlyPlan3 = result.Value.Monthly[2]; + monthlyPlan3.Id.Should().Be("Paid-USD-Monthly"); + monthlyPlan3.Cost.Should().Be(100M); + monthlyPlan3.Currency.Should().Be("USD"); + monthlyPlan3.Description.Should().Be("Paid plan"); + monthlyPlan3.DisplayName.Should().Be("Paid"); + 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("A feature1"); + monthlyPlan3.IsRecommended.Should().BeFalse(); + monthlyPlan3.Notes.Should().Be("Paid plan"); + 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 WhenSearchAllInvoicesAsyncForCustomer_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 WhenSubscribeNewCustomer_ThenSubscribes() + { + 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.Should().Contain(Constants.MetadataProperties.BillingAmount, "5000"); + result.Value.Should().Contain(Constants.MetadataProperties.BillingPeriodUnit, "Month"); + result.Value.Should().Contain(Constants.MetadataProperties.BillingPeriodValue, "1"); + result.Value.Should().NotContainKey(Constants.MetadataProperties.CanceledAt); + result.Value.Should().Contain(Constants.MetadataProperties.CurrencyCode, "USD"); + result.Value.Should().Contain(Constants.MetadataProperties.CustomerId, buyer.CompanyReference); + result.Value.Should().ContainKey(Constants.MetadataProperties.NextBillingAt) + .WhoseValue.Should().Match(value => + value.ToLong().FromUnixTimestamp().IsNear(endOfTrial, TimeSpan.FromMinutes(1))); + result.Value.Should().NotContainKey(Constants.MetadataProperties.PaymentMethodStatus); + result.Value.Should().NotContainKey(Constants.MetadataProperties.PaymentMethodType); + result.Value.Should().Contain(Constants.MetadataProperties.PlanId, "Trial-USD-Monthly"); + result.Value.Should().Contain(Constants.MetadataProperties.SubscriptionDeleted, "False"); + result.Value.Should().ContainKey(Constants.MetadataProperties.SubscriptionId) + .WhoseValue.Should().StartWith(buyer.CompanyReference); + result.Value.Should().Contain(Constants.MetadataProperties.SubscriptionStatus, "InTrial"); + result.Value.Should().ContainKey(Constants.MetadataProperties.TrialEnd) + .WhoseValue.Should().Match(value => + value.ToLong().FromUnixTimestamp().IsNear(endOfTrial, TimeSpan.FromMinutes(1))); + } + } + + [Trait("Category", "Integration.External")] + [Collection("External")] + public class GivenASubscription : ChargebeeHttpServiceClientSetupSpec + { + public GivenASubscription(ExternalApiSetup setup) : base(setup) + { + } + + [Fact] + public async Task WhenSearchAllInvoicesAsyncForSubscribedCustomer_ThenReturnsOneInvoice() + { + var provider = await SubscribeCustomerWithCardAsync(TestPlans[1].PlanId); + var now = DateTime.UtcNow.ToNearestSecond(); + var from = now.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(1); + result.Value[0].Id.Should().NotBeEmpty(); + result.Value[0].Amount.Should().Be(100); + result.Value[0].Currency.Should().Be("USD"); + result.Value[0].IncludesTax.Should().BeFalse(); + result.Value[0].InvoicedOnUtc!.Value.ToUniversalTime().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("USD"); + 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("USD"); + result.Value[0].Payment!.PaidOnUtc!.Value.ToUniversalTime().Should().BeNear(now, TimeSpan.FromMinutes(1)); + result.Value[0].Payment!.Reference.Should().NotBeEmpty(); + result.Value[0].PeriodEndUtc!.Value.ToUniversalTime().Should() + .BeNear(now.AddMonths(1), TimeSpan.FromMinutes(1)); + result.Value[0].PeriodStartUtc!.Value.ToUniversalTime().Should().BeNear(now, TimeSpan.FromMinutes(1)); + result.Value[0].Status.Should().Be(InvoiceStatus.Paid); + result.Value[0].TaxAmount.Should().Be(0); + } + } +} \ 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 61c521a9..6216e829 100644 --- a/src/Infrastructure.Shared.IntegrationTests/appsettings.Testing.json +++ b/src/Infrastructure.Shared.IntegrationTests/appsettings.Testing.json @@ -12,15 +12,12 @@ "RootPath": "./saastack/testing/external" } }, + "Chargebee": { + "TODO": "You need to provide settings to a live 'testingonly' instance of Chargebee here, or in appsettings.Testing,local.json" + }, "Flagsmith": { - "BaseUrl": "https://edge.api.flagsmith.com/api/v1/", - "EnvironmentKey": "", - "TestingOnly": { - "BaseUrl": "https://api.flagsmith.com/api/v1/", - "ApiToken": "", - "ProjectId": 0, - "EnvironmentApiKey": "" - } + "TODO": "You need to provide settings to a live 'testingonly' instance of Flagsmith here, or in appsettings.Testing,local.json", + "BaseUrl": "https://edge.api.flagsmith.com/api/v1/" }, "Gravatar": { "BaseUrl": "https://www.gravatar.com" 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..3adebd61 --- /dev/null +++ b/src/Infrastructure.Shared.UnitTests/ApplicationServices/External/ChargebeeBillingProvider.ChargebeeHttpServiceClientSpec.cs @@ -0,0 +1,1194 @@ +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 Constants = Infrastructure.Shared.ApplicationServices.External.ChargebeeStateInterpreter.Constants; +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(x => + x.FindCustomerByIdAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Optional.None); + _serviceClient.Setup(x => x.CreateCustomerForBuyerAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(CreateCustomer("acustomerid")); + _serviceClient.Setup(x => x.CreateSubscriptionForCustomerAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny>(), + It.IsAny())) + .ReturnsAsync(CreateSubscription(CreateCustomer("acustomerid"), "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(x => + x.FindCustomerByIdAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Optional.None); + _serviceClient.Setup(x => x.CreateCustomerForBuyerAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(CreateCustomer("acustomerid")); + _serviceClient.Setup(x => x.CreateSubscriptionForCustomerAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny>(), + It.IsAny())) + .ReturnsAsync(CreateSubscription(CreateCustomer("acustomerid"), "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(x => + x.FindCustomerByIdAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(customer.ToOptional()); + _serviceClient.Setup(x => x.UpdateCustomerForBuyerAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(customer); + _serviceClient.Setup(x => x.UpdateCustomerForBuyerBillingAddressAsync(It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(customer); + _serviceClient.Setup(x => x.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[Constants.MetadataProperties.CustomerId].Should().Be("acustomerid"); + result.Value[Constants.MetadataProperties.PaymentMethodStatus].Should() + .Be(Customer.CustomerPaymentMethod.StatusEnum.Valid.ToString()); + result.Value[Constants.MetadataProperties.PaymentMethodType].Should() + .Be(Customer.CustomerPaymentMethod.TypeEnum.Card.ToString()); + result.Value[Constants.MetadataProperties.SubscriptionId].Should().Be("asubscriptionid"); + result.Value[Constants.MetadataProperties.BillingPeriodValue].Should().Be("0"); + result.Value[Constants.MetadataProperties.BillingPeriodUnit].Should() + .Be(Subscription.BillingPeriodUnitEnum.Month.ToString()); + result.Value[Constants.MetadataProperties.SubscriptionStatus].Should() + .Be(Subscription.StatusEnum.Active.ToString()); + result.Value[Constants.MetadataProperties.SubscriptionDeleted].Should().Be(false.ToString()); + result.Value[Constants.MetadataProperties.CurrencyCode].Should().Be("USD"); + result.Value[Constants.MetadataProperties.PlanId].Should().Be("aplanid"); + result.Value[Constants.MetadataProperties.BillingAmount].Should().Be("0"); + result.Value[Constants.MetadataProperties.TrialEnd].Should().Be(datum.ToUnixSeconds().ToString()); + result.Value[Constants.MetadataProperties.NextBillingAt].Should().Be(datum.ToUnixSeconds().ToString()); + result.Value[Constants.MetadataProperties.CanceledAt].Should().Be(datum.ToUnixSeconds().ToString()); + _serviceClient.Verify(x => + x.FindCustomerByIdAsync(_caller.Object, "acompanyreference", It.IsAny())); + _serviceClient.Verify( + x => x.CreateCustomerForBuyerAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + _serviceClient.Verify(x => x.UpdateCustomerForBuyerAsync(_caller.Object, "acompanyreference", + buyer, It.IsAny())); + _serviceClient.Verify(x => x.UpdateCustomerForBuyerBillingAddressAsync(_caller.Object, "acompanyreference", + buyer, It.IsAny())); + _serviceClient.Verify(x => x.CreateSubscriptionForCustomerAsync(_caller.Object, "acustomerid", + "acompanyreference", "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(x => + x.FindCustomerByIdAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(customer.ToOptional()); + _serviceClient.Setup(x => x.UpdateCustomerForBuyerAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(customer); + _serviceClient.Setup(x => x.UpdateCustomerForBuyerBillingAddressAsync(It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(customer); + _serviceClient.Setup(x => x.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[Constants.MetadataProperties.CustomerId].Should().Be("acustomerid"); + result.Value[Constants.MetadataProperties.PaymentMethodStatus].Should() + .Be(Customer.CustomerPaymentMethod.StatusEnum.Valid.ToString()); + result.Value[Constants.MetadataProperties.PaymentMethodType].Should() + .Be(Customer.CustomerPaymentMethod.TypeEnum.Card.ToString()); + result.Value[Constants.MetadataProperties.SubscriptionId].Should().Be("asubscriptionid"); + result.Value[Constants.MetadataProperties.BillingPeriodValue].Should().Be("0"); + result.Value[Constants.MetadataProperties.BillingPeriodUnit].Should() + .Be(Subscription.BillingPeriodUnitEnum.Month.ToString()); + result.Value[Constants.MetadataProperties.SubscriptionStatus].Should() + .Be(Subscription.StatusEnum.Active.ToString()); + result.Value[Constants.MetadataProperties.SubscriptionDeleted].Should().Be(false.ToString()); + result.Value[Constants.MetadataProperties.CurrencyCode].Should().Be("USD"); + result.Value[Constants.MetadataProperties.PlanId].Should().Be("aplanid"); + result.Value[Constants.MetadataProperties.BillingAmount].Should().Be("0"); + result.Value[Constants.MetadataProperties.TrialEnd].Should().Be(datum.ToUnixSeconds().ToString()); + result.Value[Constants.MetadataProperties.NextBillingAt].Should().Be(datum.ToUnixSeconds().ToString()); + result.Value[Constants.MetadataProperties.CanceledAt].Should().Be(datum.ToUnixSeconds().ToString()); + _serviceClient.Verify(x => + x.FindCustomerByIdAsync(_caller.Object, "acompanyreference", It.IsAny())); + _serviceClient.Verify( + x => x.CreateCustomerForBuyerAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + _serviceClient.Verify(x => x.UpdateCustomerForBuyerAsync(_caller.Object, "acompanyreference", + buyer, It.IsAny())); + _serviceClient.Verify(x => x.UpdateCustomerForBuyerBillingAddressAsync(_caller.Object, "acompanyreference", + buyer, It.IsAny())); + _serviceClient.Verify(x => x.CreateSubscriptionForCustomerAsync(_caller.Object, "acustomerid", + "acompanyreference", "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(x => + x.FindCustomerByIdAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Optional.None); + _serviceClient.Setup(x => x.CreateCustomerForBuyerAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(customer); + _serviceClient.Setup(x => x.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[Constants.MetadataProperties.CustomerId].Should().Be("acustomerid"); + result.Value[Constants.MetadataProperties.PaymentMethodStatus].Should() + .Be(Customer.CustomerPaymentMethod.StatusEnum.Valid.ToString()); + result.Value[Constants.MetadataProperties.PaymentMethodType].Should() + .Be(Customer.CustomerPaymentMethod.TypeEnum.Card.ToString()); + result.Value[Constants.MetadataProperties.SubscriptionId].Should().Be("asubscriptionid"); + result.Value[Constants.MetadataProperties.BillingPeriodValue].Should().Be("0"); + result.Value[Constants.MetadataProperties.BillingPeriodUnit].Should() + .Be(Subscription.BillingPeriodUnitEnum.Month.ToString()); + result.Value[Constants.MetadataProperties.SubscriptionStatus].Should() + .Be(Subscription.StatusEnum.Active.ToString()); + result.Value[Constants.MetadataProperties.SubscriptionDeleted].Should().Be(false.ToString()); + result.Value[Constants.MetadataProperties.CurrencyCode].Should().Be("USD"); + result.Value[Constants.MetadataProperties.PlanId].Should().Be("aplanid"); + result.Value[Constants.MetadataProperties.BillingAmount].Should().Be("0"); + result.Value[Constants.MetadataProperties.TrialEnd].Should().Be(datum.ToUnixSeconds().ToString()); + result.Value[Constants.MetadataProperties.NextBillingAt].Should().Be(datum.ToUnixSeconds().ToString()); + result.Value[Constants.MetadataProperties.CanceledAt].Should().Be(datum.ToUnixSeconds().ToString()); + _serviceClient.Verify(x => + x.FindCustomerByIdAsync(_caller.Object, "acompanyreference", It.IsAny())); + _serviceClient.Verify(x => x.CreateCustomerForBuyerAsync(_caller.Object, "acompanyreference", + It.IsAny(), It.IsAny())); + _serviceClient.Verify( + x => x.UpdateCustomerForBuyerAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + _serviceClient.Verify( + x => x.UpdateCustomerForBuyerBillingAddressAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + _serviceClient.Verify(x => x.CreateSubscriptionForCustomerAsync(_caller.Object, "acustomerid", + "acompanyreference", "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 + { + { Constants.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; + var yesterday = DateTime.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 WhenChangeSubscriptionPlanAsyncAndActivated_ThenChangesPlan() + { + var provider = BillingProvider.Create("aprovidername", new SubscriptionMetadata + { + { Constants.MetadataProperties.CustomerId, "acustomerid" }, + { Constants.MetadataProperties.SubscriptionId, "asubscriptionid" } + }).Value; + var datum = DateTime.UtcNow.ToNearestSecond(); + var customer = CreateCustomer("acustomerid"); + 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", + OwningEntityId = "anowningentityid" + }, provider, CancellationToken.None); + + result.Should().BeSuccess(); + result.Value[Constants.MetadataProperties.CustomerId].Should().Be("acustomerid"); + result.Value[Constants.MetadataProperties.SubscriptionId].Should().Be("asubscriptionid"); + result.Value[Constants.MetadataProperties.BillingPeriodValue].Should().Be("0"); + result.Value[Constants.MetadataProperties.BillingPeriodUnit].Should() + .Be(Subscription.BillingPeriodUnitEnum.Month.ToString()); + result.Value[Constants.MetadataProperties.SubscriptionStatus].Should() + .Be(Subscription.StatusEnum.Active.ToString()); + result.Value[Constants.MetadataProperties.SubscriptionDeleted].Should().Be(false.ToString()); + result.Value[Constants.MetadataProperties.CurrencyCode].Should().Be("USD"); + result.Value[Constants.MetadataProperties.PlanId].Should().Be("aplanid2"); + result.Value[Constants.MetadataProperties.BillingAmount].Should().Be("0"); + result.Value[Constants.MetadataProperties.TrialEnd].Should().Be(datum.ToUnixSeconds().ToString()); + result.Value[Constants.MetadataProperties.NextBillingAt].Should().Be(datum.ToUnixSeconds().ToString()); + _serviceClient.Verify(x => + x.FindSubscriptionByIdAsync(_caller.Object, "asubscriptionid", It.IsAny())); + _serviceClient.Verify( + x => x.ChangeSubscriptionPlanAsync(_caller.Object, "asubscriptionid", "aplanid", Optional.None, + It.IsAny())); + } + + [Fact] + public async Task WhenChangeSubscriptionPlanAsyncAndCanceling_ThenRemovesCancellationAndChangesPlan() + { + var provider = BillingProvider.Create("aprovidername", new SubscriptionMetadata + { + { Constants.MetadataProperties.CustomerId, "acustomerid" }, + { Constants.MetadataProperties.SubscriptionId, "asubscriptionid" } + }).Value; + var datum = DateTime.UtcNow.ToNearestSecond(); + var customer = CreateCustomer("acustomerid"); + 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", + OwningEntityId = "anowningentityid" + }, provider, CancellationToken.None); + + result.Should().BeSuccess(); + result.Value[Constants.MetadataProperties.CustomerId].Should().Be("acustomerid"); + result.Value[Constants.MetadataProperties.SubscriptionId].Should().Be("asubscriptionid"); + result.Value[Constants.MetadataProperties.BillingPeriodValue].Should().Be("0"); + result.Value[Constants.MetadataProperties.BillingPeriodUnit].Should() + .Be(Subscription.BillingPeriodUnitEnum.Month.ToString()); + result.Value[Constants.MetadataProperties.SubscriptionStatus].Should() + .Be(Subscription.StatusEnum.Active.ToString()); + result.Value[Constants.MetadataProperties.SubscriptionDeleted].Should().Be(false.ToString()); + result.Value[Constants.MetadataProperties.CurrencyCode].Should().Be("USD"); + result.Value[Constants.MetadataProperties.PlanId].Should().Be("aplanid2"); + result.Value[Constants.MetadataProperties.BillingAmount].Should().Be("0"); + result.Value[Constants.MetadataProperties.TrialEnd].Should().Be(datum.ToUnixSeconds().ToString()); + result.Value[Constants.MetadataProperties.NextBillingAt].Should().Be(datum.ToUnixSeconds().ToString()); + _serviceClient.Verify(x => + x.FindSubscriptionByIdAsync(_caller.Object, "asubscriptionid", It.IsAny())); + _serviceClient.Setup(sc => + sc.RemoveScheduledSubscriptionCancellationAsync(_caller.Object, "asubscriptionid", + It.IsAny())); + _serviceClient.Verify( + x => x.ChangeSubscriptionPlanAsync(_caller.Object, "asubscriptionid", "aplanid", Optional.None, + It.IsAny())); + } + + [Fact] + public async Task WhenChangeSubscriptionPlanAsyncAndCanceled_ThenReactivatesAndChangesPlan() + { + var provider = BillingProvider.Create("aprovidername", new SubscriptionMetadata + { + { Constants.MetadataProperties.CustomerId, "acustomerid" }, + { Constants.MetadataProperties.SubscriptionId, "asubscriptionid" } + }).Value; + var datum = DateTime.UtcNow.ToNearestSecond(); + var customer = CreateCustomer("acustomerid"); + 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", + OwningEntityId = "anowningentityid" + }, provider, CancellationToken.None); + + result.Should().BeSuccess(); + result.Value[Constants.MetadataProperties.CustomerId].Should().Be("acustomerid"); + result.Value[Constants.MetadataProperties.SubscriptionId].Should().Be("asubscriptionid"); + result.Value[Constants.MetadataProperties.BillingPeriodValue].Should().Be("0"); + result.Value[Constants.MetadataProperties.BillingPeriodUnit].Should() + .Be(Subscription.BillingPeriodUnitEnum.Month.ToString()); + result.Value[Constants.MetadataProperties.SubscriptionStatus].Should() + .Be(Subscription.StatusEnum.Active.ToString()); + result.Value[Constants.MetadataProperties.SubscriptionDeleted].Should().Be(false.ToString()); + result.Value[Constants.MetadataProperties.CurrencyCode].Should().Be("USD"); + result.Value[Constants.MetadataProperties.PlanId].Should().Be("aplanid2"); + result.Value[Constants.MetadataProperties.BillingAmount].Should().Be("0"); + result.Value[Constants.MetadataProperties.TrialEnd].Should().Be(datum.ToUnixSeconds().ToString()); + result.Value[Constants.MetadataProperties.NextBillingAt].Should().Be(datum.ToUnixSeconds().ToString()); + _serviceClient.Verify(x => + x.FindSubscriptionByIdAsync(_caller.Object, "asubscriptionid", It.IsAny())); + _serviceClient.Setup(sc => sc.ReactivateSubscriptionAsync(_caller.Object, "asubscriptionid", + datum.ToUnixSeconds(), It.IsAny())); + _serviceClient.Verify( + x => x.ChangeSubscriptionPlanAsync(_caller.Object, "asubscriptionid", "aplanid", Optional.None, + It.IsAny())); + } + + [Fact] + public async Task WhenChangeSubscriptionPlanAsyncAndUnsubscribed_ThenChangesPlan() + { + var provider = BillingProvider.Create("aprovidername", new SubscriptionMetadata + { + { Constants.MetadataProperties.CustomerId, "acustomerid" }, + { Constants.MetadataProperties.SubscriptionId, "asubscriptionid" } + }).Value; + var datum = DateTime.UtcNow.ToNearestSecond(); + var customer = CreateCustomer("acustomerid"); + var subscription = CreateSubscription(customer, "asubscriptionid", status: Subscription.StatusEnum.UnKnown); + 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.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", + OwningEntityId = "anowningentityid" + }, provider, CancellationToken.None); + + result.Should().BeSuccess(); + result.Value[Constants.MetadataProperties.CustomerId].Should().Be("acustomerid"); + result.Value[Constants.MetadataProperties.SubscriptionId].Should().Be("asubscriptionid"); + result.Value[Constants.MetadataProperties.BillingPeriodValue].Should().Be("0"); + result.Value[Constants.MetadataProperties.BillingPeriodUnit].Should() + .Be(Subscription.BillingPeriodUnitEnum.Month.ToString()); + result.Value[Constants.MetadataProperties.SubscriptionStatus].Should() + .Be(Subscription.StatusEnum.Active.ToString()); + result.Value[Constants.MetadataProperties.SubscriptionDeleted].Should().Be(false.ToString()); + result.Value[Constants.MetadataProperties.CurrencyCode].Should().Be("USD"); + result.Value[Constants.MetadataProperties.PlanId].Should().Be("aplanid2"); + result.Value[Constants.MetadataProperties.BillingAmount].Should().Be("0"); + result.Value[Constants.MetadataProperties.TrialEnd].Should().Be(datum.ToUnixSeconds().ToString()); + result.Value[Constants.MetadataProperties.NextBillingAt].Should().Be(datum.ToUnixSeconds().ToString()); + _serviceClient.Verify(x => + x.FindSubscriptionByIdAsync(_caller.Object, "asubscriptionid", It.IsAny())); + _serviceClient.Setup(sc => sc.CreateSubscriptionForCustomerAsync(_caller.Object, "acustomerid", + "asubscriptionid", "aplanid", Optional.None, Optional.None, It.IsAny())); + _serviceClient.Verify( + x => x.ChangeSubscriptionPlanAsync(_caller.Object, "asubscriptionid", "aplanid", Optional.None, + It.IsAny())); + } + + [Fact] + public async Task WhenCancelSubscriptionAsyncAndImmediatelyWithDate_ThenReturnsError() + { + var provider = BillingProvider.Create("aprovidername", new SubscriptionMetadata + { + { Constants.MetadataProperties.CustomerId, "acustomerid" }, + { Constants.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 + { + { Constants.MetadataProperties.CustomerId, "acustomerid" }, + { Constants.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 + { + { Constants.MetadataProperties.CustomerId, "acustomerid" }, + { Constants.MetadataProperties.SubscriptionId, "asubscriptionid" } + }).Value; + var options = new CancelSubscriptionOptions + { + CancelWhen = CancelSubscriptionSchedule.Immediately, + FutureTime = null + }; + var datum = DateTime.UtcNow.ToNearestSecond(); + var subscription = CreateSubscription(CreateCustomer("acustomerid"), "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[Constants.MetadataProperties.CustomerId].Should().Be("acustomerid"); + result.Value[Constants.MetadataProperties.SubscriptionId].Should().Be("asubscriptionid"); + result.Value[Constants.MetadataProperties.BillingPeriodValue].Should().Be("0"); + result.Value[Constants.MetadataProperties.BillingPeriodUnit].Should() + .Be(Subscription.BillingPeriodUnitEnum.Month.ToString()); + result.Value[Constants.MetadataProperties.SubscriptionStatus].Should() + .Be(Subscription.StatusEnum.Active.ToString()); + result.Value[Constants.MetadataProperties.SubscriptionDeleted].Should().Be(false.ToString()); + result.Value[Constants.MetadataProperties.CurrencyCode].Should().Be("USD"); + result.Value[Constants.MetadataProperties.PlanId].Should().Be("aplanid"); + result.Value[Constants.MetadataProperties.BillingAmount].Should().Be("0"); + result.Value[Constants.MetadataProperties.NextBillingAt].Should().Be(datum.ToUnixSeconds().ToString()); + result.Value[Constants.MetadataProperties.CanceledAt].Should().Be(datum.ToUnixSeconds().ToString()); + _serviceClient.Verify(x => + x.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 + { + { Constants.MetadataProperties.CustomerId, "acustomerid" }, + { Constants.MetadataProperties.SubscriptionId, "asubscriptionid" } + }).Value; + var options = new CancelSubscriptionOptions + { + CancelWhen = CancelSubscriptionSchedule.EndOfTerm, + FutureTime = null + }; + var datum = DateTime.UtcNow.ToNearestSecond(); + var subscription = CreateSubscription(CreateCustomer("acustomerid"), "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[Constants.MetadataProperties.CustomerId].Should().Be("acustomerid"); + result.Value[Constants.MetadataProperties.SubscriptionId].Should().Be("asubscriptionid"); + result.Value[Constants.MetadataProperties.BillingPeriodValue].Should().Be("0"); + result.Value[Constants.MetadataProperties.BillingPeriodUnit].Should() + .Be(Subscription.BillingPeriodUnitEnum.Month.ToString()); + result.Value[Constants.MetadataProperties.SubscriptionStatus].Should() + .Be(Subscription.StatusEnum.Active.ToString()); + result.Value[Constants.MetadataProperties.SubscriptionDeleted].Should().Be(false.ToString()); + result.Value[Constants.MetadataProperties.CurrencyCode].Should().Be("USD"); + result.Value[Constants.MetadataProperties.PlanId].Should().Be("aplanid"); + result.Value[Constants.MetadataProperties.BillingAmount].Should().Be("0"); + result.Value[Constants.MetadataProperties.NextBillingAt].Should().Be(datum.ToUnixSeconds().ToString()); + result.Value[Constants.MetadataProperties.CanceledAt].Should().Be(datum.ToUnixSeconds().ToString()); + _serviceClient.Verify(x => + x.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 + { + { Constants.MetadataProperties.CustomerId, "acustomerid" }, + { Constants.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"), "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[Constants.MetadataProperties.CustomerId].Should().Be("acustomerid"); + result.Value[Constants.MetadataProperties.SubscriptionId].Should().Be("asubscriptionid"); + result.Value[Constants.MetadataProperties.BillingPeriodValue].Should().Be("0"); + result.Value[Constants.MetadataProperties.BillingPeriodUnit].Should() + .Be(Subscription.BillingPeriodUnitEnum.Month.ToString()); + result.Value[Constants.MetadataProperties.SubscriptionStatus].Should() + .Be(Subscription.StatusEnum.Active.ToString()); + result.Value[Constants.MetadataProperties.SubscriptionDeleted].Should().Be(false.ToString()); + result.Value[Constants.MetadataProperties.CurrencyCode].Should().Be("USD"); + result.Value[Constants.MetadataProperties.PlanId].Should().Be("aplanid"); + result.Value[Constants.MetadataProperties.BillingAmount].Should().Be("0"); + result.Value[Constants.MetadataProperties.NextBillingAt].Should().Be(datum.ToUnixSeconds().ToString()); + result.Value[Constants.MetadataProperties.CanceledAt].Should().Be(datum.ToUnixSeconds().ToString()); + _serviceClient.Verify(x => + x.FindSubscriptionByIdAsync(_caller.Object, "asubscriptionid", It.IsAny())); + _serviceClient.Verify(sc => sc.CancelSubscriptionAsync(_caller.Object, "asubscriptionid", false, + canceledAt.ToUnixSeconds(), It.IsAny())); + } + + [Fact] + public async Task WhenTransferSubscriptionAsyncWithEmptyBuyerCompanyReference_ThenReturnsError() + { + var provider = BillingProvider.Create("aprovidername", new SubscriptionMetadata + { + { Constants.MetadataProperties.CustomerId, "acustomerid" }, + { Constants.MetadataProperties.SubscriptionId, "asubscriptionid" } + }).Value; + var options = new TransferSubscriptionOptions + { + TransfereeBuyer = new SubscriptionBuyer + { + CompanyReference = 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 + { + { Constants.MetadataProperties.CustomerId, "acustomerid" }, + { Constants.MetadataProperties.SubscriptionId, "asubscriptionid" } + }).Value; + var buyer = new SubscriptionBuyer + { + CompanyReference = "acompanyreference", + Address = new ProfileAddress + { + CountryCode = "acountrycode" + }, + EmailAddress = "anemailaddress", + Id = "abuyerid", + Name = new PersonName + { + FirstName = "afirstname" + } + }; + var options = new TransferSubscriptionOptions + { + TransfereeBuyer = buyer + }; + var customer = CreateCustomer("acustomerid"); + 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", "acompanyreference", "aninitialplanid", Optional.None, 0, + It.IsAny())); + _serviceClient.Verify( + sc => sc.UpdateCustomerForBuyerAsync(It.IsAny(), "acompanyreference", buyer, + It.IsAny())); + _serviceClient.Verify( + sc => sc.UpdateCustomerForBuyerBillingAddressAsync(It.IsAny(), "acompanyreference", buyer, + It.IsAny())); + } + + [Fact] + public async Task + WhenTransferSubscriptionAsyncAndUnsubscribedWithPlan_ThenCreatesNewSubscriptionAndUpdatesCustomer() + { + var provider = BillingProvider.Create("aprovidername", new SubscriptionMetadata + { + { Constants.MetadataProperties.CustomerId, "acustomerid" }, + { Constants.MetadataProperties.SubscriptionId, "asubscriptionid" } + }).Value; + var buyer = new SubscriptionBuyer + { + CompanyReference = "acompanyreference", + 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"); + 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", "acompanyreference", "anotherplanid", Optional.None, 0, + It.IsAny())); + _serviceClient.Verify( + sc => sc.UpdateCustomerForBuyerAsync(It.IsAny(), "acompanyreference", buyer, + It.IsAny())); + _serviceClient.Verify( + sc => sc.UpdateCustomerForBuyerBillingAddressAsync(It.IsAny(), "acompanyreference", buyer, + It.IsAny())); + } + + private static Invoice CreateInvoice(string customerId) + { + var today = DateTime.Today.ToUnixSeconds(); + var yesterday = DateTime.Today.SubtractDays(1).ToUnixSeconds(); + var invoice = new + { + id = "aninvoiceid", + customer_id = customerId, + total = 9, + currency_code = "USD", + price_type = PriceTypeEnum.TaxInclusive.ToString(true), + date = today, + paid_at = today, + line_items = new[] + { + new + { + id = "alineitemid", + description = "adescription", + amount = 9, + currency_code = "USD", + is_taxed = true, + tax_amount = 8, + date_from = yesterday, + date_to = today + } + }, + 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 = false) + { + 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", + CompanyReference = "acompanyreference", + 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..b0c28ded --- /dev/null +++ b/src/Infrastructure.Shared.UnitTests/ApplicationServices/External/ChargebeeBillingProvider.ChargebeeStateInterpreterSpec.cs @@ -0,0 +1,456 @@ +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; + +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( + Constants.MetadataProperties.SubscriptionId, + typeof(ChargebeeStateInterpreter).FullName!)); + } + + [Fact] + public void WhenSetInitialProviderStateAndCustomerIdNotPresent_ThenReturnsError() + { + var provider = BillingProvider.Create(Constants.ProviderName, + new SubscriptionMetadata + { + { Constants.MetadataProperties.SubscriptionId, "asubscriberid" } + }) + .Value; + + var result = _interpreter.SetInitialProviderState(provider); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.BillingProvider_PropertyNotFound.Format( + Constants.MetadataProperties.CustomerId, + typeof(ChargebeeStateInterpreter).FullName!)); + } + + [Fact] + public void WhenSetInitialProviderState_ThenReturnsProviderState() + { + var provider = BillingProvider.Create(Constants.ProviderName, + new SubscriptionMetadata + { + { Constants.MetadataProperties.SubscriptionId, "asubscriberid" }, + { Constants.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[Constants.MetadataProperties.SubscriptionId].Should() + .Be("asubscriberid"); + result.Value.State[Constants.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( + Constants.MetadataProperties.CustomerId, + typeof(ChargebeeStateInterpreter).FullName!)); + } + + [Fact] + public void WhenGetBuyerReference_ThenReturnsCustomerId() + { + var provider = BillingProvider.Create("aprovidername", + new SubscriptionMetadata + { + { Constants.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 + { + { Constants.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 + { + { Constants.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 + { + { Constants.MetadataProperties.CustomerId, "acustomerid" }, + { + Constants.MetadataProperties.PaymentMethodType, + Customer.CustomerPaymentMethod.TypeEnum.Card.ToString() + }, + { + Constants.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 + { + { Constants.MetadataProperties.SubscriptionId, "asubscriptionid" }, + { Constants.MetadataProperties.CustomerId, "acustomerid" }, + { Constants.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 + { + { Constants.MetadataProperties.SubscriptionId, "asubscriptionid" }, + { Constants.MetadataProperties.TrialEnd, "1" }, + { Constants.MetadataProperties.CustomerId, "acustomerid" }, + { Constants.MetadataProperties.SubscriptionStatus, Subscription.StatusEnum.Future.ToString() }, + { Constants.MetadataProperties.SubscriptionDeleted, "false" }, + { Constants.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().BeSome(1L.FromUnixTimestamp()); + 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 + { + { Constants.MetadataProperties.SubscriptionId, "asubscriptionid" }, + { Constants.MetadataProperties.TrialEnd, "1" }, + { Constants.MetadataProperties.CustomerId, "acustomerid" }, + { Constants.MetadataProperties.SubscriptionStatus, Subscription.StatusEnum.InTrial.ToString() }, + { Constants.MetadataProperties.SubscriptionDeleted, "false" }, + { Constants.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().BeSome(1L.FromUnixTimestamp()); + 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 + { + { Constants.MetadataProperties.SubscriptionId, "asubscriptionid" }, + { Constants.MetadataProperties.TrialEnd, "1" }, + { Constants.MetadataProperties.CustomerId, "acustomerid" }, + { Constants.MetadataProperties.SubscriptionStatus, Subscription.StatusEnum.NonRenewing.ToString() }, + { Constants.MetadataProperties.CanceledAt, canceledAt.ToUnixSeconds().ToString() }, + { Constants.MetadataProperties.SubscriptionDeleted, "false" }, + { Constants.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().BeSome(1L.FromUnixTimestamp()); + 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 + { + { Constants.MetadataProperties.SubscriptionId, "asubscriptionid" }, + { Constants.MetadataProperties.TrialEnd, "1" }, + { Constants.MetadataProperties.CustomerId, "acustomerid" }, + { Constants.MetadataProperties.SubscriptionStatus, Subscription.StatusEnum.Cancelled.ToString() }, + { Constants.MetadataProperties.CanceledAt, canceledAt.ToUnixSeconds().ToString() }, + { Constants.MetadataProperties.SubscriptionDeleted, "false" }, + { Constants.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().BeSome(1L.FromUnixTimestamp()); + 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 + { + { Constants.MetadataProperties.SubscriptionId, "asubscriptionid" }, + { Constants.MetadataProperties.TrialEnd, "1" }, + { Constants.MetadataProperties.SubscriptionStatus, Subscription.StatusEnum.Active.ToString() }, + { Constants.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().BeSome(1L.FromUnixTimestamp()); + } + + [Fact] + public void WhenGetSubscriptionDetailsWithPeriodDetails_ThenReturnsSubscriptionWithPeriod() + { + var provider = BillingProvider.Create("aprovidername", + new SubscriptionMetadata + { + { Constants.MetadataProperties.SubscriptionId, "asubscriptionid" }, + { Constants.MetadataProperties.BillingPeriodValue, "9" }, + { Constants.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 + { + { Constants.MetadataProperties.SubscriptionId, "asubscriptionid" }, + { Constants.MetadataProperties.NextBillingAt, nextBilling.ToUnixSeconds().ToString() }, + { Constants.MetadataProperties.CurrencyCode, CurrencyCodes.Default.Code }, + { Constants.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 + { + { Constants.MetadataProperties.SubscriptionId, "asubscriptionid" }, + { + Constants.MetadataProperties.PaymentMethodType, + Customer.CustomerPaymentMethod.TypeEnum.Card.ToString() + }, + { + Constants.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..1fd2e698 --- /dev/null +++ b/src/Infrastructure.Shared/ApplicationServices/External/ChargebeeBillingProvider.ChargebeeHttpServiceClient.TestingOnlycs.cs @@ -0,0 +1,457 @@ +#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); + } +} +#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..d32e0393 --- /dev/null +++ b/src/Infrastructure.Shared/ApplicationServices/External/ChargebeeBillingProvider.ChargebeeHttpServiceClient.cs @@ -0,0 +1,1136 @@ +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"; + internal const string OwningEntityMetadataId = nameof(SubscriptionBuyer.CompanyReference); + internal const string ProductFamilyIdSettingName = "ApplicationServices:Chargebee:ProductFamilyId"; + private const string StartingPlanIdSettingName = "ApplicationServices:Chargebee:Plans:StartingPlanId"; + 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(StartingPlanIdSettingName), settings.Platform.GetString(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); + + //TODO: make sure we dont lose any of the state when this call , for example any customer state + 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 retrievedSubscription = await GetSubscriptionInternalAsync(caller, startingState, cancellationToken); + if (retrievedSubscription.IsFailure) + { + return retrievedSubscription.Error; + } + + var updatedState = retrievedSubscription.Value; + var retrievedStatus = updatedState.ToStatus(); + if (retrievedStatus.IsFailure) + { + return retrievedStatus.Error; + } + + Result modifiedSubscription = updatedState; + var updatedStatus = retrievedStatus.Value.Status; + switch (updatedStatus) + { + 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: + { + modifiedSubscription = await CreateSubscriptionForCustomerInternalAsync(caller, updatedState, + subscriptionId.Value, _initialPlanId, SubscribeOptions.Immediately, DateTime.UnixEpoch, + cancellationToken); + break; + } + } + + 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); + + //TODO: make sure we dont lose any of the state when this call returns , for example any customer state + 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; + } + } + + /// + /// 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 = options.PlanId.HasValue() + ? options.PlanId + : _initialPlanId; + var updatedState = updatedCustomer.Value; + var createdSubscription = await CreateSubscriptionForCustomerInternalAsync(caller, updatedState, + buyer.CompanyReference, 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 retrievedSubscription = await GetSubscriptionInternalAsync(caller, startingState, cancellationToken); + if (retrievedSubscription.IsFailure) + { + return retrievedSubscription.Error; + } + + var updatedState = retrievedSubscription.Value; + var retrievedStatus = updatedState.ToStatus(); + if (retrievedStatus.IsFailure) + { + return retrievedStatus.Error; + } + + var toBuyerId = options.TransfereeBuyer.Id; + Result modifiedSubscription = updatedState; + var updatedStatus = retrievedStatus.Value.Status; + switch (updatedStatus) + { + case BillingSubscriptionStatus.Unsubscribed: + { + var planId = options.PlanId.HasValue() + ? options.PlanId + : _initialPlanId; + modifiedSubscription = await CreateSubscriptionForCustomerInternalAsync(caller, updatedState, + options.TransfereeBuyer.CompanyReference, planId, SubscribeOptions.Immediately, DateTime.UnixEpoch, + cancellationToken); + break; + } + } + + if (modifiedSubscription.IsFailure) + { + return modifiedSubscription.Error; + } + + var updatedCustomer = await UpdateCustomerInternalAsync(caller, options.TransfereeBuyer, cancellationToken); + if (updatedCustomer.IsFailure) + { + return updatedCustomer.Error; + } + + updatedState = updatedCustomer.Value; + _recorder.TraceInformation(caller.ToCall(), + "Transferred Chargebee subscription {Subscription} to {To}", subscriptionId, toBuyerId); + + //TODO: make sure we dont lose any of the state when this call returns , for example any customer state + return updatedState; + + bool HasBuyerReference(TransferSubscriptionOptions opts) + { + return opts.TransfereeBuyer.CompanyReference.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(Constants.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(Constants.MetadataProperties.CustomerId, out var customerId)) + { + return customerId; + } + + return Error.Validation(Resources.ChargebeeHttpServiceClient_InvalidCustomerId); + } + + private static Result GetSubscriptionId(SubscriptionMetadata state) + { + if (state.TryGetValue(Constants.MetadataProperties.SubscriptionId, out var subscriptionId)) + { + return subscriptionId; + } + + return Error.Validation(Resources.ChargebeeHttpServiceClient_InvalidSubscriptionId); + } + + /// + /// Creates a new subscription for the specified customer. + /// Note: AutoCollection="on" so that the subscription automatically cancels after any trial period ends (if any). + /// + private async Task> CreateSubscriptionForCustomerInternalAsync( + ICallerContext caller, SubscriptionMetadata state, string subscriptionReference, string planId, + SubscribeOptions options, Optional forceEndTrial, CancellationToken cancellationToken) + { + subscriptionReference.ThrowIfNotValuedParameter(nameof(subscriptionReference), + Resources.ChargebeeHttpServiceClient_InvalidSubscriptionReference); + 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, + subscriptionReference, 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 GetCompanyName(this SubscriptionBuyer buyer, string customerId) + { + return buyer.CompanyReference.HasValue() + ? buyer.CompanyReference + : 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) + { + return buyer.CompanyReference[..Math.Min(buyer.CompanyReference.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 subscriptionReference) + { + var random = Guid.NewGuid().ToString("N"); + var id = $"{subscriptionReference}.{random}"; + return id[..Math.Min(id.Length, 50)]; + } + + public static SubscriptionMetadata ToCustomerState(this Customer customer) + { + var metadata = new SubscriptionMetadata + { + { Constants.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, + 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, + Reference = invoice.LinkedPayments.First().TxnId + } + : null, + PeriodEndUtc = periodEnd, + PeriodStartUtc = periodStart + }; + + (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) + { + metadata[Constants.MetadataProperties.SubscriptionId] = subscription.Id; + metadata.TryAdd(Constants.MetadataProperties.CustomerId, subscription.CustomerId); + metadata.AppendPlanPeriod(subscription); + metadata[Constants.MetadataProperties.SubscriptionStatus] = subscription.Status.ToString(); + metadata[Constants.MetadataProperties.SubscriptionDeleted] = subscription.Deleted.ToString(); + metadata.AppendPlan(subscription); + metadata.TryAddIfTrue(Constants.MetadataProperties.CanceledAt, subscription.CancelledAt, time => time.HasValue, + time => time!.Value.ToUnixSeconds().ToString()); + metadata.AppendInvoice(subscription); + } + + private static void AppendPlanPeriod(this SubscriptionMetadata metadata, Subscription subscription) + { + metadata.TryAddIfTrue(Constants.MetadataProperties.BillingPeriodValue, subscription.BillingPeriod, + i => i.HasValue, i => i!.Value.ToString()); + metadata.TryAddIfTrue(Constants.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[Constants.MetadataProperties.PlanId] = item.ItemPriceId; + } + + metadata.TryAddIfTrue(Constants.MetadataProperties.TrialEnd, subscription.TrialEnd, time => time.HasValue, + time => time!.Value.ToUnixSeconds().ToString()); + } + + private static void AppendInvoice(this SubscriptionMetadata metadata, Subscription subscription) + { + if (subscription.SubscriptionItems.HasAny()) + { + var item = subscription.SubscriptionItems.First(); + metadata[Constants.MetadataProperties.BillingAmount] = item.Amount.GetValueOrDefault(0).ToString("G"); + } + + metadata[Constants.MetadataProperties.CurrencyCode] = subscription.CurrencyCode; + metadata.TryAddIfTrue(Constants.MetadataProperties.NextBillingAt, subscription.NextBillingAt, + time => time.HasValue, time => time!.Value.ToUnixSeconds().ToString()); + } + + private static void AppendPaymentMethod(this SubscriptionMetadata metadata, Customer customer) + { + if (customer.PaymentMethod.Exists()) + { + metadata[Constants.MetadataProperties.PaymentMethodStatus] = customer.PaymentMethod.Status.ToString(); + metadata[Constants.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..52c1a0e4 --- /dev/null +++ b/src/Infrastructure.Shared/ApplicationServices/External/ChargebeeBillingProvider.ChargebeeStateInterpreter.cs @@ -0,0 +1,437 @@ +using ChargeBee.Models; +using Common; +using Common.Configuration; +using Common.Extensions; +using Domain.Common.ValueObjects; +using Domain.Services.Shared; +using Domain.Shared.Subscriptions; + +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(Constants.MetadataProperties.CustomerId, out var customerId)) + { + return customerId; + } + + return Error.RuleViolation( + Resources.BillingProvider_PropertyNotFound.Format(Constants.MetadataProperties.CustomerId, + GetType().FullName!)); + } + + public Result GetSubscriptionDetails(BillingProvider current) + { + var paymentMethod = current.State.ToPaymentMethod(); + if (paymentMethod.IsFailure) + { + return paymentMethod.Error; + } + + if (!current.State.TryGetValue(Constants.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(Constants.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(Constants.MetadataProperties.SubscriptionId, out _)) + { + return Error.RuleViolation( + Resources.BillingProvider_PropertyNotFound.Format(Constants.MetadataProperties.SubscriptionId, + GetType().FullName!)); + } + + if (!provider.State.TryGetValue(Constants.MetadataProperties.CustomerId, out _)) + { + return Error.RuleViolation( + Resources.BillingProvider_PropertyNotFound.Format(Constants.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 ProviderName = "chargebee_billing"; +#if TESTINGONLY + public static readonly IChargebeeClient.CreditCardPaymentSource TestCard = new() + { + Number = "4111111111111111", + Cvv = "100", + ExpiryYear = DateTime.UtcNow.Year + 2, + ExpiryMonth = 12 + }; +#endif + + 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"; + } + } +} + +internal static class ChargebeeInterpreterConversionExtensions +{ + public static Result ToInvoice(this SubscriptionMetadata state) + { + var currencyCode = + state.GetValueOrDefault(ChargebeeStateInterpreter.Constants.MetadataProperties.CurrencyCode, + CurrencyCodes.Default.Code); + var amount = + state.TryGetValue(ChargebeeStateInterpreter.Constants.MetadataProperties.BillingAmount, out var value) + ? CurrencyCodes.FromMinorUnit(currencyCode, + value.ToIntOrDefault(0)) + : 0M; + var nextUtc = + state.TryGetValue(ChargebeeStateInterpreter.Constants.MetadataProperties.NextBillingAt, out var value2) + ? value2.ToLongOrDefault(0).FromUnixTimestamp().ToOptional() + : Optional.None; + + return ProviderInvoice.Create(amount, currencyCode, nextUtc); + } + + public static Result ToPaymentMethod(this SubscriptionMetadata state) + { + var paymentStatus = state.TryGetValue( + ChargebeeStateInterpreter.Constants.MetadataProperties.PaymentMethodStatus, + out var value2) + ? value2.ToPaymentMethodStatus() + : BillingPaymentMethodStatus.Invalid; + + if (paymentStatus == BillingPaymentMethodStatus.Invalid) + { + return ProviderPaymentMethod.Empty; + } + + var paymentType = state.TryGetValue(ChargebeeStateInterpreter.Constants.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(ChargebeeStateInterpreter.Constants.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(ChargebeeStateInterpreter.Constants.MetadataProperties.BillingPeriodValue, "0") + .ToIntOrDefault(0); + + if (!state.TryGetValue(ChargebeeStateInterpreter.Constants.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(ChargebeeStateInterpreter.Constants.MetadataProperties.TrialEnd, out var trialEnd)) + { + return Optional.None; + } + + if (trialEnd.HasNoValue()) + { + return Optional.None; + } + + var seconds = trialEnd.ToLongOrDefault(0); + return seconds > 0 + ? seconds.FromUnixTimestamp() + : Optional.None; + } + + private static BillingSubscriptionStatus ToSubscriptionStatus(this SubscriptionMetadata state) + { + if (state.TryGetValue(ChargebeeStateInterpreter.Constants.MetadataProperties.SubscriptionDeleted, + out var deleted)) + { + if (deleted.HasValue() && deleted.ToBool()) + { + return BillingSubscriptionStatus.Unsubscribed; + } + } + + return state.TryGetValue(ChargebeeStateInterpreter.Constants.MetadataProperties.SubscriptionStatus, + out var value) + ? value.ToSubscriptionStatus() + : BillingSubscriptionStatus.Unsubscribed; + } + + private static Optional ToCanceledDate(this SubscriptionMetadata state) + { + var canceledSecs = state.GetValueOrDefault(ChargebeeStateInterpreter.Constants.MetadataProperties.CanceledAt) + .ToLongOrDefault(0); + if (canceledSecs > 0) + { + return canceledSecs.FromUnixTimestamp(); + } + + return 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(ChargebeeStateInterpreter.Constants.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..fdb22953 --- /dev/null +++ b/src/Infrastructure.Shared/ApplicationServices/External/ChargebeeHttpServiceClient.ChargebeeClient.cs @@ -0,0 +1,1319 @@ +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 + /// + Task> CreateSubscriptionForCustomerAsync(ICallerContext caller, string customerId, + string subscriptionReference, 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 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 request = Customer.Create() + .Id(customerId) + .FirstName(buyer.Name.FirstName) + .LastName(buyer.Name.LastName) + .Email(buyer.EmailAddress) + .Phone(buyer.PhoneNumber) + .Company(buyer.GetCompanyName(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 + { + { ChargebeeHttpServiceClient.OwningEntityMetadataId, customerId }, + { 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, string subscriptionReference, string planId, Optional start, + Optional trialEnds, + CancellationToken cancellationToken) + { + try + { + var subscriptionId = subscriptionReference.MakeSubscriptionId(); + var request = Subscription.CreateWithItems(customerId) + .Id(subscriptionId) + .AutoCollection(AutoCollectionEnum.On) + .SubscriptionItemItemPriceId(0, planId) + .SubscriptionItemQuantity(0, 1) + .MetaData(JToken.FromObject(new Dictionary + { + { ChargebeeHttpServiceClient.OwningEntityMetadataId, subscriptionReference } + })); + 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); + return ChargebeeError(ex); + } + } + + 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) + .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>> 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 request = Customer.Update(customerId) + .FirstName(buyer.Name.FirstName) + .LastName(buyer.Name.LastName) + .Email(buyer.EmailAddress) + .Phone(buyer.PhoneNumber) + .Company(buyer.GetCompanyName(customerId)) + .MetaData(JToken.FromObject(new Dictionary + { + { ChargebeeHttpServiceClient.OwningEntityMetadataId, customerId }, + { 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 4bdd521e..b5378baf 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..a087f8a1 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 SubscriptionId is missing. + /// + internal static string ChargebeeHttpServiceClient_InvalidSubscriptionId { + get { + return ResourceManager.GetString("ChargebeeHttpServiceClient_InvalidSubscriptionId", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The SubscriptionReference is missing. + /// + internal static string ChargebeeHttpServiceClient_InvalidSubscriptionReference { + get { + return ResourceManager.GetString("ChargebeeHttpServiceClient_InvalidSubscriptionReference", 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..3192a135 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 SubscriptionReference 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..1b716a7b --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeCancelSubscriptionRequest.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#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..d8f9e993 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeChangeSubscriptionPlanRequest.cs @@ -0,0 +1,33 @@ +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..65f795c5 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeCreateCustomerRequest.cs @@ -0,0 +1,30 @@ +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..a1c1cc53 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeCreateSubscriptionRequest.cs @@ -0,0 +1,22 @@ +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..30edabee --- /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..46d0b868 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeGetSubscriptionRequest.cs @@ -0,0 +1,14 @@ +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..ef66d152 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListAttachedItemsRequest.cs @@ -0,0 +1,23 @@ +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 +{ +} \ 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..263916e3 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListFeaturesRequest.cs @@ -0,0 +1,14 @@ +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..b013da29 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListInvoicesRequest.cs @@ -0,0 +1,23 @@ +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 +{ +} \ 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..487f219f --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListItemEntitlementsRequest.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_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..017b46de --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListItemPricesRequest.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/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..dea05df8 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeListSubscriptionsRequest.cs @@ -0,0 +1,14 @@ +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/ChargebeeReactivateSubscriptionRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeReactivateSubscriptionRequest.cs new file mode 100644 index 00000000..03767d13 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeReactivateSubscriptionRequest.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#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..4de8ef54 --- /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< + 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..7a20a242 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeUpdateCustomerBillingInfoRequest.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/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..60642121 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Chargebee/ChargebeeUpdateCustomerRequest.cs @@ -0,0 +1,26 @@ +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/TestingStubApiHost/Api/StubChargebeeApi.cs b/src/TestingStubApiHost/Api/StubChargebeeApi.cs new file mode 100644 index 00000000..5322d49a --- /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