diff --git a/src/Application.Interfaces/Audits.Designer.cs b/src/Application.Interfaces/Audits.Designer.cs index 6948483a..06673bb2 100644 --- a/src/Application.Interfaces/Audits.Designer.cs +++ b/src/Application.Interfaces/Audits.Designer.cs @@ -149,6 +149,15 @@ public static string EndUsersApplication_User_Registered_TermsAccepted { } } + /// + /// Looks up a localized string similar to Organization.Deleted. + /// + public static string OrganizationApplication_OrganizationDeleted { + get { + return ResourceManager.GetString("OrganizationApplication_OrganizationDeleted", resourceCulture); + } + } + /// /// Looks up a localized string similar to Authentication.Failed.AccountLocked. /// diff --git a/src/Application.Interfaces/Audits.resx b/src/Application.Interfaces/Audits.resx index 17cc153a..0b67894f 100644 --- a/src/Application.Interfaces/Audits.resx +++ b/src/Application.Interfaces/Audits.resx @@ -81,4 +81,7 @@ Subscription.BuyerTransferred + + Organization.Deleted + \ No newline at end of file diff --git a/src/Application.Services.Shared/IOwningEntityService.cs b/src/Application.Services.Shared/ISubscriptionOwningEntityService.cs similarity index 97% rename from src/Application.Services.Shared/IOwningEntityService.cs rename to src/Application.Services.Shared/ISubscriptionOwningEntityService.cs index 5754fbd3..f45f9b4d 100644 --- a/src/Application.Services.Shared/IOwningEntityService.cs +++ b/src/Application.Services.Shared/ISubscriptionOwningEntityService.cs @@ -6,7 +6,7 @@ namespace Application.Services.Shared; /// /// Defines a service for asserting the permissions of an owning entity of a billing subscription. /// -public interface IOwningEntityService +public interface ISubscriptionOwningEntityService { /// /// Whether the subscription can be cancelled by the diff --git a/src/Domain.Common/ValueObjects/SingleValueObjectBase.cs b/src/Domain.Common/ValueObjects/SingleValueObjectBase.cs index 716152ca..7a6bbba4 100644 --- a/src/Domain.Common/ValueObjects/SingleValueObjectBase.cs +++ b/src/Domain.Common/ValueObjects/SingleValueObjectBase.cs @@ -29,6 +29,7 @@ protected SingleValueObjectBase(TValue value) TValue ISingleValueObject.Value => Value; + [DebuggerStepThrough] public static implicit operator TValue(SingleValueObjectBase valueObject) { return valueObject.NotExists() || valueObject.Value.NotExists() diff --git a/src/Domain.Events.Shared/Organizations/BillingSubscribed.cs b/src/Domain.Events.Shared/Organizations/BillingSubscribed.cs index b9aad0c3..24dbb3ae 100644 --- a/src/Domain.Events.Shared/Organizations/BillingSubscribed.cs +++ b/src/Domain.Events.Shared/Organizations/BillingSubscribed.cs @@ -17,4 +17,5 @@ public BillingSubscribed() public required string SubscriberId { get; set; } + public required string SubscriptionId { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Subscriptions/Deleted.cs b/src/Domain.Events.Shared/Subscriptions/Deleted.cs new file mode 100644 index 00000000..bf22dc0f --- /dev/null +++ b/src/Domain.Events.Shared/Subscriptions/Deleted.cs @@ -0,0 +1,17 @@ +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; + +namespace Domain.Events.Shared.Subscriptions; + +public sealed class Deleted : TombstoneDomainEvent +{ + public Deleted(Identifier id, Identifier deletedById) : base(id, deletedById) + { + } + + [UsedImplicitly] + public Deleted() + { + } +} \ No newline at end of file diff --git a/src/Domain.Shared/Subscriptions/BillingSubscriber.cs b/src/Domain.Shared/Subscriptions/BillingSubscriber.cs new file mode 100644 index 00000000..b0119b16 --- /dev/null +++ b/src/Domain.Shared/Subscriptions/BillingSubscriber.cs @@ -0,0 +1,53 @@ +using Common; +using Common.Extensions; +using Domain.Common.ValueObjects; +using Domain.Interfaces; + +namespace Domain.Shared.Subscriptions; + +public sealed class BillingSubscriber : ValueObjectBase +{ + public static Result Create(string subscriptionId, string subscriberId) + { + if (subscriptionId.IsNotValuedParameter(nameof(subscriptionId), out var error1)) + { + return error1; + } + + if (subscriberId.IsNotValuedParameter(nameof(subscriberId), out var error2)) + { + return error2; + } + + return new BillingSubscriber(subscriptionId, subscriberId); + } + + private BillingSubscriber(string subscriptionId, string subscriberId) + { + SubscriptionId = subscriptionId; + SubscriberId = subscriberId; + } + + public string SubscriberId { get; } + + public string SubscriptionId { get; } + + public static ValueObjectFactory Rehydrate() + { + return (property, _) => + { + var parts = RehydrateToList(property, false); + return new BillingSubscriber(parts[0]!, parts[1]!); + }; + } + + protected override IEnumerable GetAtomicValues() + { + return new object[] { SubscriptionId, SubscriberId }; + } + + public BillingSubscriber ChangeSubscriber(Identifier subscriberId) + { + return new BillingSubscriber(SubscriptionId, subscriberId); + } +} \ No newline at end of file diff --git a/src/EndUsersApplication/EndUsersApplication.DomainEventHandlers.cs b/src/EndUsersApplication/EndUsersApplication.DomainEventHandlers.cs index 28ec7508..84c0133f 100644 --- a/src/EndUsersApplication/EndUsersApplication.DomainEventHandlers.cs +++ b/src/EndUsersApplication/EndUsersApplication.DomainEventHandlers.cs @@ -10,6 +10,7 @@ using Domain.Shared.Subscriptions; using EndUsersDomain; using Created = Domain.Events.Shared.Organizations.Created; +using Deleted = Domain.Events.Shared.Organizations.Deleted; namespace EndUsersApplication; @@ -26,7 +27,7 @@ public async Task> HandleOrganizationCreatedAsync(ICallerContext c public async Task> HandleOrganizationDeletedAsync(ICallerContext caller, Deleted domainEvent, CancellationToken cancellationToken) { - return await RemoveMembershipFromDeletedOrganizationAsync(caller, domainEvent.RootId.ToId(), + return await RemoveOwnerMembershipFromDeletedOrganizationAsync(caller, domainEvent.RootId.ToId(), domainEvent.DeletedById.ToId(), cancellationToken); } @@ -54,7 +55,7 @@ public async Task> HandleSubscriptionPlanChangedAsync(ICallerConte domainEvent.OwningEntityId.ToId(), cancellationToken); } - private async Task> RemoveMembershipFromDeletedOrganizationAsync(ICallerContext caller, + private async Task> RemoveOwnerMembershipFromDeletedOrganizationAsync(ICallerContext caller, Identifier organizationId, Identifier deletedById, CancellationToken cancellationToken) { var retrievedDeleter = await _endUserRepository.LoadAsync(deletedById, cancellationToken); diff --git a/src/EndUsersApplication/IEndUsersApplication.DomainEventHandlers.cs b/src/EndUsersApplication/IEndUsersApplication.DomainEventHandlers.cs index 241cbed8..339ead21 100644 --- a/src/EndUsersApplication/IEndUsersApplication.DomainEventHandlers.cs +++ b/src/EndUsersApplication/IEndUsersApplication.DomainEventHandlers.cs @@ -3,6 +3,7 @@ using Domain.Events.Shared.Organizations; using Domain.Events.Shared.Subscriptions; using Created = Domain.Events.Shared.Organizations.Created; +using Deleted = Domain.Events.Shared.Organizations.Deleted; namespace EndUsersApplication; @@ -22,5 +23,4 @@ Task> HandleOrganizationRoleUnassignedAsync(ICallerContext caller, Task> HandleSubscriptionPlanChangedAsync(ICallerContext caller, SubscriptionPlanChanged domainEvent, CancellationToken cancellationToken); - } \ No newline at end of file diff --git a/src/EndUsersDomain/EndUserRoot.RolesAndFeatures.cs b/src/EndUsersDomain/EndUserRoot.RolesAndFeatures.cs index 25c4b66f..171821bc 100644 --- a/src/EndUsersDomain/EndUserRoot.RolesAndFeatures.cs +++ b/src/EndUsersDomain/EndUserRoot.RolesAndFeatures.cs @@ -82,7 +82,7 @@ public Result AssignMembershipFeatures(Identifier assignerId, Identifier public Result AssignMembershipRoles(EndUserRoot assigner, Identifier organizationId, Roles rolesToAssign, AssignTenantRolesAction onAssign) { - if (!IsOrganizationOwner(assigner, organizationId)) + if (!IsAnOrganizationOwner(assigner, organizationId)) { return Error.RoleViolation(Resources.EndUserRoot_NotOrganizationOwner); } @@ -370,7 +370,7 @@ public Result UnassignMembershipFeatures(Identifier unassigne public Result UnassignMembershipRoles(EndUserRoot unassigner, Identifier organizationId, Roles rolesToUnassign, UnassignTenantRolesAction onUnassign) { - if (!IsOrganizationOwner(unassigner, organizationId)) + if (!IsAnOrganizationOwner(unassigner, organizationId)) { return Error.RoleViolation(Resources.EndUserRoot_NotOrganizationOwner); } diff --git a/src/EndUsersDomain/EndUserRoot.cs b/src/EndUsersDomain/EndUserRoot.cs index 94ff0c8d..c09192d4 100644 --- a/src/EndUsersDomain/EndUserRoot.cs +++ b/src/EndUsersDomain/EndUserRoot.cs @@ -435,7 +435,7 @@ public Result AddMembership(EndUserRoot adder, OrganizationOwnership owne var skipOwnershipCheck = adder.Id == Id; if (!skipOwnershipCheck) { - if (!IsOrganizationOwner(adder, organizationId)) + if (!IsAnOrganizationOwner(adder, organizationId)) { return Error.RoleViolation(Resources.EndUserRoot_NotOrganizationOwner); } @@ -565,7 +565,7 @@ public async Task> ReInviteGuestAsync(ITokensService tokensService public Result RemoveMembership(EndUserRoot remover, Identifier organizationId) { - if (!IsOrganizationOwner(remover, organizationId)) + if (!IsAnOrganizationOwner(remover, organizationId)) { return Error.RoleViolation(Resources.EndUserRoot_NotOrganizationOwner); } @@ -647,7 +647,7 @@ private static bool IsPlatformOperator(Roles roles) return roles.HasRole(PlatformRoles.Operations); } - private static bool IsOrganizationOwner(EndUserRoot assigner, Identifier organizationId) + private static bool IsAnOrganizationOwner(EndUserRoot assigner, Identifier organizationId) { var retrieved = assigner.Memberships.FindByOrganizationId(organizationId); if (!retrieved.HasValue) diff --git a/src/OrganizationsApplication.UnitTests/OrganizationsApplication.DomainEventHandlersSpec.cs b/src/OrganizationsApplication.UnitTests/OrganizationsApplication.DomainEventHandlersSpec.cs index e708a468..098637a9 100644 --- a/src/OrganizationsApplication.UnitTests/OrganizationsApplication.DomainEventHandlersSpec.cs +++ b/src/OrganizationsApplication.UnitTests/OrganizationsApplication.DomainEventHandlersSpec.cs @@ -59,10 +59,11 @@ public OrganizationsApplicationDomainEventHandlersSpec() _repository.Setup(ar => ar.SaveAsync(It.IsAny(), It.IsAny())) .Returns((OrganizationRoot root, CancellationToken _) => Task.FromResult>(root)); + var subscriptionService = new Mock(); _application = new OrganizationsApplication(_recorder.Object, _identifierFactory.Object, _tenantSettingsService.Object, _tenantSettingService.Object, endUsersService.Object, _imagesService.Object, - _repository.Object); + subscriptionService.Object, _repository.Object); } [Fact] @@ -192,7 +193,8 @@ public async Task WhenHandleSubscriptionCreatedAsyncAsync_ThenTransfersBillingSu result.Should().BeSuccess(); _repository.Verify(rep => rep.SaveAsync(It.Is(root => - root.BillingSubscriberId == "abuyerid" + root.BillingSubscriber.Value.SubscriberId == "abuyerid" + && root.BillingSubscriber.Value.SubscriptionId == "asubscriptionid" ), It.IsAny())); } @@ -206,7 +208,7 @@ public async Task WhenHandleSubscriptionTransferredAsync_ThenTransfersBillingSub var org = OrganizationRoot.Create(_recorder.Object, _identifierFactory.Object, _tenantSettingService.Object, OrganizationOwnership.Shared, "anownerid".ToId(), UserClassification.Person, DisplayName.Create("aname").Value).Value; - org.SubscribeBilling("atransfererid".ToId()); + org.SubscribeBilling("asubscriptionid".ToId(), "atransfererid".ToId()); _repository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(org); @@ -215,7 +217,9 @@ public async Task WhenHandleSubscriptionTransferredAsync_ThenTransfersBillingSub result.Should().BeSuccess(); _repository.Verify(rep => rep.SaveAsync(It.Is(root => - root.BillingSubscriberId == "atransfereeid" + root.BillingSubscriber.Value.SubscriberId == "atransfereeid" + && root.BillingSubscriber.Value.SubscriptionId == "asubscriptionid" + ), It.IsAny())); } } \ No newline at end of file diff --git a/src/OrganizationsApplication.UnitTests/OrganizationsApplication.SubscriptionOwningEntitySpec.cs b/src/OrganizationsApplication.UnitTests/OrganizationsApplication.SubscriptionOwningEntitySpec.cs index 397a7846..f0b4dd83 100644 --- a/src/OrganizationsApplication.UnitTests/OrganizationsApplication.SubscriptionOwningEntitySpec.cs +++ b/src/OrganizationsApplication.UnitTests/OrganizationsApplication.SubscriptionOwningEntitySpec.cs @@ -56,10 +56,11 @@ public OrganizationsApplicationSubscriptionOwningEntitySpec() _repository.Setup(ar => ar.SaveAsync(It.IsAny(), It.IsAny())) .Returns((OrganizationRoot root, CancellationToken _) => Task.FromResult>(root)); + var subscriptionService = new Mock(); _application = new OrganizationsApplication(_recorder.Object, _identifierFactory.Object, tenantSettingsService.Object, _tenantSettingService.Object, _endUsersService.Object, imagesService.Object, - _repository.Object); + subscriptionService.Object, _repository.Object); } [Fact] @@ -105,7 +106,7 @@ public async Task WhenCanCancelSubscriptionAsync_ThenReturnsPermission() var organization = OrganizationRoot.Create(_recorder.Object, _identifierFactory.Object, _tenantSettingService.Object, OrganizationOwnership.Personal, "auserid".ToId(), UserClassification.Person, DisplayName.Create("aname").Value).Value; - organization.SubscribeBilling("acancellerid".ToId()); + organization.SubscribeBilling("asubscriptionid".ToId(), "acancellerid".ToId()); _repository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(organization); _endUsersService.Setup(eus => @@ -178,7 +179,7 @@ public async Task WhenCanChangeSubscriptionPlanAsync_ThenReturnsPermission() var organization = OrganizationRoot.Create(_recorder.Object, _identifierFactory.Object, _tenantSettingService.Object, OrganizationOwnership.Personal, "auserid".ToId(), UserClassification.Person, DisplayName.Create("aname").Value).Value; - organization.SubscribeBilling("amodifierid".ToId()); + organization.SubscribeBilling("asubscriptionid".ToId(), "amodifierid".ToId()); _repository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(organization); _endUsersService.Setup(eus => @@ -253,7 +254,7 @@ public async Task WhenCanTransferSubscriptionAsync_ThenReturnsPermission() var organization = OrganizationRoot.Create(_recorder.Object, _identifierFactory.Object, _tenantSettingService.Object, OrganizationOwnership.Personal, "auserid".ToId(), UserClassification.Person, DisplayName.Create("aname").Value).Value; - organization.SubscribeBilling("atransfererid".ToId()); + organization.SubscribeBilling("asubscriptionid".ToId(), "atransfererid".ToId()); _repository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(organization); _endUsersService.Setup(eus => @@ -304,7 +305,7 @@ public async Task WhenCanUnsubscribeAsync_ThenReturnsPermission() var organization = OrganizationRoot.Create(_recorder.Object, _identifierFactory.Object, _tenantSettingService.Object, OrganizationOwnership.Personal, "auserid".ToId(), UserClassification.Person, DisplayName.Create("aname").Value).Value; - organization.SubscribeBilling("anunsubsciberid".ToId()); + organization.SubscribeBilling("asubscriptionid".ToId(), "anunsubsciberid".ToId()); _repository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(organization); @@ -359,7 +360,7 @@ public async Task WhenCanViewSubscriptionAsync_ThenReturnsPermission() var organization = OrganizationRoot.Create(_recorder.Object, _identifierFactory.Object, _tenantSettingService.Object, OrganizationOwnership.Personal, "auserid".ToId(), UserClassification.Person, DisplayName.Create("aname").Value).Value; - organization.SubscribeBilling("aviewerid".ToId()); + organization.SubscribeBilling("asubscriptionid".ToId(), "aviewerid".ToId()); _repository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(organization); _endUsersService.Setup(eus => diff --git a/src/OrganizationsApplication.UnitTests/OrganizationsApplicationSpec.cs b/src/OrganizationsApplication.UnitTests/OrganizationsApplicationSpec.cs index 7f8e0230..8e7070ba 100644 --- a/src/OrganizationsApplication.UnitTests/OrganizationsApplicationSpec.cs +++ b/src/OrganizationsApplication.UnitTests/OrganizationsApplicationSpec.cs @@ -33,6 +33,7 @@ public class OrganizationsApplicationSpec private readonly Mock _repository; private readonly Mock _tenantSettingService; private readonly Mock _tenantSettingsService; + private readonly Mock _subscriptionsService; public OrganizationsApplicationSpec() { @@ -55,13 +56,14 @@ public OrganizationsApplicationSpec() .Returns((string value) => value); _endUsersService = new Mock(); _imagesService = new Mock(); + _subscriptionsService = new Mock(); _repository = new Mock(); _repository.Setup(ar => ar.SaveAsync(It.IsAny(), It.IsAny())) .Returns((OrganizationRoot root, CancellationToken _) => Task.FromResult>(root)); _application = new OrganizationsApplication(_recorder.Object, _idFactory.Object, _tenantSettingsService.Object, - _tenantSettingService.Object, _endUsersService.Object, _imagesService.Object, + _tenantSettingService.Object, _endUsersService.Object, _imagesService.Object, _subscriptionsService.Object, _repository.Object); } @@ -776,11 +778,34 @@ public async Task WhenDeleteOrganizationAsync_ThenDeletes() { _caller.Setup(cc => cc.Roles) .Returns(new ICallerContext.CallerRoles([], new[] { TenantRoles.Owner })); + _caller.Setup(cc => cc.CallerId) + .Returns("acallerid"); var org = OrganizationRoot.Create(_recorder.Object, _idFactory.Object, _tenantSettingService.Object, OrganizationOwnership.Personal, "auserid".ToId(), UserClassification.Person, DisplayName.Create("aname").Value).Value; + org.SubscribeBilling("asubscriptionid".ToId(), "acallerid".ToId()); _repository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(org); + _subscriptionsService.Setup(ss => + ss.GetSubscriptionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new SubscriptionWithPlan + { + Id = "asubscriptionid", + Invoice = new InvoiceSummary + { + Currency = "acurrency" + }, + PaymentMethod = new SubscriptionPaymentMethod(), + Period = new PlanPeriod(), + Plan = new SubscriptionPlan + { + Id = "aplanid" + }, + SubscriptionId = "asubscriptionid", + BuyerId = "abuyerid", + OwningEntityId = "anowningentityid", + CanBeUnsubscribed = true + }); var result = await _application.DeleteOrganizationAsync(_caller.Object, "anorganizationid", CancellationToken.None); diff --git a/src/OrganizationsApplication/OrganizationsApplication.DomainEventHandlers.cs b/src/OrganizationsApplication/OrganizationsApplication.DomainEventHandlers.cs index 11f8622b..7c93ce96 100644 --- a/src/OrganizationsApplication/OrganizationsApplication.DomainEventHandlers.cs +++ b/src/OrganizationsApplication/OrganizationsApplication.DomainEventHandlers.cs @@ -39,7 +39,8 @@ public async Task> HandleSubscriptionCreatedAsync(ICallerContext c Created domainEvent, CancellationToken cancellationToken) { return await HandleCreatedSubscriptionAsync(caller, - domainEvent.OwningEntityId.ToId(), domainEvent.BuyerId.ToId(), cancellationToken); + domainEvent.RootId.ToId(), domainEvent.OwningEntityId.ToId(), domainEvent.BuyerId.ToId(), + cancellationToken); } public async Task> HandleSubscriptionTransferredAsync(ICallerContext caller, @@ -64,7 +65,7 @@ public async Task> HandleEndUserMembershipRemovedAsync(ICallerCont domainEvent.OrganizationId.ToId(), cancellationToken); } - private async Task> HandleCreatedSubscriptionAsync(ICallerContext caller, + private async Task> HandleCreatedSubscriptionAsync(ICallerContext caller, Identifier subscriptionId, Identifier organizationId, Identifier billingSubscriberId, CancellationToken cancellationToken) { var retrieved = await _repository.LoadAsync(organizationId, cancellationToken); @@ -74,7 +75,7 @@ private async Task> HandleCreatedSubscriptionAsync(ICallerContext } var org = retrieved.Value; - var subscribed = org.SubscribeBilling(billingSubscriberId); + var subscribed = org.SubscribeBilling(subscriptionId, billingSubscriberId); if (subscribed.IsFailure) { return subscribed.Error; diff --git a/src/OrganizationsApplication/OrganizationsApplication.cs b/src/OrganizationsApplication/OrganizationsApplication.cs index dd63d067..1435e31c 100644 --- a/src/OrganizationsApplication/OrganizationsApplication.cs +++ b/src/OrganizationsApplication/OrganizationsApplication.cs @@ -25,12 +25,13 @@ public partial class OrganizationsApplication : IOrganizationsApplication private readonly IImagesService _imagesService; private readonly IRecorder _recorder; private readonly IOrganizationRepository _repository; + private readonly ISubscriptionsService _subscriptionService; private readonly ITenantSettingService _tenantSettingService; private readonly ITenantSettingsService _tenantSettingsService; public OrganizationsApplication(IRecorder recorder, IIdentifierFactory identifierFactory, ITenantSettingsService tenantSettingsService, ITenantSettingService tenantSettingService, - IEndUsersService endUsersService, IImagesService imagesService, + IEndUsersService endUsersService, IImagesService imagesService, ISubscriptionsService subscriptionService, IOrganizationRepository repository) { _recorder = recorder; @@ -38,6 +39,7 @@ public OrganizationsApplication(IRecorder recorder, IIdentifierFactory identifie _tenantSettingService = tenantSettingService; _endUsersService = endUsersService; _imagesService = imagesService; + _subscriptionService = subscriptionService; _tenantSettingsService = tenantSettingsService; _repository = repository; } @@ -151,15 +153,25 @@ public async Task> DeleteOrganizationAsync(ICallerContext caller, return retrieved.Error; } + var org = retrieved.Value; + var subscription = + await _subscriptionService.GetSubscriptionAsync(caller, org.BillingSubscriber.Value.SubscriptionId, + cancellationToken); + if (subscription.IsFailure) + { + return subscription.Error; + } + + var canBillingSubscriptionBeUnsubscribed = subscription.Value.CanBeUnsubscribed; var deleterRoles = Roles.Create(caller.Roles.Tenant); if (deleterRoles.IsFailure) { return deleterRoles.Error; } - var org = retrieved.Value; var deleterId = caller.ToCallerId(); - var deleted = org.DeleteOrganization(deleterId, deleterRoles.Value); + var deleted = await org.DeleteOrganizationAsync(deleterId, deleterRoles.Value, + canBillingSubscriptionBeUnsubscribed, OnDelete, cancellationToken); if (deleted.IsFailure) { return deleted.Error; @@ -175,6 +187,14 @@ public async Task> DeleteOrganizationAsync(ICallerContext caller, _recorder.TraceInformation(caller.ToCall(), "Deleted organization: {Id}", org.Id); return Result.Ok; + + Task> OnDelete(Identifier deleterId1) + { + _recorder.Audit(caller.ToCall(), + Audits.OrganizationApplication_OrganizationDeleted, + "Organization {Id} was permanently deleted by {DeleterId}", org.Id, deleterId); + return Task.FromResult(Result.Ok); + } } public async Task> GetOrganizationAsync(ICallerContext caller, string id, @@ -553,8 +573,6 @@ private async Task> CreateOrganizationInternalAsync( return configured.Error; } - //TODO: Get the billing details for the creator and add the billing subscription for them - var saved = await _repository.SaveAsync(org, cancellationToken); if (saved.IsFailure) { diff --git a/src/OrganizationsApplication/Persistence/ReadModels/Organization.cs b/src/OrganizationsApplication/Persistence/ReadModels/Organization.cs index 51430c2b..bfdc95ff 100644 --- a/src/OrganizationsApplication/Persistence/ReadModels/Organization.cs +++ b/src/OrganizationsApplication/Persistence/ReadModels/Organization.cs @@ -12,11 +12,13 @@ public class Organization : ReadModelEntity public Optional AvatarUrl { get; set; } + public Optional BillingSubscriberId { get; set; } + + public Optional BillingSubscriptionId { get; set; } + public Optional CreatedById { get; set; } public Optional Name { get; set; } public Optional Ownership { get; set; } - - public Optional BillingSubscriberId { get; set; } } \ No newline at end of file diff --git a/src/OrganizationsDomain.UnitTests/OrganizationRootSpec.cs b/src/OrganizationsDomain.UnitTests/OrganizationRootSpec.cs index 96736038..9a4bc629 100644 --- a/src/OrganizationsDomain.UnitTests/OrganizationRootSpec.cs +++ b/src/OrganizationsDomain.UnitTests/OrganizationRootSpec.cs @@ -10,6 +10,7 @@ using Domain.Shared; using Domain.Shared.EndUsers; using Domain.Shared.Organizations; +using Domain.Shared.Subscriptions; using FluentAssertions; using Moq; using UnitTesting.Common; @@ -57,7 +58,7 @@ public void WhenCreate_ThenAssigns() { _org.Name.Name.Should().Be("aname"); _org.CreatedById.Should().Be("acreatorid".ToId()); - _org.BillingSubscriberId.Should().Be(Identifier.Empty()); + _org.BillingSubscriber.Should().Be(Optional.None); _org.Ownership.Should().Be(OrganizationOwnership.Shared); _org.Settings.Should().Be(Settings.Empty); } @@ -162,7 +163,7 @@ public void WhenUnInviteMemberAndRemoverNotOwner_ThenReturnsError() [Fact] public void WhenUnInviteMemberAndBillingSubscriber_ThenReturnsError() { - _org.SubscribeBilling("asubscriberid".ToId()); + _org.SubscribeBilling("asubscriptionid".ToId(), "asubscriberid".ToId()); var result = _org.UnInviteMember("aremoverid".ToId(), Roles.Create(TenantRoles.Owner).Value, "asubscriberid".ToId()); @@ -444,28 +445,58 @@ public void WhenUnassignRoles_ThenUnassigns() } [Fact] - public void WhenDeleteOrganizationAndNotOwner_ThenReturnsError() + public async Task WhenDeleteOrganizationAndNotOwner_ThenReturnsError() { - var result = _org.DeleteOrganization("adeleterid".ToId(), Roles.Empty); + var result = await _org.DeleteOrganizationAsync("adeleterid".ToId(), Roles.Empty, true, + _ => Task.FromResult(Result.Ok), CancellationToken.None); result.Should().BeError(ErrorCode.RoleViolation, Resources.OrganizationRoot_UserNotOrgOwner); } + + [Fact] + public async Task WhenDeleteOrganizationAndNotBillingSubscriber_ThenReturnsError() + { + _org.SubscribeBilling("asubscriptionid".ToId(), "asubscriberid".ToId()); + + var result = await _org.DeleteOrganizationAsync("adeleterid".ToId(), + Roles.Create(TenantRoles.BillingAdmin).Value, true, _ => Task.FromResult(Result.Ok), + CancellationToken.None); + + result.Should().BeError(ErrorCode.RoleViolation, Resources.OrganizationRoot_UserNotBillingSubscriber); + } [Fact] - public void WhenDeleteOrganizationAndHasOtherMembers_ThenReturnsError() + public async Task WhenDeleteOrganizationAndHasOtherMembers_ThenReturnsError() { _org.AddMembership("auserid1".ToId()); + _org.SubscribeBilling("asubscriptionid".ToId(), "asubscriberid".ToId()); - var result = _org.DeleteOrganization("adeleterid".ToId(), Roles.Create(TenantRoles.Owner).Value); + var result = await _org.DeleteOrganizationAsync("asubscriberid".ToId(), Roles.Create(TenantRoles.Owner).Value, + true, _ => Task.FromResult(Result.Ok), CancellationToken.None); result.Should().BeError(ErrorCode.RuleViolation, Resources.OrganizationRoot_DeleteOrganization_MembersStillExist); } + + [Fact] + public async Task WhenDeleteOrganizationAndCannotBeUnsubscribed_ThenReturnsError() + { + _org.SubscribeBilling("asubscriptionid".ToId(), "asubscriberid".ToId()); + + var result = await _org.DeleteOrganizationAsync("asubscriberid".ToId(), Roles.Create(TenantRoles.Owner).Value, + false, _ => Task.FromResult(Result.Ok), CancellationToken.None); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.OrganizationRoot_DeleteOrganization_BillingSubscriptionCannotBeUnsubscribed); + } [Fact] - public void WhenDeleteOrganization_ThenDeletes() + public async Task WhenDeleteOrganization_ThenDeletes() { - var result = _org.DeleteOrganization("adeleterid".ToId(), Roles.Create(TenantRoles.Owner).Value); + _org.SubscribeBilling("asubscriptionid".ToId(), "asubscriberid".ToId()); + + var result = await _org.DeleteOrganizationAsync("asubscriberid".ToId(), Roles.Create(TenantRoles.Owner).Value, + true, _ => Task.FromResult(Result.Ok), CancellationToken.None); result.Should().BeSuccess(); _org.IsDeleted.Value.Should().BeTrue(); @@ -482,12 +513,12 @@ public void WhenTransferBillingSubscriberByOther_ThenReturnsError() [Fact] public void WhenTransferBillingSubscriberByBillingSubscriber_ThenTransfers() { - _org.SubscribeBilling("asubscriberid".ToId()); + _org.SubscribeBilling("asubscriptionid".ToId(), "asubscriberid".ToId()); var result = _org.TransferBillingSubscriber("asubscriberid".ToId(), "atransfereeid".ToId()); result.Should().BeSuccess(); - _org.BillingSubscriberId.Should().Be("atransfereeid".ToId()); + _org.BillingSubscriber.Value.SubscriberId.Should().Be("atransfereeid".ToId()); _org.Events.Last().Should().BeOfType(); } @@ -503,7 +534,7 @@ public void WhenCanCancelBillingSubscriptionAndNotBillingSubscriber_ThenDenies() [Fact] public void WhenCanCancelBillingSubscriptionAndBillingSubscriber_ThenAllows() { - _org.SubscribeBilling("acancellerid".ToId()); + _org.SubscribeBilling("asubscriptionid".ToId(), "acancellerid".ToId()); var result = _org.CanCancelBillingSubscription("acancellerid".ToId(), Roles.Empty); @@ -551,7 +582,7 @@ public void WhenCanTransferBillingSubscriberAndNotBillingSubscriber_ThenDenies() [Fact] public void WhenCanTransferBillingSubscriberAndTransfereeNotBillingAdmin_ThenDenies() { - _org.SubscribeBilling("asubscriberid".ToId()); + _org.SubscribeBilling("asubscriptionid".ToId(), "asubscriberid".ToId()); var result = _org.CanTransferBillingSubscription("asubscriberid".ToId(), "atransfereeid".ToId(), Roles.Empty); @@ -563,7 +594,7 @@ public void WhenCanTransferBillingSubscriberAndTransfereeNotBillingAdmin_ThenDen [Fact] public void WhenCanTransferBillingSubscriberAndTransfereeIsBillingAdmin_ThenAllows() { - _org.SubscribeBilling("asubscriberid".ToId()); + _org.SubscribeBilling("asubscriptionid".ToId(), "asubscriberid".ToId()); var result = _org.CanTransferBillingSubscription("asubscriberid".ToId(), "atransfereeid".ToId(), Roles.Create(TenantRoles.BillingAdmin).Value); @@ -583,7 +614,7 @@ public void WhenCanUnsubscribeSubscriptionAndNotBillingSubscriber_ThenDenies() [Fact] public void WhenCanUnsubscribeBillingSubscription_ThenAllows() { - _org.SubscribeBilling("anunsubscriberid".ToId()); + _org.SubscribeBilling("asubscriptionid".ToId(), "anunsubscriberid".ToId()); var result = _org.CanUnsubscribeBillingSubscription("anunsubscriberid".ToId()); diff --git a/src/OrganizationsDomain/Events.cs b/src/OrganizationsDomain/Events.cs index 91f1ff8d..66455905 100644 --- a/src/OrganizationsDomain/Events.cs +++ b/src/OrganizationsDomain/Events.cs @@ -28,10 +28,11 @@ public static AvatarRemoved AvatarRemoved(Identifier id, Identifier avatarId) }; } - public static BillingSubscribed BillingSubscribed(Identifier id, Identifier subscriberId) + public static BillingSubscribed BillingSubscribed(Identifier id, Identifier subscriptionId, Identifier subscriberId) { return new BillingSubscribed(id) { + SubscriptionId = subscriptionId, SubscriberId = subscriberId }; } diff --git a/src/OrganizationsDomain/OrganizationRoot.cs b/src/OrganizationsDomain/OrganizationRoot.cs index 86c9e2e1..59fcf5ba 100644 --- a/src/OrganizationsDomain/OrganizationRoot.cs +++ b/src/OrganizationsDomain/OrganizationRoot.cs @@ -12,9 +12,12 @@ using Domain.Shared; using Domain.Shared.EndUsers; using Domain.Shared.Organizations; +using Domain.Shared.Subscriptions; namespace OrganizationsDomain; +public delegate Task> DeleteAction(Identifier deleterId); + public sealed class OrganizationRoot : AggregateRootBase { private readonly ITenantSettingService _tenantSettingService; @@ -51,7 +54,7 @@ private OrganizationRoot(IRecorder recorder, IIdentifierFactory idFactory, public Optional Avatar { get; private set; } - public Identifier BillingSubscriberId { get; private set; } = Identifier.Empty(); + public Optional BillingSubscriber { get; private set; } public Identifier CreatedById { get; private set; } = Identifier.Empty(); @@ -222,7 +225,14 @@ protected override Result OnStateChanged(IDomainEvent @event, bool isReco case BillingSubscribed changed: { - BillingSubscriberId = changed.SubscriberId.ToId(); + var subscriber = + Domain.Shared.Subscriptions.BillingSubscriber.Create(changed.SubscriptionId, changed.SubscriberId); + if (subscriber.IsFailure) + { + return subscriber.Error; + } + + BillingSubscriber = subscriber.Value; Recorder.TraceDebug(null, "Organization {Id} subscribed to billing for {Subscriber}", Id, changed.SubscriberId); return Result.Ok; @@ -230,7 +240,12 @@ protected override Result OnStateChanged(IDomainEvent @event, bool isReco case BillingSubscriberChanged changed: { - BillingSubscriberId = changed.ToSubscriberId.ToId(); + if (!BillingSubscriber.HasValue) + { + return Error.RuleViolation(Resources.OrganizationRoot_NoSubscriber); + } + + BillingSubscriber = BillingSubscriber.Value.ChangeSubscriber(changed.ToSubscriberId.ToId()); Recorder.TraceDebug(null, "Organization {Id} changed billing subscriber from {From} to {To}", Id, changed.FromSubscriberId, changed.ToSubscriberId); return Result.Ok; @@ -390,15 +405,24 @@ public Result CreateSettings(Settings settings) return Result.Ok; } - public Result DeleteOrganization(Identifier deleterId, Roles deleterRoles) + public async Task> DeleteOrganizationAsync(Identifier deleterId, Roles deleterRoles, + bool canBillingSubscriptionBeUnsubscribed, DeleteAction onDelete, CancellationToken cancellationToken) { if (!IsOwner(deleterRoles)) { return Error.RoleViolation(Resources.OrganizationRoot_UserNotOrgOwner); } - //TODO: Must be the BillingBuyer - //TODO: BillingBuyer.CanBeUnsubscribed must be true + if (!IsBillingSubscriber(deleterId)) + { + return Error.RoleViolation(Resources.OrganizationRoot_UserNotBillingSubscriber); + } + + if (!canBillingSubscriptionBeUnsubscribed) + { + return Error.RuleViolation(Resources + .OrganizationRoot_DeleteOrganization_BillingSubscriptionCannotBeUnsubscribed); + } var otherMembers = Memberships.Members .Select(m => m.UserId) @@ -409,7 +433,13 @@ public Result DeleteOrganization(Identifier deleterId, Roles deleterRoles return Error.RuleViolation(Resources.OrganizationRoot_DeleteOrganization_MembersStillExist); } - return RaisePermanentDeleteEvent(OrganizationsDomain.Events.Deleted(Id, deleterId)); + var deleted = RaisePermanentDeleteEvent(OrganizationsDomain.Events.Deleted(Id, deleterId)); + if (deleted.IsFailure) + { + return deleted.Error; + } + + return await onDelete(deleterId); } public Result ForceRemoveAvatar(Identifier deleterId) @@ -483,9 +513,9 @@ public Result RemoveMembership(Identifier userId) return RaiseChangeEvent(OrganizationsDomain.Events.MembershipRemoved(Id, userId)); } - public Result SubscribeBilling(Identifier subscriberId) + public Result SubscribeBilling(Identifier subscriptionId, Identifier subscriberId) { - return RaiseChangeEvent(OrganizationsDomain.Events.BillingSubscribed(Id, subscriberId)); + return RaiseChangeEvent(OrganizationsDomain.Events.BillingSubscribed(Id, subscriptionId, subscriberId)); } #if TESTINGONLY @@ -503,6 +533,11 @@ public Result TransferBillingSubscriber(Identifier transfererId, Identifi return Error.RoleViolation(Resources.OrganizationRoot_UserNotBillingSubscriber); } + if (!BillingSubscriber.HasValue) + { + return Error.RuleViolation(Resources.OrganizationRoot_NoSubscriber); + } + return RaiseChangeEvent(OrganizationsDomain.Events.BillingSubscriberChanged(Id, transfererId, transfereeId)); } @@ -606,7 +641,12 @@ private bool IsMember(Identifier userId) private bool IsBillingSubscriber(Identifier userId) { - return userId == BillingSubscriberId; + if (!BillingSubscriber.HasValue) + { + return false; + } + + return userId == BillingSubscriber.Value.SubscriberId; } private bool IsBillingAdminAndNotBillingSubscriber(Identifier userId, Roles roles) diff --git a/src/OrganizationsDomain/Resources.Designer.cs b/src/OrganizationsDomain/Resources.Designer.cs index 97dab3da..ef871602 100644 --- a/src/OrganizationsDomain/Resources.Designer.cs +++ b/src/OrganizationsDomain/Resources.Designer.cs @@ -86,6 +86,15 @@ internal static string OrganizationRoot_Create_SharedRequiresPerson { } } + /// + /// Looks up a localized string similar to Cannot delete this organization as the billing subscription cannot be terminated in its current state. + /// + internal static string OrganizationRoot_DeleteOrganization_BillingSubscriptionCannotBeUnsubscribed { + get { + return ResourceManager.GetString("OrganizationRoot_DeleteOrganization_BillingSubscriptionCannotBeUnsubscribed", resourceCulture); + } + } + /// /// Looks up a localized string similar to Cannot delete this organization while any others members still exist. They must be removed from the organization first. /// @@ -122,6 +131,15 @@ internal static string OrganizationRoot_NoAvatar { } } + /// + /// Looks up a localized string similar to This organization has no billing subscriber. + /// + internal static string OrganizationRoot_NoSubscriber { + get { + return ResourceManager.GetString("OrganizationRoot_NoSubscriber", resourceCulture); + } + } + /// /// Looks up a localized string similar to Must be an billing administrator to perform this action. /// diff --git a/src/OrganizationsDomain/Resources.resx b/src/OrganizationsDomain/Resources.resx index f3ff8916..3415ccfb 100644 --- a/src/OrganizationsDomain/Resources.resx +++ b/src/OrganizationsDomain/Resources.resx @@ -78,5 +78,11 @@ Must be the owner of the billing subscription or be an billing administrator to perform this action + + Cannot delete this organization as the billing subscription cannot be terminated in its current state + + + This organization has no billing subscriber + \ No newline at end of file diff --git a/src/OrganizationsInfrastructure.IntegrationTests/OrganizationsApiSpec.cs b/src/OrganizationsInfrastructure.IntegrationTests/OrganizationsApiSpec.cs index adaea745..2847adaa 100644 --- a/src/OrganizationsInfrastructure.IntegrationTests/OrganizationsApiSpec.cs +++ b/src/OrganizationsInfrastructure.IntegrationTests/OrganizationsApiSpec.cs @@ -9,6 +9,7 @@ using Infrastructure.Web.Api.Operations.Shared.Identities; using Infrastructure.Web.Api.Operations.Shared.Images; using Infrastructure.Web.Api.Operations.Shared.Organizations; +using Infrastructure.Web.Api.Operations.Shared.Subscriptions; using Infrastructure.Web.Interfaces.Clients; using IntegrationTesting.WebApi.Common; using Xunit; @@ -635,6 +636,14 @@ public async Task WhenDeleteAndHasNoMembers_ThenDeletes() }, req => req.SetJWTBearerToken(loginA.AccessToken)); result.StatusCode.Should().Be(HttpStatusCode.NoContent); + + await PropagateDomainEventsAsync(); + var subscription = await Api.GetAsync(new GetSubscriptionRequest + { + Id = organizationId + }, req => req.SetJWTBearerToken(loginA.AccessToken)); + + subscription.StatusCode.Should().Be(HttpStatusCode.NotFound); } [Fact] diff --git a/src/OrganizationsInfrastructure/ApplicationServices/OrganizationsInProcessServiceClient.cs b/src/OrganizationsInfrastructure/ApplicationServices/OrganizationsInProcessServiceClient.cs index 2819dff4..59e3b115 100644 --- a/src/OrganizationsInfrastructure/ApplicationServices/OrganizationsInProcessServiceClient.cs +++ b/src/OrganizationsInfrastructure/ApplicationServices/OrganizationsInProcessServiceClient.cs @@ -7,7 +7,7 @@ namespace OrganizationsInfrastructure.ApplicationServices; -public class OrganizationsInProcessServiceClient : IOrganizationsService, IOwningEntityService +public class OrganizationsInProcessServiceClient : IOrganizationsService, ISubscriptionOwningEntityService { private readonly Func _organizationsApplicationFactory; private IOrganizationsApplication? _application; diff --git a/src/OrganizationsInfrastructure/OrganizationsModule.cs b/src/OrganizationsInfrastructure/OrganizationsModule.cs index c28560ae..08915716 100644 --- a/src/OrganizationsInfrastructure/OrganizationsModule.cs +++ b/src/OrganizationsInfrastructure/OrganizationsModule.cs @@ -61,6 +61,7 @@ public Action RegisterServices c.GetRequiredService(), c.GetRequiredService(), c.GetRequiredService(), + c.GetRequiredService(), c.GetRequiredService())); services.AddPerHttpRequest(c => new OrganizationRepository(c.GetRequiredService(), @@ -87,7 +88,7 @@ public Action RegisterServices services.AddPerHttpRequest(c => new OrganizationsInProcessServiceClient(c.LazyGetRequiredService())); - services.AddPerHttpRequest(c => + services.AddPerHttpRequest(c => new OrganizationsInProcessServiceClient(c.LazyGetRequiredService())); }; } diff --git a/src/OrganizationsInfrastructure/Persistence/ReadModels/OrganizationProjection.cs b/src/OrganizationsInfrastructure/Persistence/ReadModels/OrganizationProjection.cs index da680c56..3157a8bf 100644 --- a/src/OrganizationsInfrastructure/Persistence/ReadModels/OrganizationProjection.cs +++ b/src/OrganizationsInfrastructure/Persistence/ReadModels/OrganizationProjection.cs @@ -80,7 +80,11 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven case BillingSubscribed e: return await _organizations.HandleUpdateAsync(e.RootId, - dto => { dto.BillingSubscriberId = e.SubscriberId; }, + dto => + { + dto.BillingSubscriberId = e.SubscriberId; + dto.BillingSubscriptionId = e.SubscriptionId; + }, cancellationToken); case BillingSubscriberChanged e: diff --git a/src/SubscriptionsApplication.UnitTests/SubscriptionsApplication.DomainEventHandlersSpec.cs b/src/SubscriptionsApplication.UnitTests/SubscriptionsApplication.DomainEventHandlersSpec.cs index 4c6a9a63..ad7c007c 100644 --- a/src/SubscriptionsApplication.UnitTests/SubscriptionsApplication.DomainEventHandlersSpec.cs +++ b/src/SubscriptionsApplication.UnitTests/SubscriptionsApplication.DomainEventHandlersSpec.cs @@ -5,6 +5,7 @@ using Domain.Common.Identity; using Domain.Common.ValueObjects; using Domain.Interfaces.Entities; +using Domain.Services.Shared; using Domain.Shared.Subscriptions; using Moq; using OrganizationsDomain; @@ -23,14 +24,16 @@ public class SubscriptionsApplicationDomainEventHandlersSpec private readonly SubscriptionsApplication _application; private readonly Mock _billingProvider; private readonly Mock _caller; + private readonly Mock _identifierFactory; + private readonly Mock _recorder; private readonly Mock _repository; private readonly Mock _userProfilesService; public SubscriptionsApplicationDomainEventHandlersSpec() { - var recorder = new Mock(); - var identifierFactory = new Mock(); - identifierFactory.Setup(x => x.Create(It.IsAny())) + _recorder = new Mock(); + _identifierFactory = new Mock(); + _identifierFactory.Setup(x => x.Create(It.IsAny())) .Returns("anid".ToId()); _caller = new Mock(); _userProfilesService = new Mock(); @@ -46,10 +49,12 @@ public SubscriptionsApplicationDomainEventHandlersSpec() .Returns("abuyerreference"); _billingProvider.Setup(bp => bp.StateInterpreter.GetSubscriptionReference(It.IsAny())) .Returns("asubscriptionreference"); - var owningEntityService = new Mock(); + var owningEntityService = new Mock(); _repository = new Mock(); + _repository.Setup(rep => rep.SaveAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((SubscriptionRoot root, CancellationToken _) => root); - _application = new SubscriptionsApplication(recorder.Object, identifierFactory.Object, + _application = new SubscriptionsApplication(_recorder.Object, _identifierFactory.Object, _userProfilesService.Object, _billingProvider.Object, owningEntityService.Object, _repository.Object); } @@ -97,4 +102,23 @@ public async Task WhenHandleOrganizationCreatedAsync_ThenReturnsOk() _billingProvider.Verify(bp => bp.StateInterpreter.GetBuyerReference(It.IsAny())); _billingProvider.Verify(bp => bp.StateInterpreter.GetSubscriptionReference(It.IsAny())); } + + [Fact] + public async Task WhenHandleOrganizationDeletedAsync_ThenReturnsOk() + { + var stateInterpreter = new Mock(); + var subscription = SubscriptionRoot.Create(_recorder.Object, _identifierFactory.Object, + "anowningentityid".ToId(), "abuyerid".ToId(), stateInterpreter.Object).Value; + var domainEvent = Events.Deleted("anowningentityid".ToId(), "adeleterid".ToId()); + _repository.Setup(rep => rep.FindByOwningEntityIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(subscription.ToOptional()); + + var result = + await _application.HandleOrganizationDeletedAsync(_caller.Object, domainEvent, CancellationToken.None); + + result.Should().BeSuccess(); + _repository.Verify(rep => rep.SaveAsync(It.Is(root => + root.IsDeleted == true + ), It.IsAny())); + } } \ No newline at end of file diff --git a/src/SubscriptionsApplication.UnitTests/SubscriptionsApplicationSpec.cs b/src/SubscriptionsApplication.UnitTests/SubscriptionsApplicationSpec.cs index cd73bee0..3580c783 100644 --- a/src/SubscriptionsApplication.UnitTests/SubscriptionsApplicationSpec.cs +++ b/src/SubscriptionsApplication.UnitTests/SubscriptionsApplicationSpec.cs @@ -25,7 +25,7 @@ public class SubscriptionsApplicationSpec private readonly Mock _billingProvider; private readonly Mock _caller; private readonly Mock _identifierFactory; - private readonly Mock _owningEntityService; + private readonly Mock _owningEntityService; private readonly Mock _recorder; private readonly Mock _repository; private readonly Mock _userProfilesService; @@ -66,7 +66,7 @@ public SubscriptionsApplicationSpec() bp.StateInterpreter.TranslateAfterSubscriptionCancelled(It.IsAny(), It.IsAny())) .Returns((BillingProvider provider, BillingProviderState _) => provider); - _owningEntityService = new Mock(); + _owningEntityService = new Mock(); _repository = new Mock(); _repository.Setup(rep => rep.SaveAsync(It.IsAny(), It.IsAny())) .ReturnsAsync((SubscriptionRoot root, CancellationToken _) => root); diff --git a/src/SubscriptionsApplication/SubscriptionsApplication.cs b/src/SubscriptionsApplication/SubscriptionsApplication.cs index 68f0d0cd..f4bcc7d8 100644 --- a/src/SubscriptionsApplication/SubscriptionsApplication.cs +++ b/src/SubscriptionsApplication/SubscriptionsApplication.cs @@ -18,20 +18,20 @@ public partial class SubscriptionsApplication : ISubscriptionsApplication { private readonly IBillingProvider _billingProvider; private readonly IIdentifierFactory _identifierFactory; - private readonly IOwningEntityService _owningEntityService; + private readonly ISubscriptionOwningEntityService _subscriptionOwningEntityService; private readonly IRecorder _recorder; private readonly ISubscriptionRepository _repository; private readonly IUserProfilesService _userProfilesService; public SubscriptionsApplication(IRecorder recorder, IIdentifierFactory identifierFactory, IUserProfilesService userProfilesService, IBillingProvider billingProvider, - IOwningEntityService owningEntityService, ISubscriptionRepository repository) + ISubscriptionOwningEntityService subscriptionOwningEntityService, ISubscriptionRepository repository) { _recorder = recorder; _identifierFactory = identifierFactory; _userProfilesService = userProfilesService; _billingProvider = billingProvider; - _owningEntityService = owningEntityService; + _subscriptionOwningEntityService = subscriptionOwningEntityService; _repository = repository; } @@ -97,7 +97,8 @@ Task> OnChange(SubscriptionRoot subscription async Task CanChange(SubscriptionRoot subscription1, Identifier modifierId1) { - return (await _owningEntityService.CanChangeSubscriptionPlanAsync(caller, subscription1.OwningEntityId, + return (await _subscriptionOwningEntityService.CanChangeSubscriptionPlanAsync(caller, + subscription1.OwningEntityId, modifierId1, cancellationToken)) .Match(optional => optional.Value, Permission.Denied_Evaluating); } @@ -249,7 +250,8 @@ Task> OnCancel(SubscriptionRoot subscription async Task CanCancel(SubscriptionRoot subscription1, Identifier cancellerId1) { - return (await _owningEntityService.CanCancelSubscriptionAsync(caller, subscription1.OwningEntityId, + return (await _subscriptionOwningEntityService.CanCancelSubscriptionAsync(caller, + subscription1.OwningEntityId, cancellerId1, cancellationToken)) .Match(optional => optional.Value, Permission.Denied_Evaluating); } @@ -352,7 +354,8 @@ public async Task> TransferSubscriptionAsync async Task CanTransfer(SubscriptionRoot subscription1, Identifier transfererId1, Identifier transfereeId1) { - return (await _owningEntityService.CanTransferSubscriptionAsync(caller, subscription1.OwningEntityId, + return (await _subscriptionOwningEntityService.CanTransferSubscriptionAsync(caller, + subscription1.OwningEntityId, transfererId1, transfereeId1, cancellationToken)) .Match(optional => optional.Value, Permission.Denied_Evaluating); } @@ -391,7 +394,8 @@ private async Task> GetSubscriptionInternalA async Task CanView(SubscriptionRoot subscription1, Identifier viewerId1) { - return (await _owningEntityService.CanViewSubscriptionAsync(caller, subscription1.OwningEntityId, + return (await _subscriptionOwningEntityService.CanViewSubscriptionAsync(caller, + subscription1.OwningEntityId, viewerId1, cancellationToken)) .Match(optional => optional.Value, Permission.Denied_Evaluating); } diff --git a/src/SubscriptionsApplication/SubscriptionsApplication_DomainEventHandlers.cs b/src/SubscriptionsApplication/SubscriptionsApplication_DomainEventHandlers.cs index ae9bf12c..b5b511c3 100644 --- a/src/SubscriptionsApplication/SubscriptionsApplication_DomainEventHandlers.cs +++ b/src/SubscriptionsApplication/SubscriptionsApplication_DomainEventHandlers.cs @@ -21,8 +21,36 @@ public async Task> HandleOrganizationCreatedAsync(ICallerContext c public async Task> HandleOrganizationDeletedAsync(ICallerContext caller, Deleted domainEvent, CancellationToken cancellationToken) { - //TODO: complete this - throw new NotImplementedException(); + return await ForceDeleteSubscriptionForDeletedOrganizationAsync(caller, domainEvent.RootId.ToId(), + domainEvent.DeletedById.ToId(), cancellationToken); + } + + private async Task> ForceDeleteSubscriptionForDeletedOrganizationAsync(ICallerContext caller, + Identifier owningEntityId, Identifier deleterId, CancellationToken cancellationToken) + { + var retrieved = await GetSubscriptionByOwningEntityAsync(owningEntityId, cancellationToken); + if (retrieved.IsFailure) + { + return retrieved.Error; + } + + var subscription = retrieved.Value; + var deleted = subscription.DeleteSubscription(deleterId, owningEntityId); + if (deleted.IsFailure) + { + return deleted.Error; + } + + var saved = await _repository.SaveAsync(subscription, cancellationToken); + if (saved.IsFailure) + { + return saved.Error; + } + + subscription = saved.Value; + _recorder.TraceInformation(caller.ToCall(), "Subscription {Id} deleted", subscription.Id); + + return Result.Ok; } private async Task> CreateSubscriptionInternalAsync(ICallerContext caller, Identifier buyerId, diff --git a/src/SubscriptionsDomain.UnitTests/SubscriptionRootSpec.cs b/src/SubscriptionsDomain.UnitTests/SubscriptionRootSpec.cs index 4616ee7e..266556e6 100644 --- a/src/SubscriptionsDomain.UnitTests/SubscriptionRootSpec.cs +++ b/src/SubscriptionsDomain.UnitTests/SubscriptionRootSpec.cs @@ -765,6 +765,24 @@ public async Task WhenTransferSubscriptionAsyncByBuyerAndCancelled_ThenTransfers _interpreter.Verify(sp => sp.TranslateAfterSubscriptionTransferred(provider, It.IsAny())); } + [Fact] + public void WhenDeleteSubscriptionWithWrongOwningEntityId_ThenReturnsError() + { + var result = _subscription.DeleteSubscription("adeleterid".ToId(), "anotherentityid".ToId()); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.SubscriptionRoot_DeleteSubscription_NotOwningEntityId); + } + + [Fact] + public void WhenDeleteSubscription_ThenDeletes() + { + var result = _subscription.DeleteSubscription("adeleterid".ToId(), "anowningentityid".ToId()); + + result.Should().BeSuccess(); + _subscription.Events.Last().Should().BeOfType(); + } + private void SetupInitialProvider() { var provider = BillingProvider.Create("aprovidername", new BillingProviderState { { "aname", "avalue" } }) diff --git a/src/SubscriptionsDomain/Events.cs b/src/SubscriptionsDomain/Events.cs index cc210044..f5ce5e5f 100644 --- a/src/SubscriptionsDomain/Events.cs +++ b/src/SubscriptionsDomain/Events.cs @@ -1,6 +1,8 @@ using Domain.Common.ValueObjects; using Domain.Events.Shared.Subscriptions; using Domain.Shared.Subscriptions; +using Created = Domain.Events.Shared.Subscriptions.Created; +using Deleted = Domain.Events.Shared.Subscriptions.Deleted; namespace SubscriptionsDomain; @@ -73,4 +75,9 @@ public static SubscriptionTransferred SubscriptionTransferred(Identifier id, Ide ToBuyerId = transfereeId }; } + + public static Deleted Deleted(Identifier id, Identifier deletedById) + { + return new Deleted(id, deletedById); + } } \ No newline at end of file diff --git a/src/SubscriptionsDomain/Resources.Designer.cs b/src/SubscriptionsDomain/Resources.Designer.cs index f22bb242..070610d7 100644 --- a/src/SubscriptionsDomain/Resources.Designer.cs +++ b/src/SubscriptionsDomain/Resources.Designer.cs @@ -104,6 +104,15 @@ internal static string SubscriptionRoot_ChangePlan_NotClaimable { } } + /// + /// Looks up a localized string similar to Can only delete the subscription for this owning entity. + /// + internal static string SubscriptionRoot_DeleteSubscription_NotOwningEntityId { + get { + return ResourceManager.GetString("SubscriptionRoot_DeleteSubscription_NotOwningEntityId", resourceCulture); + } + } + /// /// Looks up a localized string similar to Subscription must have a valid buyer ID. /// diff --git a/src/SubscriptionsDomain/Resources.resx b/src/SubscriptionsDomain/Resources.resx index 1ef3b482..3b4672e0 100644 --- a/src/SubscriptionsDomain/Resources.resx +++ b/src/SubscriptionsDomain/Resources.resx @@ -72,4 +72,7 @@ Viewing the subscription failed, reason: {0} + + Can only delete the subscription for this owning entity + \ No newline at end of file diff --git a/src/SubscriptionsDomain/SubscriptionRoot.cs b/src/SubscriptionsDomain/SubscriptionRoot.cs index 35f15bbc..27f7c031 100644 --- a/src/SubscriptionsDomain/SubscriptionRoot.cs +++ b/src/SubscriptionsDomain/SubscriptionRoot.cs @@ -298,6 +298,16 @@ public Result ChangeProvider(BillingProvider provid return interpreter.GetSubscriptionDetails(Provider.Value); } + public Result DeleteSubscription(Identifier deleterId, Identifier owningEntityId) + { + if (!IsOwningEntityId(owningEntityId)) + { + return Error.RuleViolation(Resources.SubscriptionRoot_DeleteSubscription_NotOwningEntityId); + } + + return RaisePermanentDeleteEvent(SubscriptionsDomain.Events.Deleted(Id, deleterId)); + } + public Result SetProvider(BillingProvider provider, Identifier modifierId, IBillingStateInterpreter interpreter) { @@ -575,4 +585,9 @@ private static bool IsServiceAccountOrWebhookAccount(Identifier userId) return userId == CallerConstants.MaintenanceAccountUserId || userId == CallerConstants.ExternalWebhookAccountUserId; } + + private bool IsOwningEntityId(Identifier entityId) + { + return OwningEntityId == entityId; + } } \ No newline at end of file diff --git a/src/SubscriptionsInfrastructure/Persistence/ReadModels/SubscriptionProjection.cs b/src/SubscriptionsInfrastructure/Persistence/ReadModels/SubscriptionProjection.cs index 710238bf..338a9d4e 100644 --- a/src/SubscriptionsInfrastructure/Persistence/ReadModels/SubscriptionProjection.cs +++ b/src/SubscriptionsInfrastructure/Persistence/ReadModels/SubscriptionProjection.cs @@ -68,6 +68,9 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven dto => { dto.ProviderState = e.ProviderState.ToJson(casing: StringExtensions.JsonCasing.Pascal); }, cancellationToken); + case Deleted e: + return await _subscriptions.HandleDeleteAsync(e.RootId, cancellationToken); + default: return false; }