From ed6e9e2c1df0e9e9c1277f7a90fc9e98cac61199 Mon Sep 17 00:00:00 2001 From: Jezz Santos Date: Sat, 22 Jun 2024 22:53:28 +1200 Subject: [PATCH] Added support API for force-cancelling subscriptions --- .../900-migrate-billing-provider.md | 87 +++++++++++-------- .../ForceCancelSubscriptionRequest.cs | 13 +++ .../ISubscriptionsApplication.cs | 3 + .../SubscriptionsApplication.cs | 22 ++++- .../SubscriptionRootSpec.cs | 37 +++++++- src/SubscriptionsDomain/SubscriptionRoot.cs | 12 ++- .../SubscriptionsApiSpec.cs | 27 ++++++ ...eCancelSubscriptionRequestValidatorSpec.cs | 29 +++++++ ...ForceCancelSubscriptionRequestValidator.cs | 17 ++++ .../Api/Subscriptions/SubscriptionsApi.cs | 13 +++ 10 files changed, 220 insertions(+), 40 deletions(-) create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/Subscriptions/ForceCancelSubscriptionRequest.cs create mode 100644 src/SubscriptionsInfrastructure.UnitTests/Api/Subscriptions/ForceCancelSubscriptionRequestValidatorSpec.cs create mode 100644 src/SubscriptionsInfrastructure/Api/Subscriptions/ForceCancelSubscriptionRequestValidator.cs diff --git a/docs/how-to-guides/900-migrate-billing-provider.md b/docs/how-to-guides/900-migrate-billing-provider.md index c57f5d4a..c90cf0fb 100644 --- a/docs/how-to-guides/900-migrate-billing-provider.md +++ b/docs/how-to-guides/900-migrate-billing-provider.md @@ -18,47 +18,47 @@ Depending on the BMS you select, they will each have their own conceptual models The first step is to learn about how your customers are modeled in the BMS software. -For example, in Chargebee, your customer (i.e., `Organization` + "Buyer" in the product) will be modeled as a Chargebee Customer record, and a billing subscription (i.e., `Subscription` in the product) is modeled as a Chargebee Subscription. Chargebee has a notion of a Plan, but that has no equivalent concept in the product. +For example, in Chargebee, your customer (i.e., `Organization` + "Buyer" in the product) will be modeled as a Chargebee Customer record, and a billing subscription (i.e., `Subscription` in the product) is modeled as a Chargebee Subscription. Chargebee has a notion of a Plan, but that has no real equivalent concept in the product, except for the `TierId` and the `PlanId`. -Other BMS will have slightly different conceptual models, and you will need to understand them first. +Other BMS will have slightly different conceptual models, and you will need to understand them first, and how they map to the concepts in the product. -You will also need to configure the rules and other policies in the BMS first, before you start configuring your customer data. +You will also need to configure the basic rules and other policies in the BMS first, before you start configuring your customer data. For example, configure API Keys, Webhooks etc. +The last thing will be to explore whether the BMS supports a "sandbox" environment for you to play around with and test your migration. You don't want to be adding test data to your production customer data. + +> You may need to sign up for a free plan first to play around with and explore the options, and then use a paid version for your production data. + ### View the available data Use the API endpoint `GET /subscriptions/export` to view the data available for export into your chosen BMS. > Note: this is a protected endpoint that you can only access with HMAC secrets -This data represents all the subscriptions created in the product so far, and you will need to import them into your chosen BMS. +This data represents all the subscriptions created in the product so far. + +This is the data you will need to import into your chosen BMS, during the migration. + +> Note: some of the values are simply encoded JSON values ```json { "subscriptions": [{ + "id": "subscription_QR5hju7FDMIklw39GVCs", "buyerId": "user_wYU128873MRRBRtjj3E", "owningEntityId": "org_M36utr98Fdde8890BDEcS2", "providerName": "simple_billing_provider", "providerState": { "SubscriptionId": "simplesub_dd45baa2188c43d39745344356781123" }, - "id": "subscription_QR5hju7FDMIklw39GVCs", "buyer": { - "buyerId": "user_wYU128873MRRBRtjj3E", - "owningEntityId": "org_MM36utr98Fdde8890BDEcS2", + "id": "user_wYU128873MRRBRtjj3E", + "companyReference": "org_MM36utr98Fdde8890BDEcS2", "firstName": "firstname", - "lastName": "lastname", + "name": "{\"FirstName\":\"afirstname\",\"LastName\":\"alastname\"}", "emailAddress": "user1@company.com", - "address": { - "line1": "", - "line2": "", - "line3": "", - "city": "", - "state": "", - "countryCode": "NZL", - "zip": "" - } + "address": "{\"City\":\"\",\"CountryCode\":\"NZL\",\"Line1\":\"\",\"Line2\":\"\",\"Line3\":\"\",\"State\":\"\",\"Zip\":\"\"}" }, } ], @@ -78,31 +78,47 @@ This data represents all the subscriptions created in the product so far, and yo ### Build Your Scripts -You will likely need to build some scripts that take the raw data above and automate the creation of various related data structures in the new BMS. +You will likely need to build some scripts that translate the raw data above and automate the creation of various related data structures in the new BMS. -You will need to test and refine these scripts thoroughly so that they are reliable when run during the migration with many more subscriptions. +For example, in Chargebee: -### Configure Your New Plans +1. You would create a Chargebee Customer record using the data in the `buyer` property. You would save the `buyer.id` in the metadata of the Chargebee Customer record. +2. You would create a Chargebee Subscription for the Chargebee Customer. You would also save the `id` and `owningEntityId` as metadata in the Chargebee Subscription. +3. You would define some Chargebee Plans, and assign one of those plans to the Chargebee subscription. -In your chosen BMS, you will need to design and define the new pricing plans you intend to support for all your customers moving forward in this BMS. +Next, during the migration, once you have automated the creation of the BMS records, you will also need a collection of metadata of those BMS records back into the data of the `IBillingProvider` for when it is being used. -You won't see any plan information in the exported data from the previous step, unless it appears in the `ProviderState` entry. +Use the API endpoint `POST /subscriptions/{Id}/migrate` to copy the data from your BMS into the product. -> If you are using the `SimpleBillingProvider` prior to this step, it does not maintain any plan information. That's because it hardcodes a single plan for everyone's use. You can find that single hardcoded plan information in the `InProcessInMemSimpleBillingGatewayService`. +For example, for Chargebee, we would be saving, at the very least, the following data: -You also need to remember that the `SimpleBillingProvider` has everyone on a "free" plan that requires no payment method and does not support a Trial period. +1. The Chargebee `CustomerId` +2. The Chargebee `SubscriptionId` and `SubscriptionStatus` +3. The Chargebee `PlanId` -This means that when you import these subscriptions (created by the `SimpleBillingProvider`) into your new BMS, you need to import them into a "free" plan that also does not require them to have a valid `PaymentMethod`. Your customers will not have given their `PaymentMethod` yet. +> There are a total of about 15 other properties about the Customer, Subscription, Plan, and the payment method that will be stored by the `IBillingProvider` over the course of the lifetime of a subscription. See the implementation of the `ChargebeeBillingProvider`. -Also, in the product, by default, we have defined the following `SubscriptionTier`: +Next, you would need to test and refine these scripts thoroughly so that they are reliable when run during the migration with hundreds/thousands of subscriptions (depending on how many customers you have at the time). + +### Configure Your New Plans + +In your chosen BMS, you will need to design and define the new pricing plans you intend to support for all your customers moving forward in this BMS. + +> If you are using the `SimpleBillingProvider` prior to this step, you won't see much plan information in the exported data from the previous step, that's because this provider does not maintain much plan information at all. That's because it hardcodes a single plan for everyone's use. You can find that single hardcoded plan information in the `InProcessInMemSimpleBillingGatewayService`. +> +> You also need to remember that the `SimpleBillingProvider` has everyone on a "free" plan that requires no payment method and does not support a Trial period. + +This means that when you import these subscriptions (created by the `SimpleBillingProvider`) into your new BMS, you need to import them into a "free" plan that does not require them to have a valid `PaymentMethod`. If migrating from the `SimpleBillingProvider`, your customers will not have given any `PaymentMethod` yet. + +In the product, by default, we have defined the following tiers (see: `SubscriptionTier`): * Standard * Professional * Enterprise -You are free to rename, add, or remove these tiers (in the code) to whatever you would like to support in your future pricing plans in your new BMS. +You are free to rename, add, or remove these tiers (in the code) to whatever you would like to support in your future pricing plans in your new BMS. Essentially, we have 3 paid tiers, where `Standard` may have a trial, and is generally the default plan for new users. -> Remember to modify the mapping between these tiers and the feature levels you will be supporting in your pricing plans. +> Remember, if you modify these tiers, you will also need to modify the mapping between these tiers and the feature levels you will be supporting in your pricing plans. see the `EndUserRoot` for details. In your BMS, we recommend defining at least the following plans: @@ -117,11 +133,13 @@ You will need to define all the parameters for each of these new plans, includin ### Configure the BillingProvider -Your newly chosen BMS will require a built and tested implementation of the `IBillingProvider` to work with it. You will also need to have built any webhooks or synchronization processes built to handle updates originating from the BMS to the `Subscriptions` subdomain. +Your newly chosen BMS will require a built and tested implementation of the `IBillingProvider` to work with it. + +You will also need to build any webhooks or synchronization processes to handle updates originating from the BMS to the `Subscriptions` subdomain so that changes in the BMS update the data kept in the `Subscription` of the product. > SaaStack comes with a small number of existing `IBillingProvider` implementations already. These can be used and they can be referenced to build your own implementations for other BMSs. -To swap out the existing `IBillingProvider` (e.g. `SimpleBillingProvider`) with your new implementation, you simply change the dependency injection code in the `Subscriptions` subdomain to swap them out. +To swap out the existing `IBillingProvider` (e.g. `SimpleBillingProvider`) with your new implementation, you simply change the dependency injection code in the `Subscriptions` subdomain (see: `SubscriptionsModule`). > You can then delete the `SimpleBillingProvider` and its associated classes and tests. You are very unlikely to revert back to using it ever again. @@ -142,10 +160,11 @@ Unfortunately, due to the nature of this migration, you are going to need to sch These are the activities to schedule and perform to complete the migration and before you can resume service with the new BMS integration: 1. Export the data from the running product using the endpoint: `GET /subscriptions/export` -2. Immediately, shutdown your service, to prevent any new customers signing up (and thus creating new subscriptions). You may need other measures if you have heavy sign-up traffic to ensure no one is signing up while you are exporting the data. -3. Import your exported subscription data into your new BMS. You are likely going to be scripting the creation a numerous new data structures in the new BMS, with this data. +2. Immediately shutdown your service, to prevent any new customers signing up (and thus creating new subscriptions). You may need other measures if you have heavy sign-up traffic to ensure no one is signing up while you are exporting the data. +3. Import the exported data it into your new BMS (using your scripts and the BMS API). You are likely going to be scripting the creation of numerous new data structures in the new BMS with this data, and collecting a bunch of key identifiers that are created. 4. Deploy a new version of your software that includes the configured new `IBillingProvider` to your new BMS. -5. Resume service using your new BMS integration, and test by signing up new users and ensuring that new subscriptions are created in your new BMS. This may be performed in a non-production slot. -6. Resume service for all your customers. +5. With the collected data from the BMS, import that data back into your API (using `POST /subscriptions/{Id}/migrate`) +6. Resume service using your new BMS integration, and test by signing up new users and ensuring that new subscriptions are created in your new BMS. This may be performed in a non-production slot. +7. Resume service for all your customers. After this migration, any new users that are registered in your product will be automatically integrated into your product, and appear in the BMS. diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Subscriptions/ForceCancelSubscriptionRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Subscriptions/ForceCancelSubscriptionRequest.cs new file mode 100644 index 00000000..11ec7d9c --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Subscriptions/ForceCancelSubscriptionRequest.cs @@ -0,0 +1,13 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Subscriptions; + +/// +/// Forces the billing subscription to be cancelled for the organization. +/// +[Route("/subscriptions/{Id}/force", OperationMethod.Delete, AccessType.Token)] +[Authorize(Roles.Platform_Operations)] +public class ForceCancelSubscriptionRequest : UnTenantedRequest, IUnTenantedOrganizationRequest +{ + public string? Id { get; set; } +} \ No newline at end of file diff --git a/src/SubscriptionsApplication/ISubscriptionsApplication.cs b/src/SubscriptionsApplication/ISubscriptionsApplication.cs index 817c87bd..3f9aa906 100644 --- a/src/SubscriptionsApplication/ISubscriptionsApplication.cs +++ b/src/SubscriptionsApplication/ISubscriptionsApplication.cs @@ -16,6 +16,9 @@ Task> ChangePlanAsync(ICallerContext caller, Task, Error>> ExportSubscriptionsToMigrateAsync(ICallerContext caller, SearchOptions searchOptions, GetOptions getOptions, CancellationToken cancellationToken); + Task> ForceCancelSubscriptionAsync(ICallerContext caller, string owningEntityId, + CancellationToken cancellationToken); + Task> GetSubscriptionAsync(ICallerContext caller, string owningEntityId, CancellationToken cancellationToken); diff --git a/src/SubscriptionsApplication/SubscriptionsApplication.cs b/src/SubscriptionsApplication/SubscriptionsApplication.cs index 69b6b8d9..e911a551 100644 --- a/src/SubscriptionsApplication/SubscriptionsApplication.cs +++ b/src/SubscriptionsApplication/SubscriptionsApplication.cs @@ -7,10 +7,12 @@ using Common.Extensions; using Domain.Common.Identity; using Domain.Common.ValueObjects; +using Domain.Shared; using Domain.Shared.Subscriptions; using SubscriptionsApplication.Persistence; using SubscriptionsDomain; using Subscription = SubscriptionsApplication.Persistence.ReadModels.Subscription; +using Validations = SubscriptionsDomain.Validations; namespace SubscriptionsApplication; @@ -18,9 +20,9 @@ public partial class SubscriptionsApplication : ISubscriptionsApplication { private readonly IBillingProvider _billingProvider; private readonly IIdentifierFactory _identifierFactory; - private readonly ISubscriptionOwningEntityService _subscriptionOwningEntityService; private readonly IRecorder _recorder; private readonly ISubscriptionRepository _repository; + private readonly ISubscriptionOwningEntityService _subscriptionOwningEntityService; private readonly IUserProfilesService _userProfilesService; public SubscriptionsApplication(IRecorder recorder, IIdentifierFactory identifierFactory, @@ -120,6 +122,12 @@ Task> OnTransfer(BillingProvider provider, I } } + public async Task> ForceCancelSubscriptionAsync(ICallerContext caller, + string owningEntityId, CancellationToken cancellationToken) + { + return await CancelSubscriptionAsync(caller, owningEntityId, cancellationToken); + } + public async Task> GetSubscriptionAsync(ICallerContext caller, string owningEntityId, CancellationToken cancellationToken) { @@ -213,8 +221,15 @@ public async Task> CancelSubscriptionAsync(I var subscription = retrieved.Value; var cancellerId = caller.ToCallerId(); + var cancellerRoles = Roles.Create(caller.Roles.All); + if (cancellerRoles.IsFailure) + { + return cancellerRoles.Error; + } + var cancelled = - await subscription.CancelSubscriptionAsync(_billingProvider.StateInterpreter, cancellerId, CanCancel, + await subscription.CancelSubscriptionAsync(_billingProvider.StateInterpreter, cancellerId, + cancellerRoles.Value, CanCancel, OnCancel, false); if (cancelled.IsFailure) @@ -403,7 +418,8 @@ async Task CanView(SubscriptionRoot subscription1, Identifier viewer /// /// Calculate the date range based on inputs and defaults - /// Note: If not explicitly specified, the range should be + /// Note: If not explicitly specified, the range should be + /// /// in length, and as muh as possible include those past months /// private static (DateTime From, DateTime To) CalculatedSearchRange(DateTime? fromUtc, DateTime? toUtc) diff --git a/src/SubscriptionsDomain.UnitTests/SubscriptionRootSpec.cs b/src/SubscriptionsDomain.UnitTests/SubscriptionRootSpec.cs index 4c1fdaa2..17c4f2e9 100644 --- a/src/SubscriptionsDomain.UnitTests/SubscriptionRootSpec.cs +++ b/src/SubscriptionsDomain.UnitTests/SubscriptionRootSpec.cs @@ -4,8 +4,10 @@ using Domain.Common.ValueObjects; using Domain.Events.Shared.Subscriptions; using Domain.Interfaces; +using Domain.Interfaces.Authorization; using Domain.Interfaces.Entities; using Domain.Services.Shared; +using Domain.Shared; using Domain.Shared.Subscriptions; using FluentAssertions; using Moq; @@ -501,6 +503,7 @@ public async Task WhenChangePlanAsyncByAnotherAndUnsubscribed_ThenTransfersPlan( public async Task WhenCancelSubscriptionAsyncAndNoProvider_ThenReturnsError() { var result = await _subscription.CancelSubscriptionAsync(_interpreter.Object, "acancellerid".ToId(), + Roles.Empty, (_, _) => Task.FromResult(Permission.Allowed), _ => Task.FromResult>(new SubscriptionMetadata()), false); @@ -519,6 +522,7 @@ public async Task WhenCancelSubscriptionAsyncAndDifferentProvider_ThenReturnsErr _subscription.SetProvider(provider, "abuyerid".ToId(), _interpreter.Object); var result = await _subscription.CancelSubscriptionAsync(_interpreter.Object, "acancellerid".ToId(), + Roles.Empty, (_, _) => Task.FromResult(Permission.Allowed), _ => Task.FromResult>(new SubscriptionMetadata()), false); @@ -535,6 +539,7 @@ public async Task WhenCancelSubscriptionAsyncByBuyerButNotAllowed_ThenReturnsErr _subscription.SetProvider(provider, "abuyerid".ToId(), _interpreter.Object); var result = await _subscription.CancelSubscriptionAsync(_interpreter.Object, "abuyerid".ToId(), + Roles.Empty, (_, _) => Task.FromResult(Permission.Denied_Rule("areason")), _ => Task.FromResult>(new SubscriptionMetadata()), false); @@ -557,6 +562,7 @@ public async Task WhenCancelSubscriptionAsyncByBuyerButNotCancellable_ThenReturn .Value)); var result = await _subscription.CancelSubscriptionAsync(_interpreter.Object, "abuyerid".ToId(), + Roles.Empty, (_, _) => Task.FromResult(Permission.Allowed), _ => Task.FromResult>(new SubscriptionMetadata()), false); @@ -564,6 +570,35 @@ public async Task WhenCancelSubscriptionAsyncByBuyerButNotCancellable_ThenReturn Resources.SubscriptionRoot_CancelSubscription_NotCancellable); } + [Fact] + public async Task WhenCancelSubscriptionAsyncByOperations_ThenCancelled() + { + var provider = BillingProvider.Create("aprovidername", new SubscriptionMetadata + { + { "aname", "avalue" } + }).Value; + _subscription.SetProvider(provider, "abuyerid".ToId(), _interpreter.Object); + _interpreter.Setup(bp => bp.GetSubscriptionDetails(It.IsAny())) + .Returns(ProviderSubscription.Create( + ProviderStatus.Create(BillingSubscriptionStatus.Activated, null, true).Value, + ProviderPaymentMethod.Create(BillingPaymentMethodType.Card, BillingPaymentMethodStatus.Valid, null) + .Value)); + + var result = await _subscription.CancelSubscriptionAsync(_interpreter.Object, "anotheruserid".ToId(), + Roles.Create(PlatformRoles.Operations).Value, + (_, _) => Task.FromResult(Permission.Allowed), + _ => Task.FromResult>(new SubscriptionMetadata + { + { "aname", "avalue" } + }), false); + + result.Should().BeSuccess(); + result.Value.Should().NotBeNull(); + _subscription.BuyerId.Should().Be("abuyerid".ToId()); + _subscription.Events.Last().Should().BeOfType(); + _interpreter.Verify(sp => sp.GetSubscriptionDetails(provider)); + } + [Fact] public async Task WhenCancelSubscriptionAsyncByBuyer_ThenCancelled() { @@ -579,6 +614,7 @@ public async Task WhenCancelSubscriptionAsyncByBuyer_ThenCancelled() .Value)); var result = await _subscription.CancelSubscriptionAsync(_interpreter.Object, "abuyerid".ToId(), + Roles.Empty, (_, _) => Task.FromResult(Permission.Allowed), _ => Task.FromResult>(new SubscriptionMetadata { @@ -948,5 +984,4 @@ public async Task WhenChangePaymentMethodForBuyerAsyncByServiceAccount_ThenChang _subscription.BuyerId.Should().Be("abuyerid".ToId()); _subscription.Events.Last().Should().BeOfType(); } - } \ No newline at end of file diff --git a/src/SubscriptionsDomain/SubscriptionRoot.cs b/src/SubscriptionsDomain/SubscriptionRoot.cs index 7ea61669..48c3ed8d 100644 --- a/src/SubscriptionsDomain/SubscriptionRoot.cs +++ b/src/SubscriptionsDomain/SubscriptionRoot.cs @@ -5,9 +5,11 @@ using Domain.Common.ValueObjects; using Domain.Events.Shared.Subscriptions; using Domain.Interfaces; +using Domain.Interfaces.Authorization; using Domain.Interfaces.Entities; using Domain.Interfaces.ValueObjects; using Domain.Services.Shared; +using Domain.Shared; using Domain.Shared.Subscriptions; namespace SubscriptionsDomain; @@ -215,7 +217,8 @@ protected override Result OnStateChanged(IDomainEvent @event, bool isReco } public async Task> CancelSubscriptionAsync(IBillingStateInterpreter interpreter, - Identifier cancellerId, CanCancelSubscriptionCheck canCancel, CancelSubscriptionAction onCancel, bool force) + Identifier cancellerId, Roles cancellerRoles, CanCancelSubscriptionCheck canCancel, + CancelSubscriptionAction onCancel, bool force) { var verified = VerifyProviderIsSameAsInstalled(interpreter); if (verified.IsFailure) @@ -223,7 +226,7 @@ public async Task> CancelSubscriptionAsync(I return verified.Error; } - var skipPermissionCheck = IsExecutedOnBehalfOfBuyer(cancellerId); + var skipPermissionCheck = IsExecutedOnBehalfOfBuyer(cancellerId) || IsOperations(cancellerRoles); if (!skipPermissionCheck) { var canCancelPermission = await canCancel(this, cancellerId); @@ -268,6 +271,11 @@ async Task> CancelSubscriptionForBuyerAsync() } } + private bool IsOperations(Roles roles) + { + return roles.HasRole(PlatformRoles.Operations); + } + public async Task> ChangePaymentMethodForBuyerAsync(IBillingStateInterpreter interpreter, Identifier modifierId, ChangePaymentMethodAction onChangePaymentMethod) { diff --git a/src/SubscriptionsInfrastructure.IntegrationTests/SubscriptionsApiSpec.cs b/src/SubscriptionsInfrastructure.IntegrationTests/SubscriptionsApiSpec.cs index 94862e0d..3ece1e30 100644 --- a/src/SubscriptionsInfrastructure.IntegrationTests/SubscriptionsApiSpec.cs +++ b/src/SubscriptionsInfrastructure.IntegrationTests/SubscriptionsApiSpec.cs @@ -120,6 +120,33 @@ public async Task WhenCancel_ThenRestores() result.Invoice.NextUtc.Should().BeNull(); } + [Fact] + public async Task WhenForceCancel_ThenRestores() + { + var loginA = await LoginUserAsync(); + var (_, organizationId) = await SetupOrganization(loginA); + + var @operator = await LoginUserAsync(LoginUser.Operator); + var result = (await Api.DeleteAsync(new ForceCancelSubscriptionRequest + { + Id = organizationId + }, req => req.SetJWTBearerToken(@operator.AccessToken))).Content.Value.Subscription!; + + result.OwningEntityId.Should().Be(organizationId); + result.ProviderName.Should().Be(SinglePlanBillingStateInterpreter.Constants.ProviderName); + result.Status.Should().Be(SubscriptionStatus.Activated); + result.CancelledDateUtc.Should().BeNull(); + result.Plan.Id.Should().Be(SinglePlanBillingStateInterpreter.Constants.BasicPlanId); + result.Plan.IsTrial.Should().BeFalse(); + result.Plan.TrialEndDateUtc.Should().BeNull(); + result.Plan.Tier.Should().Be(SubscriptionTier.Standard); + result.Period.Frequency.Should().Be(0); + result.Period.Unit.Should().Be(PeriodFrequencyUnit.Eternity); + result.Invoice.Amount.Should().Be(0); + result.Invoice.Currency.Should().Be(CurrencyCodes.Default.Code); + result.Invoice.NextUtc.Should().BeNull(); + } + [Fact] public async Task WhenListPricingPlans_ThenReturnsPlans() { diff --git a/src/SubscriptionsInfrastructure.UnitTests/Api/Subscriptions/ForceCancelSubscriptionRequestValidatorSpec.cs b/src/SubscriptionsInfrastructure.UnitTests/Api/Subscriptions/ForceCancelSubscriptionRequestValidatorSpec.cs new file mode 100644 index 00000000..d106d9bf --- /dev/null +++ b/src/SubscriptionsInfrastructure.UnitTests/Api/Subscriptions/ForceCancelSubscriptionRequestValidatorSpec.cs @@ -0,0 +1,29 @@ +using Domain.Common.Identity; +using FluentValidation; +using Infrastructure.Web.Api.Operations.Shared.Subscriptions; +using SubscriptionsInfrastructure.Api.Subscriptions; +using Xunit; + +namespace SubscriptionsInfrastructure.UnitTests.Api.Subscriptions; + +[Trait("Category", "Unit")] +public class ForceCancelSubscriptionSubscriptionRequestValidatorSpec +{ + private readonly ForceCancelSubscriptionRequest _dto; + private readonly ForceCancelSubscriptionRequestValidator _validator; + + public ForceCancelSubscriptionSubscriptionRequestValidatorSpec() + { + _validator = new ForceCancelSubscriptionRequestValidator(new FixedIdentifierFactory("anid")); + _dto = new ForceCancelSubscriptionRequest + { + Id = "anid" + }; + } + + [Fact] + public void WhenAllProperties_ThenSucceeds() + { + _validator.ValidateAndThrow(_dto); + } +} \ No newline at end of file diff --git a/src/SubscriptionsInfrastructure/Api/Subscriptions/ForceCancelSubscriptionRequestValidator.cs b/src/SubscriptionsInfrastructure/Api/Subscriptions/ForceCancelSubscriptionRequestValidator.cs new file mode 100644 index 00000000..3c49b252 --- /dev/null +++ b/src/SubscriptionsInfrastructure/Api/Subscriptions/ForceCancelSubscriptionRequestValidator.cs @@ -0,0 +1,17 @@ +using Domain.Common.Identity; +using Domain.Interfaces.Validations; +using FluentValidation; +using Infrastructure.Web.Api.Common.Validation; +using Infrastructure.Web.Api.Operations.Shared.Subscriptions; + +namespace SubscriptionsInfrastructure.Api.Subscriptions; + +public class ForceCancelSubscriptionRequestValidator : AbstractValidator +{ + public ForceCancelSubscriptionRequestValidator(IIdentifierFactory identifierFactory) + { + RuleFor(req => req.Id) + .IsEntityId(identifierFactory) + .WithMessage(CommonValidationResources.AnyValidator_InvalidId); + } +} \ No newline at end of file diff --git a/src/SubscriptionsInfrastructure/Api/Subscriptions/SubscriptionsApi.cs b/src/SubscriptionsInfrastructure/Api/Subscriptions/SubscriptionsApi.cs index 23f3f761..62b1376f 100644 --- a/src/SubscriptionsInfrastructure/Api/Subscriptions/SubscriptionsApi.cs +++ b/src/SubscriptionsInfrastructure/Api/Subscriptions/SubscriptionsApi.cs @@ -76,4 +76,17 @@ public async Task(x => new GetSubscriptionResponse { Subscription = x }); } + + public async Task> ForceCancelSubscription( + ForceCancelSubscriptionRequest request, CancellationToken cancellationToken) + { + var subscription = await _subscriptionsApplication.ForceCancelSubscriptionAsync(_callerFactory.Create(), + request.Id!, cancellationToken); + + return () => + subscription.HandleApplicationResult(x => + new GetSubscriptionResponse + { Subscription = x }); + } + } \ No newline at end of file