From c47f2af03eb3402115c9c881dc206757adc9384f Mon Sep 17 00:00:00 2001 From: Jezz Santos Date: Fri, 28 Jun 2024 18:58:43 +1200 Subject: [PATCH] Unit tested adapter --- src/ApiHost1/appsettings.json | 12 + ...Provider.ChargebeeHttpServiceClientSpec.cs | 1182 ++++++++++++ ...gProvider.ChargebeeStateInterpreterSpec.cs | 460 +++++ ...lingProvider.ChargebeeHttpServiceClient.cs | 1655 +++++++++++++++++ ...llingProvider.ChargebeeStateInterpreter.cs | 432 +++++ .../External/ChargebeeBillingProvider.cs | 28 + .../Infrastructure.Shared.csproj | 1 + .../Resources.Designer.cs | 74 +- src/Infrastructure.Shared/Resources.resx | 26 +- 9 files changed, 3868 insertions(+), 2 deletions(-) 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/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 diff --git a/src/ApiHost1/appsettings.json b/src/ApiHost1/appsettings.json index bcc72ab2..f8494cb7 100644 --- a/src/ApiHost1/appsettings.json +++ b/src/ApiHost1/appsettings.json @@ -38,6 +38,18 @@ }, "EventNotifications": { "SubscriptionName": "ApiHost1" + }, + "Chargebee": { + "BaseUrl": "https://localhost:5656/chargebee/", + "ApiKey": "anapikey", + "SiteName": "saastack-test", + "ProductFamilyId": "afamilyid", + "Plans": { + "StartingPlanId": "aplanid", + "Tier1PlanIds": "aplanid", + "Tier2PlanIds": "", + "Tier3PlanIds": "" + } } }, "Hosts": { 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..2c48fa69 --- /dev/null +++ b/src/Infrastructure.Shared.UnitTests/ApplicationServices/External/ChargebeeBillingProvider.ChargebeeHttpServiceClientSpec.cs @@ -0,0 +1,1182 @@ +#region + +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; + +#endregion + +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.Get()) + .Returns(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("NZD"); + 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("NZD"); + 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("NZD"); + 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.Get()) + .Returns(plans.ToOptional()); + + var result = await _client.ListAllPricingPlansAsync(_caller.Object, CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.Should().Be(plans); + _pricingPlanCache.Verify(ppc => ppc.Get()); + _pricingPlanCache.Verify(ppc => ppc.Set(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.ListPlanAttachedItemsAsync(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.ListPlanAttachedItemsAsync(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 == "NZD" + && 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.Get()); + _pricingPlanCache.Verify(ppc => ppc.Set(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.ListPlanAttachedItemsAsync(_caller.Object, It.IsAny(), + It.IsAny())); + _serviceClient.Setup(sc => sc.ListPlanEntitlementsAsync(_caller.Object, It.IsAny(), + 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 == "NZD" + && 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 == "NZD" + && 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 == "NZD" + && 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("NZD"); + 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("NZD"); + 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("NZD"); + 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("NZD"); + 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.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("NZD"); + 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(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.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("NZD"); + 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(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.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("NZD"); + 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(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 = "NZD", + price_type = PriceTypeEnum.TaxInclusive.ToString(true), + date = today, + paid_at = today, + line_items = new[] + { + new + { + id = "alineitemid", + description = "adescription", + amount = 9, + currency_code = "NZD", + 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, + charge_once = true, + status = AttachedItem.StatusEnum.Active.ToString(true), + type = AttachedItem.TypeEnum.Mandatory.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 = "NZD", + 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 = "NZD") + { + 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 = "NZD", + 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..8d38899c --- /dev/null +++ b/src/Infrastructure.Shared.UnitTests/ApplicationServices/External/ChargebeeBillingProvider.ChargebeeStateInterpreterSpec.cs @@ -0,0 +1,460 @@ +#region + +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; + +#endregion + +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/ApplicationServices/External/ChargebeeBillingProvider.ChargebeeHttpServiceClient.cs b/src/Infrastructure.Shared/ApplicationServices/External/ChargebeeBillingProvider.ChargebeeHttpServiceClient.cs new file mode 100644 index 00000000..8ab9e834 --- /dev/null +++ b/src/Infrastructure.Shared/ApplicationServices/External/ChargebeeBillingProvider.ChargebeeHttpServiceClient.cs @@ -0,0 +1,1655 @@ +#region + +using Application.Common.Extensions; +using Application.Interfaces; +using Application.Resources.Shared; +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 Domain.Shared.Subscriptions; +using Newtonsoft.Json.Linq; +using Invoice = Application.Resources.Shared.Invoice; +using Subscription = ChargeBee.Models.Subscription; +using Constants = Infrastructure.Shared.ApplicationServices.External.ChargebeeStateInterpreter.Constants; + +#endregion + +namespace Infrastructure.Shared.ApplicationServices.External; + +/// +/// Provides a service client to the Chargebee API +/// +/// +public class ChargebeeHttpServiceClient : IBillingGatewayService +{ + private const string BuyerMetadataId = "BuyerId"; + private const string OwningEntityMetadataId = nameof(SubscriptionBuyer.CompanyReference); + private const string ProductFamilyIdSettingName = "ApplicationServices:Chargebee:ProductFamilyId"; + private const string StartingPlanIdSettingName = "ApplicationServices:Chargebee:Plans:StartingPlanId"; + private readonly string _initialPlanId; + private readonly IPricingPlanCache _pricingPlanCache; + private readonly string _productFamilyId; + private readonly IRecorder _recorder; + private readonly IChargebeeClient _serviceClient; + + public ChargebeeHttpServiceClient(IRecorder recorder, IConfigurationSettings settings) : this(recorder, + new ChargebeeServiceClient(recorder, settings), new InMemPricingPlanCache(), + settings.Platform.GetString(StartingPlanIdSettingName), settings.Platform.GetString(ProductFamilyIdSettingName)) + { + } + + internal ChargebeeHttpServiceClient(IRecorder recorder, IChargebeeClient serviceClient, + IPricingPlanCache pricingPlanCache, string initialPlanId, string productFamilyId) + { + _recorder = recorder; + _serviceClient = serviceClient; + _initialPlanId = initialPlanId; + _productFamilyId = productFamilyId; + _pricingPlanCache = pricingPlanCache; + } + + /// + /// 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. + /// + public async Task> ListAllPricingPlansAsync(ICallerContext caller, + CancellationToken cancellationToken) + { + var cachedPlans = _pricingPlanCache.Get(); + if (cachedPlans is { IsSuccessful: true, Value.HasValue: true }) + { + return cachedPlans.Value.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.ToCurrency(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() + }; + + _pricingPlanCache.Set(plans); + return plans; + + async Task> GetPlanSetupCost(ItemPrice itemPrice) + { + var retrievedAttachedItems = + await _serviceClient.ListPlanAttachedItemsAsync(caller, itemPrice, cancellationToken); + if (retrievedAttachedItems.IsFailure) + { + return retrievedAttachedItems.Error; + } + + var attachedItems = retrievedAttachedItems.Value; + if (attachedItems.HasAny()) + { + var charges = attachedItems + .Where(attachment => attachment.ParentItemId == itemPrice.ItemId + && attachment.ChargeOnce + && attachment.Status == AttachedItem.StatusEnum.Active + && attachment.AttachedItemType == AttachedItem.TypeEnum.Mandatory + && attachment.ChargeOnEvent == ChargeOnEventEnum.SubscriptionCreation) + .ToList(); + + var currency = itemPrice.CurrencyCode; + var prices = LookupChargePriceItemInSameCurrency(); + + return prices.Sum(price => CurrencyCodes.ToCurrency(currency, (int)price.Price.GetValueOrDefault(0))); + + List LookupChargePriceItemInSameCurrency() + { + return charges + .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 itemPrice) + { + var retrievedEntitlements = + await _serviceClient.ListPlanEntitlementsAsync(caller, itemPrice, 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; + _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. 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 updatedState = updatedCustomer.Value; + var createdSubscription = await CreateSubscriptionForCustomerInternalAsync(caller, updatedState, + buyer.CompanyReference, _initialPlanId, 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, _initialPlanId); + + 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(); + } + + public interface IChargebeeClient + { + /// + /// 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 customer for the specified + /// + Task> CreateCustomerForBuyerAsync(ICallerContext caller, string customerId, + SubscriptionBuyer buyer, CancellationToken cancellationToken); + + /// + /// Returns a new subscription for the specified + /// + Task> CreateSubscriptionForCustomerAsync(ICallerContext caller, string customerId, + string subscriptionReference, string planId, Optional start, Optional trialEnds, + 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 + /// + Task, Error>> ListActiveItemPricesAsync(ICallerContext caller, + string productFamilyId, CancellationToken cancellationToken); + + /// + /// Returns all for plans + /// + Task, Error>> ListPlanAttachedItemsAsync(ICallerContext caller, + ItemPrice itemPrice, CancellationToken cancellationToken); + + /// + /// Returns all for plans + /// + Task, Error>> ListPlanEntitlementsAsync(ICallerContext caller, + ItemPrice itemPrice, CancellationToken cancellationToken); + + /// + /// Returns all the optional (switch) + /// + Task, Error>> ListSwitchFeaturesAsync(ICallerContext caller, + CancellationToken cancellationToken); + + /// + /// Returns a reactivated subscription, that may have been canceled. + /// + Task> ReactivateSubscriptionAsync(ICallerContext caller, string subscriptionId, + Optional trialEndsIn, 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 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 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); + } + + /// + /// Provides a service client to the Chargebee API + /// + private sealed class ChargebeeServiceClient : 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 ChargebeeServiceClient(IRecorder recorder, IConfigurationSettings settings) + { + _recorder = recorder; + + var siteName = settings.Platform.GetString(SiteNameSettingName); + var apiKey = settings.Platform.GetString(ApiKeySettingName); + ApiConfig.Configure(siteName, apiKey); + ApiConfig.SetBaseUrl(settings.Platform.GetString(BaseUrlSettingName)); + } + + 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 + { + { OwningEntityMetadataId, customerId }, + { 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> 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 + { + { OwningEntityMetadataId, customerId }, + { 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); + } + } + + 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, 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>> 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> 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> 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>> ListPlanAttachedItemsAsync(ICallerContext caller, + ItemPrice itemPrice, CancellationToken cancellationToken) + { + try + { + var request = await AttachedItem.List(itemPrice.ItemId) + .ItemType().Is(ItemTypeEnum.Plan) + .Limit(100) + .RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Searched for plan attached items, and found {Count}", request.List.Count); + return request.List.Select(entry => entry.AttachedItem).ToList(); + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Searching for plan attached items failed with {Code}", ex.ApiErrorCode); + return ChargebeeError(ex); + } + } + + public async Task, Error>> ListPlanEntitlementsAsync(ICallerContext caller, + ItemPrice itemPrice, CancellationToken cancellationToken) + { + try + { + var request = await Entitlement.List() + .EntityType().Is(Entitlement.EntityTypeEnum.Plan) + .EntityId().Is(itemPrice.ItemId) + .Limit(100) + .RequestAsync(); + + _recorder.TraceDebug(caller.ToCall(), + "Chargebee Client: Searched for plan entitlements, and found {Count}", request.List.Count); + return request.List.Select(entry => entry.Entitlement).ToList(); + } + catch (ApiException ex) + { + _recorder.TraceError(caller.ToCall(), + "Chargebee Client: Searching for plan entitlements failed with {Code}", 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 ChargeBee.Models.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>> 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> 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 + { + { 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); + } + } + + private static Error ChargebeeError(ApiException ex) + { + var message = $"Chargebee failed with error: {ex.Message}, and code: {ex.ApiErrorCode}"; + return Error.Unexpected(message); + } + } + + /// + /// Defines a cache for cached pricing plans + /// + public interface IPricingPlanCache + { + Result, Error> Get(); + + void Set(PricingPlans plans); + } + + /// + /// Provides an in-memory cache for fetched plans, to save us fetching them frequently, + /// as they are very expensive to fetch and build + /// + private class InMemPricingPlanCache : IPricingPlanCache + { + public void Set(PricingPlans plans) + { + throw new NotImplementedException(); + } + + public Result, Error> Get() + { + throw new NotImplementedException(); + } + } +} + +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.ToCurrency(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..d02ad561 --- /dev/null +++ b/src/Infrastructure.Shared/ApplicationServices/External/ChargebeeBillingProvider.ChargebeeStateInterpreter.cs @@ -0,0 +1,432 @@ +#region + +using ChargeBee.Models; +using Common; +using Common.Configuration; +using Common.Extensions; +using Domain.Common.ValueObjects; +using Domain.Services.Shared; +using Domain.Shared.Subscriptions; + +#endregion + +namespace Infrastructure.Shared.ApplicationServices.External; + +/// +/// Provides an interpreter for managing the subscription state of a Chargebee subscription. +/// +public 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 string ProviderName => Constants.ProviderName; + + 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 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"; + + 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.ToCurrency(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..833a0dd4 --- /dev/null +++ b/src/Infrastructure.Shared/ApplicationServices/External/ChargebeeBillingProvider.cs @@ -0,0 +1,28 @@ +#region + +using Application.Services.Shared; +using Common; +using Common.Configuration; +using Domain.Services.Shared; + +#endregion + +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/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