From d7c544a116f549a74d019eb9d5e0b0d3a06d9fc7 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Mon, 23 Oct 2023 11:28:13 +0100 Subject: [PATCH 1/4] [AC 1536] Breakdown The SubscriptionUpdate.cs into multiple files (#3356) * Move sub-subscription classes to a separate files * Refactor the sub-class to a separate files * format whitespace * remove directive that is unnecessary * Remove the baseSeat class --- .../Models/Business/SeatSubscriptionUpdate.cs | 49 +++ .../Business/SecretsManagerSubscribeUpdate.cs | 78 +++++ .../ServiceAccountSubscriptionUpdate.cs | 50 +++ .../Business/SmSeatSubscriptionUpdate.cs | 50 +++ .../SponsorOrganizationSubscriptionUpdate.cs | 83 +++++ .../Business/StorageSubscriptionUpdate.cs | 53 +++ .../Models/Business/SubscriptionUpdate.cs | 321 +----------------- ...UpdateSecretsManagerSubscriptionCommand.cs | 2 +- src/Core/Services/IPaymentService.cs | 1 + .../Implementations/StripePaymentService.cs | 5 + ...eSecretsManagerSubscriptionCommandTests.cs | 6 +- 11 files changed, 374 insertions(+), 324 deletions(-) create mode 100644 src/Core/Models/Business/SeatSubscriptionUpdate.cs create mode 100644 src/Core/Models/Business/SecretsManagerSubscribeUpdate.cs create mode 100644 src/Core/Models/Business/ServiceAccountSubscriptionUpdate.cs create mode 100644 src/Core/Models/Business/SmSeatSubscriptionUpdate.cs create mode 100644 src/Core/Models/Business/SponsorOrganizationSubscriptionUpdate.cs create mode 100644 src/Core/Models/Business/StorageSubscriptionUpdate.cs diff --git a/src/Core/Models/Business/SeatSubscriptionUpdate.cs b/src/Core/Models/Business/SeatSubscriptionUpdate.cs new file mode 100644 index 0000000000..8b4c613d61 --- /dev/null +++ b/src/Core/Models/Business/SeatSubscriptionUpdate.cs @@ -0,0 +1,49 @@ +using Bit.Core.Entities; +using Stripe; + +namespace Bit.Core.Models.Business; + +public class SeatSubscriptionUpdate : SubscriptionUpdate +{ + private readonly int _previousSeats; + private readonly StaticStore.Plan _plan; + private readonly long? _additionalSeats; + protected override List PlanIds => new() { _plan.PasswordManager.StripeSeatPlanId }; + public SeatSubscriptionUpdate(Organization organization, StaticStore.Plan plan, long? additionalSeats) + { + _plan = plan; + _additionalSeats = additionalSeats; + _previousSeats = organization.Seats.GetValueOrDefault(); + } + + public override List UpgradeItemsOptions(Subscription subscription) + { + var item = SubscriptionItem(subscription, PlanIds.Single()); + return new() + { + new SubscriptionItemOptions + { + Id = item?.Id, + Plan = PlanIds.Single(), + Quantity = _additionalSeats, + Deleted = (item?.Id != null && _additionalSeats == 0) ? true : (bool?)null, + } + }; + } + + public override List RevertItemsOptions(Subscription subscription) + { + + var item = SubscriptionItem(subscription, PlanIds.Single()); + return new() + { + new SubscriptionItemOptions + { + Id = item?.Id, + Plan = PlanIds.Single(), + Quantity = _previousSeats, + Deleted = _previousSeats == 0 ? true : (bool?)null, + } + }; + } +} diff --git a/src/Core/Models/Business/SecretsManagerSubscribeUpdate.cs b/src/Core/Models/Business/SecretsManagerSubscribeUpdate.cs new file mode 100644 index 0000000000..8f3fb89349 --- /dev/null +++ b/src/Core/Models/Business/SecretsManagerSubscribeUpdate.cs @@ -0,0 +1,78 @@ +using Bit.Core.Entities; +using Stripe; + +namespace Bit.Core.Models.Business; + +public class SecretsManagerSubscribeUpdate : SubscriptionUpdate +{ + private readonly StaticStore.Plan _plan; + private readonly long? _additionalSeats; + private readonly long? _additionalServiceAccounts; + private readonly int _previousSeats; + private readonly int _previousServiceAccounts; + protected override List PlanIds => new() { _plan.SecretsManager.StripeSeatPlanId, _plan.SecretsManager.StripeServiceAccountPlanId }; + public SecretsManagerSubscribeUpdate(Organization organization, StaticStore.Plan plan, long? additionalSeats, long? additionalServiceAccounts) + { + _plan = plan; + _additionalSeats = additionalSeats; + _additionalServiceAccounts = additionalServiceAccounts; + _previousSeats = organization.SmSeats.GetValueOrDefault(); + _previousServiceAccounts = organization.SmServiceAccounts.GetValueOrDefault(); + } + + public override List RevertItemsOptions(Subscription subscription) + { + var updatedItems = new List(); + + RemovePreviousSecretsManagerItems(updatedItems); + + return updatedItems; + } + + public override List UpgradeItemsOptions(Subscription subscription) + { + var updatedItems = new List(); + + AddNewSecretsManagerItems(updatedItems); + + return updatedItems; + } + + private void AddNewSecretsManagerItems(List updatedItems) + { + if (_additionalSeats > 0) + { + updatedItems.Add(new SubscriptionItemOptions + { + Price = _plan.SecretsManager.StripeSeatPlanId, + Quantity = _additionalSeats + }); + } + + if (_additionalServiceAccounts > 0) + { + updatedItems.Add(new SubscriptionItemOptions + { + Price = _plan.SecretsManager.StripeServiceAccountPlanId, + Quantity = _additionalServiceAccounts + }); + } + } + + private void RemovePreviousSecretsManagerItems(List updatedItems) + { + updatedItems.Add(new SubscriptionItemOptions + { + Price = _plan.SecretsManager.StripeSeatPlanId, + Quantity = _previousSeats, + Deleted = _previousSeats == 0 ? true : (bool?)null, + }); + + updatedItems.Add(new SubscriptionItemOptions + { + Price = _plan.SecretsManager.StripeServiceAccountPlanId, + Quantity = _previousServiceAccounts, + Deleted = _previousServiceAccounts == 0 ? true : (bool?)null, + }); + } +} diff --git a/src/Core/Models/Business/ServiceAccountSubscriptionUpdate.cs b/src/Core/Models/Business/ServiceAccountSubscriptionUpdate.cs new file mode 100644 index 0000000000..b49c9cd6c5 --- /dev/null +++ b/src/Core/Models/Business/ServiceAccountSubscriptionUpdate.cs @@ -0,0 +1,50 @@ +using Bit.Core.Entities; +using Stripe; + +namespace Bit.Core.Models.Business; + +public class ServiceAccountSubscriptionUpdate : SubscriptionUpdate +{ + private long? _prevServiceAccounts; + private readonly StaticStore.Plan _plan; + private readonly long? _additionalServiceAccounts; + protected override List PlanIds => new() { _plan.SecretsManager.StripeServiceAccountPlanId }; + + public ServiceAccountSubscriptionUpdate(Organization organization, StaticStore.Plan plan, long? additionalServiceAccounts) + { + _plan = plan; + _additionalServiceAccounts = additionalServiceAccounts; + _prevServiceAccounts = organization.SmServiceAccounts ?? 0; + } + + public override List UpgradeItemsOptions(Subscription subscription) + { + var item = SubscriptionItem(subscription, PlanIds.Single()); + _prevServiceAccounts = item?.Quantity ?? 0; + return new() + { + new SubscriptionItemOptions + { + Id = item?.Id, + Plan = PlanIds.Single(), + Quantity = _additionalServiceAccounts, + Deleted = (item?.Id != null && _additionalServiceAccounts == 0) ? true : (bool?)null, + } + }; + } + + public override List RevertItemsOptions(Subscription subscription) + { + var item = SubscriptionItem(subscription, PlanIds.Single()); + return new() + { + new SubscriptionItemOptions + { + Id = item?.Id, + Plan = PlanIds.Single(), + Quantity = _prevServiceAccounts, + Deleted = _prevServiceAccounts == 0 ? true : (bool?)null, + } + }; + } +} diff --git a/src/Core/Models/Business/SmSeatSubscriptionUpdate.cs b/src/Core/Models/Business/SmSeatSubscriptionUpdate.cs new file mode 100644 index 0000000000..ddc126a261 --- /dev/null +++ b/src/Core/Models/Business/SmSeatSubscriptionUpdate.cs @@ -0,0 +1,50 @@ +using Bit.Core.Entities; +using Stripe; + +namespace Bit.Core.Models.Business; + +public class SmSeatSubscriptionUpdate : SubscriptionUpdate +{ + private readonly int _previousSeats; + private readonly StaticStore.Plan _plan; + private readonly long? _additionalSeats; + protected override List PlanIds => new() { _plan.SecretsManager.StripeSeatPlanId }; + + public SmSeatSubscriptionUpdate(Organization organization, StaticStore.Plan plan, long? additionalSeats) + { + _plan = plan; + _additionalSeats = additionalSeats; + _previousSeats = organization.SmSeats.GetValueOrDefault(); + } + + public override List UpgradeItemsOptions(Subscription subscription) + { + var item = SubscriptionItem(subscription, PlanIds.Single()); + return new() + { + new SubscriptionItemOptions + { + Id = item?.Id, + Plan = PlanIds.Single(), + Quantity = _additionalSeats, + Deleted = (item?.Id != null && _additionalSeats == 0) ? true : (bool?)null, + } + }; + } + + public override List RevertItemsOptions(Subscription subscription) + { + + var item = SubscriptionItem(subscription, PlanIds.Single()); + return new() + { + new SubscriptionItemOptions + { + Id = item?.Id, + Plan = PlanIds.Single(), + Quantity = _previousSeats, + Deleted = _previousSeats == 0 ? true : (bool?)null, + } + }; + } +} diff --git a/src/Core/Models/Business/SponsorOrganizationSubscriptionUpdate.cs b/src/Core/Models/Business/SponsorOrganizationSubscriptionUpdate.cs new file mode 100644 index 0000000000..88af72f199 --- /dev/null +++ b/src/Core/Models/Business/SponsorOrganizationSubscriptionUpdate.cs @@ -0,0 +1,83 @@ +using Stripe; + +namespace Bit.Core.Models.Business; + +public class SponsorOrganizationSubscriptionUpdate : SubscriptionUpdate +{ + private readonly string _existingPlanStripeId; + private readonly string _sponsoredPlanStripeId; + private readonly bool _applySponsorship; + protected override List PlanIds => new() { _existingPlanStripeId, _sponsoredPlanStripeId }; + + public SponsorOrganizationSubscriptionUpdate(StaticStore.Plan existingPlan, StaticStore.SponsoredPlan sponsoredPlan, bool applySponsorship) + { + _existingPlanStripeId = existingPlan.PasswordManager.StripePlanId; + _sponsoredPlanStripeId = sponsoredPlan?.StripePlanId; + _applySponsorship = applySponsorship; + } + + public override List RevertItemsOptions(Subscription subscription) + { + var result = new List(); + if (!string.IsNullOrWhiteSpace(AddStripePlanId)) + { + result.Add(new SubscriptionItemOptions + { + Id = AddStripeItem(subscription)?.Id, + Plan = AddStripePlanId, + Quantity = 0, + Deleted = true, + }); + } + + if (!string.IsNullOrWhiteSpace(RemoveStripePlanId)) + { + result.Add(new SubscriptionItemOptions + { + Id = RemoveStripeItem(subscription)?.Id, + Plan = RemoveStripePlanId, + Quantity = 1, + Deleted = false, + }); + } + return result; + } + + public override List UpgradeItemsOptions(Subscription subscription) + { + var result = new List(); + if (RemoveStripeItem(subscription) != null) + { + result.Add(new SubscriptionItemOptions + { + Id = RemoveStripeItem(subscription)?.Id, + Plan = RemoveStripePlanId, + Quantity = 0, + Deleted = true, + }); + } + + if (!string.IsNullOrWhiteSpace(AddStripePlanId)) + { + result.Add(new SubscriptionItemOptions + { + Id = AddStripeItem(subscription)?.Id, + Plan = AddStripePlanId, + Quantity = 1, + Deleted = false, + }); + } + return result; + } + + private string RemoveStripePlanId => _applySponsorship ? _existingPlanStripeId : _sponsoredPlanStripeId; + private string AddStripePlanId => _applySponsorship ? _sponsoredPlanStripeId : _existingPlanStripeId; + private Stripe.SubscriptionItem RemoveStripeItem(Subscription subscription) => + _applySponsorship ? + SubscriptionItem(subscription, _existingPlanStripeId) : + SubscriptionItem(subscription, _sponsoredPlanStripeId); + private Stripe.SubscriptionItem AddStripeItem(Subscription subscription) => + _applySponsorship ? + SubscriptionItem(subscription, _sponsoredPlanStripeId) : + SubscriptionItem(subscription, _existingPlanStripeId); +} diff --git a/src/Core/Models/Business/StorageSubscriptionUpdate.cs b/src/Core/Models/Business/StorageSubscriptionUpdate.cs new file mode 100644 index 0000000000..30ab2428e2 --- /dev/null +++ b/src/Core/Models/Business/StorageSubscriptionUpdate.cs @@ -0,0 +1,53 @@ +using Stripe; + +namespace Bit.Core.Models.Business; + +public class StorageSubscriptionUpdate : SubscriptionUpdate +{ + private long? _prevStorage; + private readonly string _plan; + private readonly long? _additionalStorage; + protected override List PlanIds => new() { _plan }; + + public StorageSubscriptionUpdate(string plan, long? additionalStorage) + { + _plan = plan; + _additionalStorage = additionalStorage; + } + + public override List UpgradeItemsOptions(Subscription subscription) + { + var item = SubscriptionItem(subscription, PlanIds.Single()); + _prevStorage = item?.Quantity ?? 0; + return new() + { + new SubscriptionItemOptions + { + Id = item?.Id, + Plan = _plan, + Quantity = _additionalStorage, + Deleted = (item?.Id != null && _additionalStorage == 0) ? true : (bool?)null, + } + }; + } + + public override List RevertItemsOptions(Subscription subscription) + { + if (!_prevStorage.HasValue) + { + throw new Exception("Unknown previous value, must first call UpgradeItemsOptions"); + } + + var item = SubscriptionItem(subscription, PlanIds.Single()); + return new() + { + new SubscriptionItemOptions + { + Id = item?.Id, + Plan = _plan, + Quantity = _prevStorage.Value, + Deleted = _prevStorage.Value == 0 ? true : (bool?)null, + } + }; + } +} diff --git a/src/Core/Models/Business/SubscriptionUpdate.cs b/src/Core/Models/Business/SubscriptionUpdate.cs index 0dcf696dbc..497a455d6c 100644 --- a/src/Core/Models/Business/SubscriptionUpdate.cs +++ b/src/Core/Models/Business/SubscriptionUpdate.cs @@ -1,5 +1,4 @@ -using Bit.Core.Entities; -using Stripe; +using Stripe; namespace Bit.Core.Models.Business; @@ -28,321 +27,3 @@ public abstract class SubscriptionUpdate protected static SubscriptionItem SubscriptionItem(Subscription subscription, string planId) => planId == null ? null : subscription.Items?.Data?.FirstOrDefault(i => i.Plan.Id == planId); } - -public abstract class BaseSeatSubscriptionUpdate : SubscriptionUpdate -{ - private readonly int _previousSeats; - protected readonly StaticStore.Plan Plan; - private readonly long? _additionalSeats; - - protected BaseSeatSubscriptionUpdate(Organization organization, StaticStore.Plan plan, long? additionalSeats, int previousSeats) - { - Plan = plan; - _additionalSeats = additionalSeats; - _previousSeats = previousSeats; - } - - protected abstract string GetPlanId(); - - protected override List PlanIds => new() { GetPlanId() }; - - public override List UpgradeItemsOptions(Subscription subscription) - { - var item = SubscriptionItem(subscription, PlanIds.Single()); - return new() - { - new SubscriptionItemOptions - { - Id = item?.Id, - Plan = PlanIds.Single(), - Quantity = _additionalSeats, - Deleted = (item?.Id != null && _additionalSeats == 0) ? true : (bool?)null, - } - }; - } - - public override List RevertItemsOptions(Subscription subscription) - { - - var item = SubscriptionItem(subscription, PlanIds.Single()); - return new() - { - new SubscriptionItemOptions - { - Id = item?.Id, - Plan = PlanIds.Single(), - Quantity = _previousSeats, - Deleted = _previousSeats == 0 ? true : (bool?)null, - } - }; - } -} - -public class SeatSubscriptionUpdate : BaseSeatSubscriptionUpdate -{ - public SeatSubscriptionUpdate(Organization organization, StaticStore.Plan plan, long? additionalSeats) - : base(organization, plan, additionalSeats, organization.Seats.GetValueOrDefault()) - { } - - protected override string GetPlanId() => Plan.PasswordManager.StripeSeatPlanId; -} - -public class SmSeatSubscriptionUpdate : BaseSeatSubscriptionUpdate -{ - public SmSeatSubscriptionUpdate(Organization organization, StaticStore.Plan plan, long? additionalSeats) - : base(organization, plan, additionalSeats, organization.SmSeats.GetValueOrDefault()) - { } - - protected override string GetPlanId() => Plan.SecretsManager.StripeSeatPlanId; -} - -public class ServiceAccountSubscriptionUpdate : SubscriptionUpdate -{ - private long? _prevServiceAccounts; - private readonly StaticStore.Plan _plan; - private readonly long? _additionalServiceAccounts; - protected override List PlanIds => new() { _plan.SecretsManager.StripeServiceAccountPlanId }; - - public ServiceAccountSubscriptionUpdate(Organization organization, StaticStore.Plan plan, long? additionalServiceAccounts) - { - _plan = plan; - _additionalServiceAccounts = additionalServiceAccounts; - _prevServiceAccounts = organization.SmServiceAccounts ?? 0; - } - - public override List UpgradeItemsOptions(Subscription subscription) - { - var item = SubscriptionItem(subscription, PlanIds.Single()); - _prevServiceAccounts = item?.Quantity ?? 0; - return new() - { - new SubscriptionItemOptions - { - Id = item?.Id, - Plan = PlanIds.Single(), - Quantity = _additionalServiceAccounts, - Deleted = (item?.Id != null && _additionalServiceAccounts == 0) ? true : (bool?)null, - } - }; - } - - public override List RevertItemsOptions(Subscription subscription) - { - var item = SubscriptionItem(subscription, PlanIds.Single()); - return new() - { - new SubscriptionItemOptions - { - Id = item?.Id, - Plan = PlanIds.Single(), - Quantity = _prevServiceAccounts, - Deleted = _prevServiceAccounts == 0 ? true : (bool?)null, - } - }; - } -} - -public class StorageSubscriptionUpdate : SubscriptionUpdate -{ - private long? _prevStorage; - private readonly string _plan; - private readonly long? _additionalStorage; - protected override List PlanIds => new() { _plan }; - - public StorageSubscriptionUpdate(string plan, long? additionalStorage) - { - _plan = plan; - _additionalStorage = additionalStorage; - } - - public override List UpgradeItemsOptions(Subscription subscription) - { - var item = SubscriptionItem(subscription, PlanIds.Single()); - _prevStorage = item?.Quantity ?? 0; - return new() - { - new SubscriptionItemOptions - { - Id = item?.Id, - Plan = _plan, - Quantity = _additionalStorage, - Deleted = (item?.Id != null && _additionalStorage == 0) ? true : (bool?)null, - } - }; - } - - public override List RevertItemsOptions(Subscription subscription) - { - if (!_prevStorage.HasValue) - { - throw new Exception("Unknown previous value, must first call UpgradeItemsOptions"); - } - - var item = SubscriptionItem(subscription, PlanIds.Single()); - return new() - { - new SubscriptionItemOptions - { - Id = item?.Id, - Plan = _plan, - Quantity = _prevStorage.Value, - Deleted = _prevStorage.Value == 0 ? true : (bool?)null, - } - }; - } -} - -public class SponsorOrganizationSubscriptionUpdate : SubscriptionUpdate -{ - private readonly string _existingPlanStripeId; - private readonly string _sponsoredPlanStripeId; - private readonly bool _applySponsorship; - protected override List PlanIds => new() { _existingPlanStripeId, _sponsoredPlanStripeId }; - - public SponsorOrganizationSubscriptionUpdate(StaticStore.Plan existingPlan, StaticStore.SponsoredPlan sponsoredPlan, bool applySponsorship) - { - _existingPlanStripeId = existingPlan.PasswordManager.StripePlanId; - _sponsoredPlanStripeId = sponsoredPlan?.StripePlanId; - _applySponsorship = applySponsorship; - } - - public override List RevertItemsOptions(Subscription subscription) - { - var result = new List(); - if (!string.IsNullOrWhiteSpace(AddStripePlanId)) - { - result.Add(new SubscriptionItemOptions - { - Id = AddStripeItem(subscription)?.Id, - Plan = AddStripePlanId, - Quantity = 0, - Deleted = true, - }); - } - - if (!string.IsNullOrWhiteSpace(RemoveStripePlanId)) - { - result.Add(new SubscriptionItemOptions - { - Id = RemoveStripeItem(subscription)?.Id, - Plan = RemoveStripePlanId, - Quantity = 1, - Deleted = false, - }); - } - return result; - } - - public override List UpgradeItemsOptions(Subscription subscription) - { - var result = new List(); - if (RemoveStripeItem(subscription) != null) - { - result.Add(new SubscriptionItemOptions - { - Id = RemoveStripeItem(subscription)?.Id, - Plan = RemoveStripePlanId, - Quantity = 0, - Deleted = true, - }); - } - - if (!string.IsNullOrWhiteSpace(AddStripePlanId)) - { - result.Add(new SubscriptionItemOptions - { - Id = AddStripeItem(subscription)?.Id, - Plan = AddStripePlanId, - Quantity = 1, - Deleted = false, - }); - } - return result; - } - - private string RemoveStripePlanId => _applySponsorship ? _existingPlanStripeId : _sponsoredPlanStripeId; - private string AddStripePlanId => _applySponsorship ? _sponsoredPlanStripeId : _existingPlanStripeId; - private Stripe.SubscriptionItem RemoveStripeItem(Subscription subscription) => - _applySponsorship ? - SubscriptionItem(subscription, _existingPlanStripeId) : - SubscriptionItem(subscription, _sponsoredPlanStripeId); - private Stripe.SubscriptionItem AddStripeItem(Subscription subscription) => - _applySponsorship ? - SubscriptionItem(subscription, _sponsoredPlanStripeId) : - SubscriptionItem(subscription, _existingPlanStripeId); - -} - -public class SecretsManagerSubscribeUpdate : SubscriptionUpdate -{ - private readonly StaticStore.Plan _plan; - private readonly long? _additionalSeats; - private readonly long? _additionalServiceAccounts; - private readonly int _previousSeats; - private readonly int _previousServiceAccounts; - protected override List PlanIds => new() { _plan.SecretsManager.StripeSeatPlanId, _plan.SecretsManager.StripeServiceAccountPlanId }; - public SecretsManagerSubscribeUpdate(Organization organization, StaticStore.Plan plan, long? additionalSeats, long? additionalServiceAccounts) - { - _plan = plan; - _additionalSeats = additionalSeats; - _additionalServiceAccounts = additionalServiceAccounts; - _previousSeats = organization.SmSeats.GetValueOrDefault(); - _previousServiceAccounts = organization.SmServiceAccounts.GetValueOrDefault(); - } - - public override List RevertItemsOptions(Subscription subscription) - { - var updatedItems = new List(); - - RemovePreviousSecretsManagerItems(updatedItems); - - return updatedItems; - } - - public override List UpgradeItemsOptions(Subscription subscription) - { - var updatedItems = new List(); - - AddNewSecretsManagerItems(updatedItems); - - return updatedItems; - } - - private void AddNewSecretsManagerItems(List updatedItems) - { - if (_additionalSeats > 0) - { - updatedItems.Add(new SubscriptionItemOptions - { - Price = _plan.SecretsManager.StripeSeatPlanId, - Quantity = _additionalSeats - }); - } - - if (_additionalServiceAccounts > 0) - { - updatedItems.Add(new SubscriptionItemOptions - { - Price = _plan.SecretsManager.StripeServiceAccountPlanId, - Quantity = _additionalServiceAccounts - }); - } - } - - private void RemovePreviousSecretsManagerItems(List updatedItems) - { - updatedItems.Add(new SubscriptionItemOptions - { - Price = _plan.SecretsManager.StripeSeatPlanId, - Quantity = _previousSeats, - Deleted = _previousSeats == 0 ? true : (bool?)null, - }); - - updatedItems.Add(new SubscriptionItemOptions - { - Price = _plan.SecretsManager.StripeServiceAccountPlanId, - Quantity = _previousServiceAccounts, - Deleted = _previousServiceAccounts == 0 ? true : (bool?)null, - }); - } -} diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs index aeaac974d3..30ccbe5789 100644 --- a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs @@ -66,7 +66,7 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs { if (update.SmSeatsChanged) { - await _paymentService.AdjustSeatsAsync(update.Organization, update.Plan, update.SmSeatsExcludingBase, update.ProrationDate); + await _paymentService.AdjustSmSeatsAsync(update.Organization, update.Plan, update.SmSeatsExcludingBase, update.ProrationDate); // TODO: call ReferenceEventService - see AC-1481 } diff --git a/src/Core/Services/IPaymentService.cs b/src/Core/Services/IPaymentService.cs index 03c2e93dd2..82386a1cf2 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Services/IPaymentService.cs @@ -18,6 +18,7 @@ public interface IPaymentService Task PurchasePremiumAsync(User user, PaymentMethodType paymentMethodType, string paymentToken, short additionalStorageGb, TaxInfo taxInfo); Task AdjustSeatsAsync(Organization organization, Plan plan, int additionalSeats, DateTime? prorationDate = null); + Task AdjustSmSeatsAsync(Organization organization, Plan plan, int additionalSeats, DateTime? prorationDate = null); Task AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage, string storagePlanId, DateTime? prorationDate = null); Task AdjustServiceAccountsAsync(Organization organization, Plan plan, int additionalServiceAccounts, diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index e1ac1ef775..dc6d4dd6f0 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -863,6 +863,11 @@ public class StripePaymentService : IPaymentService return FinalizeSubscriptionChangeAsync(organization, new SeatSubscriptionUpdate(organization, plan, additionalSeats), prorationDate); } + public Task AdjustSmSeatsAsync(Organization organization, StaticStore.Plan plan, int additionalSeats, DateTime? prorationDate = null) + { + return FinalizeSubscriptionChangeAsync(organization, new SmSeatSubscriptionUpdate(organization, plan, additionalSeats), prorationDate); + } + public Task AdjustServiceAccountsAsync(Organization organization, StaticStore.Plan plan, int additionalServiceAccounts, DateTime? prorationDate = null) { return FinalizeSubscriptionChangeAsync(organization, new ServiceAccountSubscriptionUpdate(organization, plan, additionalServiceAccounts), prorationDate); diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs index 5a689f39bc..16508bd25a 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs @@ -54,7 +54,7 @@ public class UpdateSecretsManagerSubscriptionCommandTests var plan = StaticStore.GetPlan(organization.PlanType); await sutProvider.GetDependency().Received(1) - .AdjustSeatsAsync(organization, plan, update.SmSeatsExcludingBase); + .AdjustSmSeatsAsync(organization, plan, update.SmSeatsExcludingBase); await sutProvider.GetDependency().Received(1) .AdjustServiceAccountsAsync(organization, plan, update.SmServiceAccountsExcludingBase); @@ -98,7 +98,7 @@ public class UpdateSecretsManagerSubscriptionCommandTests var plan = StaticStore.GetPlan(organization.PlanType); await sutProvider.GetDependency().Received(1) - .AdjustSeatsAsync(organization, plan, update.SmSeatsExcludingBase); + .AdjustSmSeatsAsync(organization, plan, update.SmSeatsExcludingBase); await sutProvider.GetDependency().Received(1) .AdjustServiceAccountsAsync(organization, plan, update.SmServiceAccountsExcludingBase); @@ -599,7 +599,7 @@ public class UpdateSecretsManagerSubscriptionCommandTests private static async Task VerifyDependencyNotCalledAsync(SutProvider sutProvider) { await sutProvider.GetDependency().DidNotReceive() - .AdjustSeatsAsync(Arg.Any(), Arg.Any(), Arg.Any()); + .AdjustSmSeatsAsync(Arg.Any(), Arg.Any(), Arg.Any()); await sutProvider.GetDependency().DidNotReceive() .AdjustServiceAccountsAsync(Arg.Any(), Arg.Any(), Arg.Any()); // TODO: call ReferenceEventService - see AC-1481 From 19e2215376c318545c0836cac6e2055217837553 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Mon, 23 Oct 2023 10:02:02 -0400 Subject: [PATCH 2/4] Added percent off to discount, removed discount from user sub (#3326) --- .../Response/Organizations/OrganizationResponseModel.cs | 4 ++-- src/Api/Models/Response/SubscriptionResponseModel.cs | 8 ++++---- src/Core/Models/Business/SubscriptionInfo.cs | 4 +++- src/Core/Services/Implementations/StripePaymentService.cs | 2 +- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs index 1d83f79e1b..c4e848b35d 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs @@ -112,7 +112,7 @@ public class OrganizationSubscriptionResponseModel : OrganizationResponseModel { Subscription = subscription.Subscription != null ? new BillingSubscription(subscription.Subscription) : null; UpcomingInvoice = subscription.UpcomingInvoice != null ? new BillingSubscriptionUpcomingInvoice(subscription.UpcomingInvoice) : null; - Discount = subscription.Discount != null ? new BillingCustomerDiscount(subscription.Discount) : null; + CustomerDiscount = subscription.CustomerDiscount != null ? new BillingCustomerDiscount(subscription.CustomerDiscount) : null; Expiration = DateTime.UtcNow.AddYears(1); // Not used, so just give it a value. if (hideSensitiveData) @@ -143,7 +143,7 @@ public class OrganizationSubscriptionResponseModel : OrganizationResponseModel public string StorageName { get; set; } public double? StorageGb { get; set; } - public BillingCustomerDiscount Discount { get; set; } + public BillingCustomerDiscount CustomerDiscount { get; set; } public BillingSubscription Subscription { get; set; } public BillingSubscriptionUpcomingInvoice UpcomingInvoice { get; set; } diff --git a/src/Api/Models/Response/SubscriptionResponseModel.cs b/src/Api/Models/Response/SubscriptionResponseModel.cs index 553b7dd99a..883f7ac900 100644 --- a/src/Api/Models/Response/SubscriptionResponseModel.cs +++ b/src/Api/Models/Response/SubscriptionResponseModel.cs @@ -13,7 +13,6 @@ public class SubscriptionResponseModel : ResponseModel Subscription = subscription.Subscription != null ? new BillingSubscription(subscription.Subscription) : null; UpcomingInvoice = subscription.UpcomingInvoice != null ? new BillingSubscriptionUpcomingInvoice(subscription.UpcomingInvoice) : null; - Discount = subscription.Discount != null ? new BillingCustomerDiscount(subscription.Discount) : null; StorageName = user.Storage.HasValue ? CoreHelpers.ReadableBytesSize(user.Storage.Value) : null; StorageGb = user.Storage.HasValue ? Math.Round(user.Storage.Value / 1073741824D, 2) : 0; // 1 GB MaxStorageGb = user.MaxStorageGb; @@ -41,7 +40,6 @@ public class SubscriptionResponseModel : ResponseModel public short? MaxStorageGb { get; set; } public BillingSubscriptionUpcomingInvoice UpcomingInvoice { get; set; } public BillingSubscription Subscription { get; set; } - public BillingCustomerDiscount Discount { get; set; } public UserLicense License { get; set; } public DateTime? Expiration { get; set; } public bool UsingInAppPurchase { get; set; } @@ -53,10 +51,12 @@ public class BillingCustomerDiscount { Id = discount.Id; Active = discount.Active; + PercentOff = discount.PercentOff; } - public string Id { get; set; } - public bool Active { get; set; } + public string Id { get; } + public bool Active { get; } + public decimal? PercentOff { get; } } public class BillingSubscription diff --git a/src/Core/Models/Business/SubscriptionInfo.cs b/src/Core/Models/Business/SubscriptionInfo.cs index f8284e0e30..8a0a6add74 100644 --- a/src/Core/Models/Business/SubscriptionInfo.cs +++ b/src/Core/Models/Business/SubscriptionInfo.cs @@ -4,7 +4,7 @@ namespace Bit.Core.Models.Business; public class SubscriptionInfo { - public BillingCustomerDiscount Discount { get; set; } + public BillingCustomerDiscount CustomerDiscount { get; set; } public BillingSubscription Subscription { get; set; } public BillingUpcomingInvoice UpcomingInvoice { get; set; } public bool UsingInAppPurchase { get; set; } @@ -17,10 +17,12 @@ public class SubscriptionInfo { Id = discount.Id; Active = discount.Start != null && discount.End == null; + PercentOff = discount.Coupon?.PercentOff; } public string Id { get; } public bool Active { get; } + public decimal? PercentOff { get; } } public class BillingSubscription diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index dc6d4dd6f0..0f7965db77 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -1568,7 +1568,7 @@ public class StripePaymentService : IPaymentService if (customer.Discount != null) { - subscriptionInfo.Discount = new SubscriptionInfo.BillingCustomerDiscount(customer.Discount); + subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(customer.Discount); } if (subscriber.IsUser()) From 18b43130e85af54a5373f07356ae4bd3999adaa3 Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Mon, 23 Oct 2023 16:56:04 +0200 Subject: [PATCH 3/4] [PM-4252] Change attachment Size to be represented as a string (#3335) --- .../Vault/Models/Response/AttachmentResponseModel.cs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/Api/Vault/Models/Response/AttachmentResponseModel.cs b/src/Api/Vault/Models/Response/AttachmentResponseModel.cs index c7e3caabb9..f3c0261e98 100644 --- a/src/Api/Vault/Models/Response/AttachmentResponseModel.cs +++ b/src/Api/Vault/Models/Response/AttachmentResponseModel.cs @@ -1,5 +1,4 @@ -using System.Text.Json.Serialization; -using Bit.Core.Models.Api; +using Bit.Core.Models.Api; using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.Core.Vault.Entities; @@ -15,7 +14,7 @@ public class AttachmentResponseModel : ResponseModel Url = data.Url; FileName = data.Data.FileName; Key = data.Data.Key; - Size = data.Data.Size; + Size = data.Data.Size.ToString(); SizeName = CoreHelpers.ReadableBytesSize(data.Data.Size); } @@ -27,7 +26,7 @@ public class AttachmentResponseModel : ResponseModel Url = $"{globalSettings.Attachment.BaseUrl}/{cipher.Id}/{id}"; FileName = data.FileName; Key = data.Key; - Size = data.Size; + Size = data.Size.ToString(); SizeName = CoreHelpers.ReadableBytesSize(data.Size); } @@ -35,8 +34,7 @@ public class AttachmentResponseModel : ResponseModel public string Url { get; set; } public string FileName { get; set; } public string Key { get; set; } - [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)] - public long Size { get; set; } + public string Size { get; set; } public string SizeName { get; set; } public static IEnumerable FromCipher(Cipher cipher, IGlobalSettings globalSettings) From c442bae2bc56dc2f1c4b7bf48ebc64044086ee26 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Mon, 23 Oct 2023 13:46:29 -0400 Subject: [PATCH 4/4] [AC-1693] Send InvoiceUpcoming Notification to Client Owners (#3319) * Add Organization_ReadOwnerEmailAddresses SPROC * Add IOrganizationRepository.GetOwnerEmailAddressesById * Add SendInvoiceUpcoming overload for multiple emails * Update InvoiceUpcoming handler to send multiple emails * Cy's feedback * Updates from testing Hardened against missing entity IDs in Stripe events in the StripeEventService. Updated ValidateCloudRegion to not use a refresh/expansion for the customer because the invoice.upcoming event does not have an invoice.Id. Updated the StripeController's handling of invoice.upcoming to not use a refresh/expansion for the subscription because the invoice does not have an ID. * Fix broken test --- src/Billing/Controllers/StripeController.cs | 82 ++++++++++++------- .../Implementations/StripeEventService.cs | 49 ++++++++++- .../Repositories/IOrganizationRepository.cs | 1 + src/Core/Services/IMailService.cs | 12 ++- .../Implementations/HandlebarsMailService.cs | 17 +++- .../NoopImplementations/NoopMailService.cs | 18 ++-- .../Repositories/OrganizationRepository.cs | 10 +++ .../Repositories/OrganizationRepository.cs | 20 +++++ .../Services/StripeEventServiceTests.cs | 3 +- ...00_OrganizationReadOwnerEmailAddresses.sql | 17 ++++ 10 files changed, 190 insertions(+), 39 deletions(-) create mode 100644 util/Migrator/DbScripts/2023-10-03_00_OrganizationReadOwnerEmailAddresses.sql diff --git a/src/Billing/Controllers/StripeController.cs b/src/Billing/Controllers/StripeController.cs index fb63e1993e..e71e025dff 100644 --- a/src/Billing/Controllers/StripeController.cs +++ b/src/Billing/Controllers/StripeController.cs @@ -52,6 +52,7 @@ public class StripeController : Controller private readonly ICurrentContext _currentContext; private readonly GlobalSettings _globalSettings; private readonly IStripeEventService _stripeEventService; + private readonly IStripeFacade _stripeFacade; public StripeController( GlobalSettings globalSettings, @@ -70,7 +71,8 @@ public class StripeController : Controller ITaxRateRepository taxRateRepository, IUserRepository userRepository, ICurrentContext currentContext, - IStripeEventService stripeEventService) + IStripeEventService stripeEventService, + IStripeFacade stripeFacade) { _billingSettings = billingSettings?.Value; _hostingEnvironment = hostingEnvironment; @@ -97,6 +99,7 @@ public class StripeController : Controller _currentContext = currentContext; _globalSettings = globalSettings; _stripeEventService = stripeEventService; + _stripeFacade = stripeFacade; } [HttpPost("webhook")] @@ -209,48 +212,71 @@ public class StripeController : Controller else if (parsedEvent.Type.Equals(HandledStripeWebhook.UpcomingInvoice)) { var invoice = await _stripeEventService.GetInvoice(parsedEvent); - var subscriptionService = new SubscriptionService(); - var subscription = await subscriptionService.GetAsync(invoice.SubscriptionId); + + if (string.IsNullOrEmpty(invoice.SubscriptionId)) + { + _logger.LogWarning("Received 'invoice.upcoming' Event with ID '{eventId}' that did not include a Subscription ID", parsedEvent.Id); + return new OkResult(); + } + + var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId); + if (subscription == null) { - throw new Exception("Invoice subscription is null. " + invoice.Id); + throw new Exception( + $"Received null Subscription from Stripe for ID '{invoice.SubscriptionId}' while processing Event with ID '{parsedEvent.Id}'"); } - subscription = await VerifyCorrectTaxRateForCharge(invoice, subscription); + var updatedSubscription = await VerifyCorrectTaxRateForCharge(invoice, subscription); - string email = null; - var ids = GetIdsFromMetaData(subscription.Metadata); - // org - if (ids.Item1.HasValue) + var (organizationId, userId) = GetIdsFromMetaData(updatedSubscription.Metadata); + + var invoiceLineItemDescriptions = invoice.Lines.Select(i => i.Description).ToList(); + + async Task SendEmails(IEnumerable emails) { - // sponsored org - if (IsSponsoredSubscription(subscription)) - { - await _validateSponsorshipCommand.ValidateSponsorshipAsync(ids.Item1.Value); - } + var validEmails = emails.Where(e => !string.IsNullOrEmpty(e)); - var org = await _organizationRepository.GetByIdAsync(ids.Item1.Value); - if (org != null && OrgPlanForInvoiceNotifications(org)) + if (invoice.NextPaymentAttempt.HasValue) { - email = org.BillingEmail; + await _mailService.SendInvoiceUpcoming( + validEmails, + invoice.AmountDue / 100M, + invoice.NextPaymentAttempt.Value, + invoiceLineItemDescriptions, + true); } } - // user - else if (ids.Item2.HasValue) + + if (organizationId.HasValue) { - var user = await _userService.GetUserByIdAsync(ids.Item2.Value); + if (IsSponsoredSubscription(updatedSubscription)) + { + await _validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId.Value); + } + + var organization = await _organizationRepository.GetByIdAsync(organizationId.Value); + + if (organization == null || !OrgPlanForInvoiceNotifications(organization)) + { + return new OkResult(); + } + + await SendEmails(new List { organization.BillingEmail }); + + var ownerEmails = await _organizationRepository.GetOwnerEmailAddressesById(organization.Id); + + await SendEmails(ownerEmails); + } + else if (userId.HasValue) + { + var user = await _userService.GetUserByIdAsync(userId.Value); + if (user.Premium) { - email = user.Email; + await SendEmails(new List { user.Email }); } } - - if (!string.IsNullOrWhiteSpace(email) && invoice.NextPaymentAttempt.HasValue) - { - var items = invoice.Lines.Select(i => i.Description).ToList(); - await _mailService.SendInvoiceUpcomingAsync(email, invoice.AmountDue / 100M, - invoice.NextPaymentAttempt.Value, items, true); - } } else if (parsedEvent.Type.Equals(HandledStripeWebhook.ChargeSucceeded)) { diff --git a/src/Billing/Services/Implementations/StripeEventService.cs b/src/Billing/Services/Implementations/StripeEventService.cs index 076602e3d2..ce7ab311ff 100644 --- a/src/Billing/Services/Implementations/StripeEventService.cs +++ b/src/Billing/Services/Implementations/StripeEventService.cs @@ -7,13 +7,16 @@ namespace Bit.Billing.Services.Implementations; public class StripeEventService : IStripeEventService { private readonly GlobalSettings _globalSettings; + private readonly ILogger _logger; private readonly IStripeFacade _stripeFacade; public StripeEventService( GlobalSettings globalSettings, + ILogger logger, IStripeFacade stripeFacade) { _globalSettings = globalSettings; + _logger = logger; _stripeFacade = stripeFacade; } @@ -26,6 +29,12 @@ public class StripeEventService : IStripeEventService return eventCharge; } + if (string.IsNullOrEmpty(eventCharge.Id)) + { + _logger.LogWarning("Cannot retrieve up-to-date Charge for Event with ID '{eventId}' because no Charge ID was included in the Event.", stripeEvent.Id); + return eventCharge; + } + var charge = await _stripeFacade.GetCharge(eventCharge.Id, new ChargeGetOptions { Expand = expand }); if (charge == null) @@ -46,6 +55,12 @@ public class StripeEventService : IStripeEventService return eventCustomer; } + if (string.IsNullOrEmpty(eventCustomer.Id)) + { + _logger.LogWarning("Cannot retrieve up-to-date Customer for Event with ID '{eventId}' because no Customer ID was included in the Event.", stripeEvent.Id); + return eventCustomer; + } + var customer = await _stripeFacade.GetCustomer(eventCustomer.Id, new CustomerGetOptions { Expand = expand }); if (customer == null) @@ -66,6 +81,12 @@ public class StripeEventService : IStripeEventService return eventInvoice; } + if (string.IsNullOrEmpty(eventInvoice.Id)) + { + _logger.LogWarning("Cannot retrieve up-to-date Invoice for Event with ID '{eventId}' because no Invoice ID was included in the Event.", stripeEvent.Id); + return eventInvoice; + } + var invoice = await _stripeFacade.GetInvoice(eventInvoice.Id, new InvoiceGetOptions { Expand = expand }); if (invoice == null) @@ -86,6 +107,12 @@ public class StripeEventService : IStripeEventService return eventPaymentMethod; } + if (string.IsNullOrEmpty(eventPaymentMethod.Id)) + { + _logger.LogWarning("Cannot retrieve up-to-date Payment Method for Event with ID '{eventId}' because no Payment Method ID was included in the Event.", stripeEvent.Id); + return eventPaymentMethod; + } + var paymentMethod = await _stripeFacade.GetPaymentMethod(eventPaymentMethod.Id, new PaymentMethodGetOptions { Expand = expand }); if (paymentMethod == null) @@ -106,6 +133,12 @@ public class StripeEventService : IStripeEventService return eventSubscription; } + if (string.IsNullOrEmpty(eventSubscription.Id)) + { + _logger.LogWarning("Cannot retrieve up-to-date Subscription for Event with ID '{eventId}' because no Subscription ID was included in the Event.", stripeEvent.Id); + return eventSubscription; + } + var subscription = await _stripeFacade.GetSubscription(eventSubscription.Id, new SubscriptionGetOptions { Expand = expand }); if (subscription == null) @@ -132,7 +165,7 @@ public class StripeEventService : IStripeEventService (await GetCharge(stripeEvent, true, customerExpansion))?.Customer?.Metadata, HandledStripeWebhook.UpcomingInvoice => - (await GetInvoice(stripeEvent, true, customerExpansion))?.Customer?.Metadata, + await GetCustomerMetadataFromUpcomingInvoiceEvent(stripeEvent), HandledStripeWebhook.PaymentSucceeded or HandledStripeWebhook.PaymentFailed or HandledStripeWebhook.InvoiceCreated => (await GetInvoice(stripeEvent, true, customerExpansion))?.Customer?.Metadata, @@ -154,6 +187,20 @@ public class StripeEventService : IStripeEventService var customerRegion = GetCustomerRegion(customerMetadata); return customerRegion == serverRegion; + + /* In Stripe, when we receive an invoice.upcoming event, the event does not include an Invoice ID because + the invoice hasn't been created yet. Therefore, rather than retrieving the fresh Invoice with a 'customer' + expansion, we need to use the Customer ID on the event to retrieve the metadata. */ + async Task> GetCustomerMetadataFromUpcomingInvoiceEvent(Event localStripeEvent) + { + var invoice = await GetInvoice(localStripeEvent); + + var customer = !string.IsNullOrEmpty(invoice.CustomerId) + ? await _stripeFacade.GetCustomer(invoice.CustomerId) + : null; + + return customer?.Metadata; + } } private static T Extract(Event stripeEvent) diff --git a/src/Core/Repositories/IOrganizationRepository.cs b/src/Core/Repositories/IOrganizationRepository.cs index 14126adb0a..4ac518489b 100644 --- a/src/Core/Repositories/IOrganizationRepository.cs +++ b/src/Core/Repositories/IOrganizationRepository.cs @@ -14,4 +14,5 @@ public interface IOrganizationRepository : IRepository Task GetByLicenseKeyAsync(string licenseKey); Task GetSelfHostedOrganizationDetailsById(Guid id); Task> SearchUnassignedToProviderAsync(string name, string ownerEmail, int skip, int take); + Task> GetOwnerEmailAddressesById(Guid organizationId); } diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index 0e5831082f..6350a0e461 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -24,7 +24,17 @@ public interface IMailService Task SendOrganizationConfirmedEmailAsync(string organizationName, string email); Task SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(string organizationName, string email); Task SendPasswordlessSignInAsync(string returnUrl, string token, string email); - Task SendInvoiceUpcomingAsync(string email, decimal amount, DateTime dueDate, List items, + Task SendInvoiceUpcoming( + string email, + decimal amount, + DateTime dueDate, + List items, + bool mentionInvoices); + Task SendInvoiceUpcoming( + IEnumerable email, + decimal amount, + DateTime dueDate, + List items, bool mentionInvoices); Task SendPaymentFailedAsync(string email, decimal amount, bool mentionInvoices); Task SendAddedCreditAsync(string email, decimal amount); diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index 98ff7df07b..24974f7ff0 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -285,10 +285,21 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } - public async Task SendInvoiceUpcomingAsync(string email, decimal amount, DateTime dueDate, - List items, bool mentionInvoices) + public async Task SendInvoiceUpcoming( + string email, + decimal amount, + DateTime dueDate, + List items, + bool mentionInvoices) => await SendInvoiceUpcoming(new List { email }, amount, dueDate, items, mentionInvoices); + + public async Task SendInvoiceUpcoming( + IEnumerable emails, + decimal amount, + DateTime dueDate, + List items, + bool mentionInvoices) { - var message = CreateDefaultMessage("Your Subscription Will Renew Soon", email); + var message = CreateDefaultMessage("Your Subscription Will Renew Soon", emails); var model = new InvoiceUpcomingViewModel { WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index 97d69cfa48..089ae18f18 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -88,11 +88,19 @@ public class NoopMailService : IMailService return Task.FromResult(0); } - public Task SendInvoiceUpcomingAsync(string email, decimal amount, DateTime dueDate, - List items, bool mentionInvoices) - { - return Task.FromResult(0); - } + public Task SendInvoiceUpcoming( + string email, + decimal amount, + DateTime dueDate, + List items, + bool mentionInvoices) => Task.FromResult(0); + + public Task SendInvoiceUpcoming( + IEnumerable emails, + decimal amount, + DateTime dueDate, + List items, + bool mentionInvoices) => Task.FromResult(0); public Task SendPaymentFailedAsync(string email, decimal amount, bool mentionInvoices) { diff --git a/src/Infrastructure.Dapper/Repositories/OrganizationRepository.cs b/src/Infrastructure.Dapper/Repositories/OrganizationRepository.cs index 9d8cad0f9c..9329e23790 100644 --- a/src/Infrastructure.Dapper/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/OrganizationRepository.cs @@ -149,4 +149,14 @@ public class OrganizationRepository : Repository, IOrganizat return results.ToList(); } } + + public async Task> GetOwnerEmailAddressesById(Guid organizationId) + { + await using var connection = new SqlConnection(ConnectionString); + + return await connection.QueryAsync( + $"[{Schema}].[{Table}_ReadOwnerEmailAddressesById]", + new { OrganizationId = organizationId }, + commandType: CommandType.StoredProcedure); + } } diff --git a/src/Infrastructure.EntityFramework/Repositories/OrganizationRepository.cs b/src/Infrastructure.EntityFramework/Repositories/OrganizationRepository.cs index b7ffb9978a..62f4df63e3 100644 --- a/src/Infrastructure.EntityFramework/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/OrganizationRepository.cs @@ -224,4 +224,24 @@ public class OrganizationRepository : Repository> GetOwnerEmailAddressesById(Guid organizationId) + { + using var scope = ServiceScopeFactory.CreateScope(); + + var dbContext = GetDatabaseContext(scope); + + var query = + from u in dbContext.Users + join ou in dbContext.OrganizationUsers on u.Id equals ou.UserId + where + ou.OrganizationId == organizationId && + ou.Type == OrganizationUserType.Owner && + ou.Status == OrganizationUserStatusType.Confirmed + group u by u.Email + into grouped + select grouped.Key; + + return await query.ToListAsync(); + } } diff --git a/test/Billing.Test/Services/StripeEventServiceTests.cs b/test/Billing.Test/Services/StripeEventServiceTests.cs index 5b1642413d..f2fe1c8d19 100644 --- a/test/Billing.Test/Services/StripeEventServiceTests.cs +++ b/test/Billing.Test/Services/StripeEventServiceTests.cs @@ -3,6 +3,7 @@ using Bit.Billing.Services.Implementations; using Bit.Billing.Test.Utilities; using Bit.Core.Settings; using FluentAssertions; +using Microsoft.Extensions.Logging; using NSubstitute; using Stripe; using Xunit; @@ -21,7 +22,7 @@ public class StripeEventServiceTests globalSettings.BaseServiceUri = baseServiceUriSettings; _stripeFacade = Substitute.For(); - _stripeEventService = new StripeEventService(globalSettings, _stripeFacade); + _stripeEventService = new StripeEventService(globalSettings, Substitute.For>(), _stripeFacade); } #region GetCharge diff --git a/util/Migrator/DbScripts/2023-10-03_00_OrganizationReadOwnerEmailAddresses.sql b/util/Migrator/DbScripts/2023-10-03_00_OrganizationReadOwnerEmailAddresses.sql new file mode 100644 index 0000000000..c88b12af03 --- /dev/null +++ b/util/Migrator/DbScripts/2023-10-03_00_OrganizationReadOwnerEmailAddresses.sql @@ -0,0 +1,17 @@ +CREATE OR ALTER PROCEDURE [dbo].[Organization_ReadOwnerEmailAddressesById] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + [U].[Email] + FROM [User] AS [U] + INNER JOIN [OrganizationUser] AS [OU] ON [U].[Id] = [OU].[UserId] + WHERE + [OU].[OrganizationId] = @OrganizationId AND + [OU].[Type] = 0 AND -- Owner + [OU].[Status] = 2 -- Confirmed + GROUP BY [U].[Email] +END +GO