Skip to content
Open
19 changes: 5 additions & 14 deletions src/Api/Billing/Controllers/OrganizationsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
using Bit.Api.Models.Request;
using Bit.Api.Models.Request.Organizations;
using Bit.Api.Models.Response;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Models;
Expand Down Expand Up @@ -50,7 +49,6 @@ public class OrganizationsController(
ISubscriberService subscriberService,
IOrganizationInstallationRepository organizationInstallationRepository,
IPricingClient pricingClient,
IFeatureService featureService,
IReinstateSubscriptionCommand reinstateSubscriptionCommand)
: Controller
{
Expand Down Expand Up @@ -250,20 +248,13 @@ public async Task PostReinstate(Guid id)
throw new NotFoundException();
}

if (featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal))
{
var organization = await organizationRepository.GetByIdAsync(id);
if (organization == null)
{
throw new NotFoundException();
}

(await reinstateSubscriptionCommand.Run(organization)).GetValueOrThrow();
}
else
var organization = await organizationRepository.GetByIdAsync(id);
if (organization == null)
{
await organizationService.ReinstateSubscriptionAsync(id);
throw new NotFoundException();
}

(await reinstateSubscriptionCommand.Run(organization)).GetValueOrThrow();
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,7 @@ public async Task HandleAsync(Event parsedEvent)
break;
case Organization organization:
{
if (_featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal))
{
await HandleScheduleTriggeredFamiliesMigrationAsync(parsedEvent, subscription, organization.Id);
}
await HandleScheduleTriggeredFamiliesMigrationAsync(parsedEvent, subscription, organization.Id);

await HandleScheduleTriggeredBusinessMigrationAsync(parsedEvent, subscription, organization.Id);

Expand Down
201 changes: 65 additions & 136 deletions src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -269,67 +269,14 @@ private async Task<bool> ScheduleFamiliesPriceMigrationAsync(

try
{
if (featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal))
{
var scheduled = await priceIncreaseScheduler.SchedulePersonalPriceIncrease(subscription);
if (!scheduled)
{
return true;
}
}
else
{
organization.PlanType = familiesPlan.Type;
organization.Plan = familiesPlan.Name;
organization.UsersGetPremium = familiesPlan.UsersGetPremium;
organization.Seats = familiesPlan.PasswordManager.BaseSeats;
var scheduled = await priceIncreaseScheduler.SchedulePersonalPriceIncrease(subscription);

var options = new SubscriptionUpdateOptions
{
Items =
[
new SubscriptionItemOptions
{
Id = passwordManagerItem.Id,
Price = familiesPlan.PasswordManager.StripePlanId
}
],
ProrationBehavior = ProrationBehavior.None
};

if (plan.Type == PlanType.FamiliesAnnually2019)
{
options.Discounts =
[
new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone3SubscriptionDiscount }
];

var premiumAccessAddOnItem = subscription.Items.FirstOrDefault(item =>
item.Price.Id == plan.PasswordManager.StripePremiumAccessPlanId);

if (premiumAccessAddOnItem != null)
{
options.Items.Add(new SubscriptionItemOptions
{
Id = premiumAccessAddOnItem.Id,
Deleted = true
});
}

var seatAddOnItem = subscription.Items.FirstOrDefault(item => item.Price.Id == "personal-org-seat-annually");

if (seatAddOnItem != null)
{
options.Items.Add(new SubscriptionItemOptions
{
Id = seatAddOnItem.Id,
Deleted = true
});
}
}

await organizationRepository.ReplaceAsync(organization);
await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, options);
// A false result means no new schedule was created β€” typically because an earlier upcoming-invoice
// event for this renewal already deferred the migration and sent its renewal email. Return true so
// the caller skips the generic upcoming-invoice email instead of sending a duplicate.
if (!scheduled)
{
return true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you mind adding a comment here (and the other locations where you are early-returning true) to explain why we want to just return true here if !scheduled? I know you only removed the flag in this PR but I assume you know why and I realized while reading through this that I don't lol so a comment would be helpful. πŸ˜„

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call - added here: 496e464

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Looks like you have broken integration tests too:

/home/runner/work/server/server/test/SeederApi.IntegrationTest/Pipeline/RecipeOrchestratorIntegrationTests.cs(123,24): error CS7036: There is no argument given that corresponds to the required parameter 'LicensingService' of 'SeederDependencies.SeederDependencies(DatabaseContext, IMapper, IPasswordHasher<User>, IManglerService, ILicensingService)' [/home/runner/work/server/server/test/SeederApi.IntegrationTest/SeederApi.IntegrationTest.csproj]

@kdenney kdenney Jun 29, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which is weird because you didn't edit those files lol. Maybe it just needs reran..I'll try that.

edit: nope, both unit and integration tests are failing. Might be a bad merge got into main.

}

await SendFamiliesRenewalEmailAsync(organization, familiesPlan, plan);
Expand Down Expand Up @@ -752,29 +699,14 @@ private async Task<bool> AlignPremiumUsersSubscriptionConcernsAsync(

try
{
if (featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal))
{
var scheduled = await priceIncreaseScheduler.SchedulePersonalPriceIncrease(subscription);
if (!scheduled)
{
return true;
}
}
else
var scheduled = await priceIncreaseScheduler.SchedulePersonalPriceIncrease(subscription);

// A false result means no new schedule was created β€” typically because an earlier upcoming-invoice
// event for this renewal already deferred the migration and sent its renewal email. Return true so
// the caller skips the generic upcoming-invoice email instead of sending a duplicate.
if (!scheduled)
{
await stripeAdapter.UpdateSubscriptionAsync(subscription.Id,
new SubscriptionUpdateOptions
{
Items =
[
new SubscriptionItemOptions { Id = premiumItem.Id, Price = newPlan.Seat.StripePriceId }
],
Discounts =
[
new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone2SubscriptionDiscount }
],
ProrationBehavior = ProrationBehavior.None
});
return true;
}

await SendPremiumRenewalEmailAsync(user, newPlan);
Expand Down Expand Up @@ -913,73 +845,70 @@ await mailService.SendProviderInvoiceUpcoming(

private async Task EnableAutomaticTaxAsync(Subscription subscription)
{
if (featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal))
{
var schedules = await stripeAdapter.ListSubscriptionSchedulesAsync(
new SubscriptionScheduleListOptions { Customer = subscription.CustomerId });
var schedules = await stripeAdapter.ListSubscriptionSchedulesAsync(
new SubscriptionScheduleListOptions { Customer = subscription.CustomerId });

var activeSchedule = schedules.Data.FirstOrDefault(s =>
s.SubscriptionId == subscription.Id && s.Status == SubscriptionScheduleStatus.Active);
var activeSchedule = schedules.Data.FirstOrDefault(s =>
s.SubscriptionId == subscription.Id && s.Status == SubscriptionScheduleStatus.Active);

if (activeSchedule != null)
if (activeSchedule != null)
{
var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow;
var phases = new List<SubscriptionSchedulePhaseOptions>();

for (var i = 0; i < activeSchedule.Phases.Count; i++)
{
var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow;
var phases = new List<SubscriptionSchedulePhaseOptions>();
var phase = activeSchedule.Phases[i];

for (var i = 0; i < activeSchedule.Phases.Count; i++)
// Skip phases that have already completed
if (phase.EndDate <= now)
{
var phase = activeSchedule.Phases[i];
continue;
}

// Skip phases that have already completed
if (phase.EndDate <= now)
{
continue;
}
// When a phase's predecessor has ended, the phase is already active and
// its one-time migration discount has been applied and consumed.
// Re-including it would cause Stripe to re-apply it.
var discountConsumed = i > 0 && activeSchedule.Phases[i - 1].EndDate <= now;

// When a phase's predecessor has ended, the phase is already active and
// its one-time migration discount has been applied and consumed.
// Re-including it would cause Stripe to re-apply it.
var discountConsumed = i > 0 && activeSchedule.Phases[i - 1].EndDate <= now;
// Gate on StartDate > now, not !discountConsumed (false for the active phase 0),
// so we never re-stack the customer coupon onto the already-billing current period.
var customerDiscount = phase.StartDate > now ? subscription.Customer?.Discount : null;

// Gate on StartDate > now, not !discountConsumed (false for the active phase 0),
// so we never re-stack the customer coupon onto the already-billing current period.
var customerDiscount = phase.StartDate > now ? subscription.Customer?.Discount : null;
phases.Add(new SubscriptionSchedulePhaseOptions
{
StartDate = phase.StartDate,
EndDate = phase.EndDate,
Items = phase.Items.Select(item => new SubscriptionSchedulePhaseItemOptions
{
Price = item.PriceId,
Quantity = item.Quantity
}).ToList(),
Discounts = discountConsumed
? []
: customerDiscount.MergeDiscountCouponIds(
phase.Discounts?.Select(d => d.CouponId)).ToPhaseDiscountOptions(),
ProrationBehavior = phase.ProrationBehavior,
AutomaticTax = new SubscriptionSchedulePhaseAutomaticTaxOptions
{
Enabled = true
}
});
}

phases.Add(new SubscriptionSchedulePhaseOptions
await stripeAdapter.UpdateSubscriptionScheduleAsync(activeSchedule.Id,
new SubscriptionScheduleUpdateOptions
{
DefaultSettings = new SubscriptionScheduleDefaultSettingsOptions
{
StartDate = phase.StartDate,
EndDate = phase.EndDate,
Items = phase.Items.Select(item => new SubscriptionSchedulePhaseItemOptions
{
Price = item.PriceId,
Quantity = item.Quantity
}).ToList(),
Discounts = discountConsumed
? []
: customerDiscount.MergeDiscountCouponIds(
phase.Discounts?.Select(d => d.CouponId)).ToPhaseDiscountOptions(),
ProrationBehavior = phase.ProrationBehavior,
AutomaticTax = new SubscriptionSchedulePhaseAutomaticTaxOptions
AutomaticTax = new SubscriptionScheduleDefaultSettingsAutomaticTaxOptions
{
Enabled = true
}
});
}

await stripeAdapter.UpdateSubscriptionScheduleAsync(activeSchedule.Id,
new SubscriptionScheduleUpdateOptions
{
DefaultSettings = new SubscriptionScheduleDefaultSettingsOptions
{
AutomaticTax = new SubscriptionScheduleDefaultSettingsAutomaticTaxOptions
{
Enabled = true
}
},
Phases = phases
});
return;
}
},
Phases = phases
});
return;
}

await stripeAdapter.UpdateSubscriptionAsync(subscription.Id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,13 @@
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;

public class DeleteClaimedOrganizationUserAccountCommand(
IUserService userService,
IEventService eventService,
IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery,
IOrganizationUserRepository organizationUserRepository,
IUserRepository userRepository,
IPushNotificationService pushService,
ILogger<DeleteClaimedOrganizationUserAccountCommand> logger,
IDeleteClaimedOrganizationUserAccountValidator deleteClaimedOrganizationUserAccountValidator,
IFeatureService featureService,
ISubscriberService subscriberService)
: IDeleteClaimedOrganizationUserAccountCommand
{
Expand Down Expand Up @@ -132,18 +130,11 @@ private async Task CancelPremiumsAsync(IEnumerable<DeleteUserValidationRequest>
{
try
{
if (featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal))
{
// In cases where the subscription is not active, the cancellation will fail and be logged.
await subscriberService.CancelSubscription(
user,
cancelImmediately: false,
offboardingSurveyResponse: new OffboardingSurveyResponse { UserId = user.Id });
}
else
{
await userService.CancelPremiumAsync(user);
}
// In cases where the subscription is not active, the cancellation will fail and be logged.
await subscriberService.CancelSubscription(
user,
cancelImmediately: false,
offboardingSurveyResponse: new OffboardingSurveyResponse { UserId = user.Id });
}
catch (Exception exception) when (exception is GatewayException or BillingException)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
using Bit.Core.Billing.Services;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Tools.Services;
using Bit.Core.Vault.Services;
using Microsoft.Extensions.Logging;
Expand All @@ -18,32 +17,26 @@ public class OrganizationDeleteCommand : IOrganizationDeleteCommand
{
private readonly IOrganizationAbilityCacheService _organizationAbilityCacheService;
private readonly IOrganizationRepository _organizationRepository;
private readonly IStripePaymentService _paymentService;
private readonly ISsoConfigRepository _ssoConfigRepository;
private readonly ICipherService _cipherService;
private readonly ISubscriberService _subscriberService;
private readonly IFeatureService _featureService;
private readonly ISendFileStorageService _sendFileStorageService;
private readonly ILogger<OrganizationDeleteCommand> _logger;

public OrganizationDeleteCommand(
IOrganizationAbilityCacheService organizationAbilityCacheService,
IOrganizationRepository organizationRepository,
IStripePaymentService paymentService,
ISsoConfigRepository ssoConfigRepository,
ICipherService cipherService,
ISubscriberService subscriberService,
IFeatureService featureService,
ISendFileStorageService sendFileStorageService,
ILogger<OrganizationDeleteCommand> logger)
{
_organizationAbilityCacheService = organizationAbilityCacheService;
_organizationRepository = organizationRepository;
_paymentService = paymentService;
_ssoConfigRepository = ssoConfigRepository;
_cipherService = cipherService;
_subscriberService = subscriberService;
_featureService = featureService;
_sendFileStorageService = sendFileStorageService;
_logger = logger;
}
Expand All @@ -59,15 +52,8 @@ public async Task DeleteAsync(Organization organization)
var eop = !organization.ExpirationDate.HasValue ||
organization.ExpirationDate.Value >= DateTime.UtcNow;

if (_featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal))
{
// In cases where the subscription is not active, the cancellation will fail and be logged.
await _subscriberService.CancelSubscription(organization, cancelImmediately: !eop);
}
else
{
await _paymentService.CancelSubscriptionAsync(organization, eop);
}
// In cases where the subscription is not active, the cancellation will fail and be logged.
await _subscriberService.CancelSubscription(organization, cancelImmediately: !eop);
}
catch (Exception exception) when (exception is GatewayException or BillingException)
{
Expand Down
1 change: 0 additions & 1 deletion src/Core/AdminConsole/Services/IOrganizationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ namespace Bit.Core.Services;

public interface IOrganizationService
{
Task ReinstateSubscriptionAsync(Guid organizationId);
Task<string> AdjustStorageAsync(Guid organizationId, short storageAdjustmentGb);
Task UpdateSubscription(Guid organizationId, int seatAdjustment, int? maxAutoscaleSeats);
Task AutoAddSeatsAsync(Organization organization, int seatsToAdd);
Expand Down
Loading
Loading