diff --git a/src/Api/Billing/Controllers/TaxController.cs b/src/Api/Billing/Controllers/PreviewInvoiceController.cs similarity index 62% rename from src/Api/Billing/Controllers/TaxController.cs rename to src/Api/Billing/Controllers/PreviewInvoiceController.cs index 4ead414589..c958454618 100644 --- a/src/Api/Billing/Controllers/TaxController.cs +++ b/src/Api/Billing/Controllers/PreviewInvoiceController.cs @@ -1,8 +1,9 @@ using Bit.Api.Billing.Attributes; -using Bit.Api.Billing.Models.Requests.Tax; +using Bit.Api.Billing.Models.Requests.PreviewInvoice; using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Premium.Commands; +using Bit.Core.Entities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; @@ -10,10 +11,11 @@ using Microsoft.AspNetCore.Mvc.ModelBinding; namespace Bit.Api.Billing.Controllers; [Authorize("Application")] -[Route("billing/tax")] -public class TaxController( +[Route("billing/preview-invoice")] +public class PreviewInvoiceController( IPreviewOrganizationTaxCommand previewOrganizationTaxCommand, - IPreviewPremiumTaxCommand previewPremiumTaxCommand) : BaseBillingController + IPreviewPremiumTaxCommand previewPremiumTaxCommand, + IPreviewPremiumUpgradeProrationCommand previewPremiumUpgradeProrationCommand) : BaseBillingController { [HttpPost("organizations/subscriptions/purchase")] public async Task PreviewOrganizationSubscriptionPurchaseTaxAsync( @@ -21,11 +23,7 @@ public class TaxController( { var (purchase, billingAddress) = request.ToDomain(); var result = await previewOrganizationTaxCommand.Run(purchase, billingAddress); - return Handle(result.Map(pair => new - { - pair.Tax, - pair.Total - })); + return Handle(result.Map(pair => new { pair.Tax, pair.Total })); } [HttpPost("organizations/{organizationId:guid}/subscription/plan-change")] @@ -36,11 +34,7 @@ public class TaxController( { var (planChange, billingAddress) = request.ToDomain(); var result = await previewOrganizationTaxCommand.Run(organization, planChange, billingAddress); - return Handle(result.Map(pair => new - { - pair.Tax, - pair.Total - })); + return Handle(result.Map(pair => new { pair.Tax, pair.Total })); } [HttpPut("organizations/{organizationId:guid}/subscription/update")] @@ -51,11 +45,7 @@ public class TaxController( { var update = request.ToDomain(); var result = await previewOrganizationTaxCommand.Run(organization, update); - return Handle(result.Map(pair => new - { - pair.Tax, - pair.Total - })); + return Handle(result.Map(pair => new { pair.Tax, pair.Total })); } [HttpPost("premium/subscriptions/purchase")] @@ -64,10 +54,29 @@ public class TaxController( { var (purchase, billingAddress) = request.ToDomain(); var result = await previewPremiumTaxCommand.Run(purchase, billingAddress); - return Handle(result.Map(pair => new + return Handle(result.Map(pair => new { pair.Tax, pair.Total })); + } + + [HttpPost("premium/subscriptions/upgrade")] + [InjectUser] + public async Task PreviewPremiumUpgradeProrationAsync( + [BindNever] User user, + [FromBody] PreviewPremiumUpgradeProrationRequest request) + { + var (planType, billingAddress) = request.ToDomain(); + + var result = await previewPremiumUpgradeProrationCommand.Run( + user, + planType, + billingAddress); + + return Handle(result.Map(proration => new { - pair.Tax, - pair.Total + proration.NewPlanProratedAmount, + proration.Credit, + proration.Tax, + proration.Total, + proration.NewPlanProratedMonths })); } } diff --git a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs index 6c56d6db3a..241e595333 100644 --- a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs +++ b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs @@ -132,8 +132,8 @@ public class AccountBillingVNextController( [BindNever] User user, [FromBody] UpgradePremiumToOrganizationRequest request) { - var (organizationName, key, planType) = request.ToDomain(); - var result = await upgradePremiumToOrganizationCommand.Run(user, organizationName, key, planType); + var (organizationName, key, planType, billingAddress) = request.ToDomain(); + var result = await upgradePremiumToOrganizationCommand.Run(user, organizationName, key, planType, billingAddress); return Handle(result); } } diff --git a/src/Api/Billing/Models/Requests/Premium/UpgradePremiumToOrganizationRequest.cs b/src/Api/Billing/Models/Requests/Premium/UpgradePremiumToOrganizationRequest.cs index 14375efc78..00b1da4bba 100644 --- a/src/Api/Billing/Models/Requests/Premium/UpgradePremiumToOrganizationRequest.cs +++ b/src/Api/Billing/Models/Requests/Premium/UpgradePremiumToOrganizationRequest.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; +using Bit.Api.Billing.Models.Requests.Payment; using Bit.Core.Billing.Enums; namespace Bit.Api.Billing.Models.Requests.Premium; @@ -14,24 +15,30 @@ public class UpgradePremiumToOrganizationRequest [Required] [JsonConverter(typeof(JsonStringEnumConverter))] - public ProductTierType Tier { get; set; } + public required ProductTierType TargetProductTierType { get; set; } [Required] - [JsonConverter(typeof(JsonStringEnumConverter))] - public PlanCadenceType Cadence { get; set; } + public required MinimalBillingAddressRequest BillingAddress { get; set; } - private PlanType PlanType => - Tier switch + private PlanType PlanType + { + get { - ProductTierType.Families => PlanType.FamiliesAnnually, - ProductTierType.Teams => Cadence == PlanCadenceType.Monthly - ? PlanType.TeamsMonthly - : PlanType.TeamsAnnually, - ProductTierType.Enterprise => Cadence == PlanCadenceType.Monthly - ? PlanType.EnterpriseMonthly - : PlanType.EnterpriseAnnually, - _ => throw new InvalidOperationException("Cannot upgrade to an Organization subscription that isn't Families, Teams or Enterprise.") - }; + if (TargetProductTierType is not (ProductTierType.Families or ProductTierType.Teams or ProductTierType.Enterprise)) + { + throw new InvalidOperationException($"Cannot upgrade Premium subscription to {TargetProductTierType} plan."); + } - public (string OrganizationName, string Key, PlanType PlanType) ToDomain() => (OrganizationName, Key, PlanType); + return TargetProductTierType switch + { + ProductTierType.Families => PlanType.FamiliesAnnually, + ProductTierType.Teams => PlanType.TeamsAnnually, + ProductTierType.Enterprise => PlanType.EnterpriseAnnually, + _ => throw new InvalidOperationException($"Unexpected ProductTierType: {TargetProductTierType}") + }; + } + } + + public (string OrganizationName, string Key, PlanType PlanType, Core.Billing.Payment.Models.BillingAddress BillingAddress) ToDomain() => + (OrganizationName, Key, PlanType, BillingAddress.ToDomain()); } diff --git a/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPlanChangeTaxRequest.cs b/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewOrganizationSubscriptionPlanChangeTaxRequest.cs similarity index 91% rename from src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPlanChangeTaxRequest.cs rename to src/Api/Billing/Models/Requests/PreviewInvoice/PreviewOrganizationSubscriptionPlanChangeTaxRequest.cs index 9233a53c85..ccb8f948af 100644 --- a/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPlanChangeTaxRequest.cs +++ b/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewOrganizationSubscriptionPlanChangeTaxRequest.cs @@ -4,7 +4,7 @@ using Bit.Api.Billing.Models.Requests.Payment; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Payment.Models; -namespace Bit.Api.Billing.Models.Requests.Tax; +namespace Bit.Api.Billing.Models.Requests.PreviewInvoice; public record PreviewOrganizationSubscriptionPlanChangeTaxRequest { diff --git a/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPurchaseTaxRequest.cs b/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewOrganizationSubscriptionPurchaseTaxRequest.cs similarity index 91% rename from src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPurchaseTaxRequest.cs rename to src/Api/Billing/Models/Requests/PreviewInvoice/PreviewOrganizationSubscriptionPurchaseTaxRequest.cs index dcc5911f3d..40bec9dec3 100644 --- a/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPurchaseTaxRequest.cs +++ b/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewOrganizationSubscriptionPurchaseTaxRequest.cs @@ -4,7 +4,7 @@ using Bit.Api.Billing.Models.Requests.Payment; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Payment.Models; -namespace Bit.Api.Billing.Models.Requests.Tax; +namespace Bit.Api.Billing.Models.Requests.PreviewInvoice; public record PreviewOrganizationSubscriptionPurchaseTaxRequest { diff --git a/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionUpdateTaxRequest.cs b/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewOrganizationSubscriptionUpdateTaxRequest.cs similarity index 84% rename from src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionUpdateTaxRequest.cs rename to src/Api/Billing/Models/Requests/PreviewInvoice/PreviewOrganizationSubscriptionUpdateTaxRequest.cs index ae96214ae3..4568fea972 100644 --- a/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionUpdateTaxRequest.cs +++ b/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewOrganizationSubscriptionUpdateTaxRequest.cs @@ -1,7 +1,7 @@ using Bit.Api.Billing.Models.Requests.Organizations; using Bit.Core.Billing.Organizations.Models; -namespace Bit.Api.Billing.Models.Requests.Tax; +namespace Bit.Api.Billing.Models.Requests.PreviewInvoice; public class PreviewOrganizationSubscriptionUpdateTaxRequest { diff --git a/src/Api/Billing/Models/Requests/Tax/PreviewPremiumSubscriptionPurchaseTaxRequest.cs b/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewPremiumSubscriptionPurchaseTaxRequest.cs similarity index 90% rename from src/Api/Billing/Models/Requests/Tax/PreviewPremiumSubscriptionPurchaseTaxRequest.cs rename to src/Api/Billing/Models/Requests/PreviewInvoice/PreviewPremiumSubscriptionPurchaseTaxRequest.cs index 76b8a5a444..d1707cf6de 100644 --- a/src/Api/Billing/Models/Requests/Tax/PreviewPremiumSubscriptionPurchaseTaxRequest.cs +++ b/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewPremiumSubscriptionPurchaseTaxRequest.cs @@ -2,7 +2,7 @@ using Bit.Api.Billing.Models.Requests.Payment; using Bit.Core.Billing.Payment.Models; -namespace Bit.Api.Billing.Models.Requests.Tax; +namespace Bit.Api.Billing.Models.Requests.PreviewInvoice; public record PreviewPremiumSubscriptionPurchaseTaxRequest { diff --git a/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewPremiumUpgradeProrationRequest.cs b/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewPremiumUpgradeProrationRequest.cs new file mode 100644 index 0000000000..68d7a8d002 --- /dev/null +++ b/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewPremiumUpgradeProrationRequest.cs @@ -0,0 +1,39 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Bit.Api.Billing.Models.Requests.Payment; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Payment.Models; + +namespace Bit.Api.Billing.Models.Requests.PreviewInvoice; + +public record PreviewPremiumUpgradeProrationRequest +{ + [Required] + [JsonConverter(typeof(JsonStringEnumConverter))] + public required ProductTierType TargetProductTierType { get; set; } + + [Required] + public required MinimalBillingAddressRequest BillingAddress { get; set; } + + private PlanType PlanType + { + get + { + if (TargetProductTierType is not (ProductTierType.Families or ProductTierType.Teams or ProductTierType.Enterprise)) + { + throw new InvalidOperationException($"Cannot upgrade Premium subscription to {TargetProductTierType} plan."); + } + + return TargetProductTierType switch + { + ProductTierType.Families => PlanType.FamiliesAnnually, + ProductTierType.Teams => PlanType.TeamsAnnually, + ProductTierType.Enterprise => PlanType.EnterpriseAnnually, + _ => throw new InvalidOperationException($"Unexpected ProductTierType: {TargetProductTierType}") + }; + } + } + + public (PlanType, BillingAddress) ToDomain() => + (PlanType, BillingAddress.ToDomain()); +} diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index c61c4e6279..ddf3479aa3 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -59,6 +59,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddTransient(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); } diff --git a/src/Core/Billing/Premium/Commands/PreviewPremiumUpgradeProrationCommand.cs b/src/Core/Billing/Premium/Commands/PreviewPremiumUpgradeProrationCommand.cs new file mode 100644 index 0000000000..af2a8bdacb --- /dev/null +++ b/src/Core/Billing/Premium/Commands/PreviewPremiumUpgradeProrationCommand.cs @@ -0,0 +1,166 @@ +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Premium.Models; +using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Microsoft.Extensions.Logging; +using Stripe; + +namespace Bit.Core.Billing.Premium.Commands; + +/// +/// Previews the proration details for upgrading a Premium user subscription to an Organization +/// plan by using the Stripe API to create an invoice preview, prorated, for the upgrade. +/// +public interface IPreviewPremiumUpgradeProrationCommand +{ + /// + /// Calculates the tax, total cost, and proration credit for upgrading a Premium subscription to an Organization plan. + /// + /// The user with an active Premium subscription. + /// The target organization plan type. + /// The billing address for tax calculation. + /// The proration details for the upgrade including costs, credits, tax, and time remaining. + Task> Run( + User user, + PlanType targetPlanType, + BillingAddress billingAddress); +} + +public class PreviewPremiumUpgradeProrationCommand( + ILogger logger, + IPricingClient pricingClient, + IStripeAdapter stripeAdapter) + : BaseBillingCommand(logger), + IPreviewPremiumUpgradeProrationCommand +{ + public Task> Run( + User user, + PlanType targetPlanType, + BillingAddress billingAddress) => HandleAsync(async () => + { + if (user is not { Premium: true, GatewaySubscriptionId: not null and not "" }) + { + return new BadRequest("User does not have an active Premium subscription."); + } + + var currentSubscription = await stripeAdapter.GetSubscriptionAsync( + user.GatewaySubscriptionId, + new SubscriptionGetOptions { Expand = ["customer"] }); + var premiumPlans = await pricingClient.ListPremiumPlans(); + var passwordManagerItem = currentSubscription.Items.Data.FirstOrDefault(i => + premiumPlans.Any(p => p.Seat.StripePriceId == i.Price.Id)); + + if (passwordManagerItem == null) + { + return new BadRequest("Premium subscription password manager item not found."); + } + + var usersPremiumPlan = premiumPlans.First(p => p.Seat.StripePriceId == passwordManagerItem.Price.Id); + var targetPlan = await pricingClient.GetPlanOrThrow(targetPlanType); + var subscriptionItems = new List(); + var storageItem = currentSubscription.Items.Data.FirstOrDefault(i => + i.Price.Id == usersPremiumPlan.Storage.StripePriceId); + + // Delete the storage item if it exists for this user's plan + if (storageItem != null) + { + subscriptionItems.Add(new InvoiceSubscriptionDetailsItemOptions + { + Id = storageItem.Id, + Deleted = true + }); + } + + // Hardcode seats to 1 for upgrade flow + if (targetPlan.HasNonSeatBasedPasswordManagerPlan()) + { + subscriptionItems.Add(new InvoiceSubscriptionDetailsItemOptions + { + Id = passwordManagerItem.Id, + Price = targetPlan.PasswordManager.StripePlanId, + Quantity = 1 + }); + } + else + { + subscriptionItems.Add(new InvoiceSubscriptionDetailsItemOptions + { + Id = passwordManagerItem.Id, + Price = targetPlan.PasswordManager.StripeSeatPlanId, + Quantity = 1 + }); + } + + var options = new InvoiceCreatePreviewOptions + { + AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true }, + Customer = user.GatewayCustomerId, + Subscription = user.GatewaySubscriptionId, + CustomerDetails = new InvoiceCustomerDetailsOptions + { + Address = new AddressOptions + { + Country = billingAddress.Country, + PostalCode = billingAddress.PostalCode + } + }, + SubscriptionDetails = new InvoiceSubscriptionDetailsOptions + { + Items = subscriptionItems, + ProrationBehavior = StripeConstants.ProrationBehavior.AlwaysInvoice + } + }; + + var invoicePreview = await stripeAdapter.CreateInvoicePreviewAsync(options); + var proration = GetProration(invoicePreview, passwordManagerItem); + + return proration; + }); + + private static PremiumUpgradeProration GetProration(Invoice invoicePreview, SubscriptionItem passwordManagerItem) => new() + { + NewPlanProratedAmount = GetNewPlanProratedAmountFromInvoice(invoicePreview), + Credit = GetProrationCreditFromInvoice(invoicePreview), + Tax = Convert.ToDecimal(invoicePreview.TotalTaxes.Sum(invoiceTotalTax => invoiceTotalTax.Amount)) / 100, + Total = Convert.ToDecimal(invoicePreview.Total) / 100, + // Use invoice periodEnd here instead of UtcNow so that testing with Stripe time clocks works correctly. And if there is no test clock, + // (like in production), the previewInvoice's periodEnd is the same as UtcNow anyway because of the proration behavior (always_invoice) + NewPlanProratedMonths = CalculateNewPlanProratedMonths(invoicePreview.PeriodEnd, passwordManagerItem.CurrentPeriodEnd) + }; + + private static decimal GetProrationCreditFromInvoice(Invoice invoicePreview) + { + // Extract proration credit from negative line items (credits are negative in Stripe) + var prorationCredit = invoicePreview.Lines?.Data? + .Where(line => line.Amount < 0) + .Sum(line => Math.Abs(line.Amount)) ?? 0; // Return the credit as positive number + + return Convert.ToDecimal(prorationCredit) / 100; + } + + private static decimal GetNewPlanProratedAmountFromInvoice(Invoice invoicePreview) + { + // The target plan's prorated upgrade amount should be the only positive-valued line item + var proratedTotal = invoicePreview.Lines?.Data? + .Where(line => line.Amount > 0) + .Sum(line => line.Amount) ?? 0; + + return Convert.ToDecimal(proratedTotal) / 100; + } + + private static int CalculateNewPlanProratedMonths(DateTime invoicePeriodEnd, DateTime currentPeriodEnd) + { + var daysInProratedPeriod = (currentPeriodEnd - invoicePeriodEnd).TotalDays; + + // Round to nearest month (30-day periods) + // 1-14 days = 1 month, 15-44 days = 1 month, 45-74 days = 2 months, etc. + // Minimum is always 1 month (never returns 0) + // Use MidpointRounding.AwayFromZero to round 0.5 up to 1 + var months = (int)Math.Round(daysInProratedPeriod / 30, MidpointRounding.AwayFromZero); + return Math.Max(1, months); + } +} diff --git a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs index 9e573fac53..803674120a 100644 --- a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs +++ b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs @@ -27,12 +27,14 @@ public interface IUpgradePremiumToOrganizationCommand /// The name for the new organization. /// The encrypted organization key for the owner. /// The target organization plan type to upgrade to. + /// The billing address for tax calculation. /// A billing command result indicating success or failure with appropriate error details. Task> Run( User user, string organizationName, string key, - PlanType targetPlanType); + PlanType targetPlanType, + Payment.Models.BillingAddress billingAddress); } public class UpgradePremiumToOrganizationCommand( @@ -50,7 +52,8 @@ public class UpgradePremiumToOrganizationCommand( User user, string organizationName, string key, - PlanType targetPlanType) => HandleAsync(async () => + PlanType targetPlanType, + Payment.Models.BillingAddress billingAddress) => HandleAsync(async () => { // Validate that the user has an active Premium subscription if (user is not { Premium: true, GatewaySubscriptionId: not null and not "" }) @@ -73,7 +76,7 @@ public class UpgradePremiumToOrganizationCommand( if (passwordManagerItem == null) { - return new BadRequest("Premium subscription item not found."); + return new BadRequest("Premium subscription password manager item not found."); } var usersPremiumPlan = premiumPlans.First(p => p.Seat.StripePriceId == passwordManagerItem.Price.Id); @@ -84,13 +87,6 @@ public class UpgradePremiumToOrganizationCommand( // Build the list of subscription item updates var subscriptionItemOptions = new List(); - // Delete the user's specific password manager item - subscriptionItemOptions.Add(new SubscriptionItemOptions - { - Id = passwordManagerItem.Id, - Deleted = true - }); - // Delete the storage item if it exists for this user's plan var storageItem = currentSubscription.Items.Data.FirstOrDefault(i => i.Price.Id == usersPremiumPlan.Storage.StripePriceId); @@ -109,6 +105,7 @@ public class UpgradePremiumToOrganizationCommand( { subscriptionItemOptions.Add(new SubscriptionItemOptions { + Id = passwordManagerItem.Id, Price = targetPlan.PasswordManager.StripePlanId, Quantity = 1 }); @@ -117,6 +114,7 @@ public class UpgradePremiumToOrganizationCommand( { subscriptionItemOptions.Add(new SubscriptionItemOptions { + Id = passwordManagerItem.Id, Price = targetPlan.PasswordManager.StripeSeatPlanId, Quantity = seats }); @@ -129,7 +127,9 @@ public class UpgradePremiumToOrganizationCommand( var subscriptionUpdateOptions = new SubscriptionUpdateOptions { Items = subscriptionItemOptions, - ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations, + ProrationBehavior = StripeConstants.ProrationBehavior.AlwaysInvoice, + BillingCycleAnchor = SubscriptionBillingCycleAnchor.Unchanged, + AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }, Metadata = new Dictionary { [StripeConstants.MetadataKeys.OrganizationId] = organizationId.ToString(), @@ -144,7 +144,7 @@ public class UpgradePremiumToOrganizationCommand( Name = organizationName, BillingEmail = user.Email, PlanType = targetPlan.Type, - Seats = (short)seats, + Seats = seats, MaxCollections = targetPlan.PasswordManager.MaxCollections, MaxStorageGb = targetPlan.PasswordManager.BaseStorageGb, UsePolicies = targetPlan.HasPolicies, @@ -174,6 +174,16 @@ public class UpgradePremiumToOrganizationCommand( GatewaySubscriptionId = currentSubscription.Id }; + // Update customer billing address for tax calculation + await stripeAdapter.UpdateCustomerAsync(user.GatewayCustomerId, new CustomerUpdateOptions + { + Address = new AddressOptions + { + Country = billingAddress.Country, + PostalCode = billingAddress.PostalCode + } + }); + // Update the subscription in Stripe await stripeAdapter.UpdateSubscriptionAsync(currentSubscription.Id, subscriptionUpdateOptions); diff --git a/src/Core/Billing/Premium/Models/PremiumUpgradeProration.cs b/src/Core/Billing/Premium/Models/PremiumUpgradeProration.cs new file mode 100644 index 0000000000..d8acaa3170 --- /dev/null +++ b/src/Core/Billing/Premium/Models/PremiumUpgradeProration.cs @@ -0,0 +1,36 @@ +namespace Bit.Core.Billing.Premium.Models; + +/// +/// Represents the proration details for upgrading a Premium user subscription to an Organization plan. +/// +public class PremiumUpgradeProration +{ + /// + /// The prorated cost for the new organization plan, calculated from now until the end of the current billing period. + /// This represents what the user will pay for the upgraded plan for the remainder of the period. + /// + public decimal NewPlanProratedAmount { get; set; } + + /// + /// The credit amount for the unused portion of the current Premium subscription. + /// This credit is applied against the cost of the new organization plan. + /// + public decimal Credit { get; set; } + + /// + /// The tax amount calculated for the upgrade transaction. + /// + public decimal Tax { get; set; } + + /// + /// The total amount due for the upgrade after applying the credit and adding tax. + /// + public decimal Total { get; set; } + + /// + /// The number of months the user will be charged for the new organization plan in the prorated billing period. + /// Calculated by rounding the days remaining in the current billing cycle to the nearest month. + /// Minimum value is 1 month (never returns 0). + /// + public int NewPlanProratedMonths { get; set; } +} diff --git a/test/Api.Test/Billing/Models/Requests/PreviewPremiumUpgradeProrationRequestTests.cs b/test/Api.Test/Billing/Models/Requests/PreviewPremiumUpgradeProrationRequestTests.cs new file mode 100644 index 0000000000..5ed4182a5d --- /dev/null +++ b/test/Api.Test/Billing/Models/Requests/PreviewPremiumUpgradeProrationRequestTests.cs @@ -0,0 +1,56 @@ +using Bit.Api.Billing.Models.Requests.Payment; +using Bit.Api.Billing.Models.Requests.PreviewInvoice; +using Bit.Core.Billing.Enums; +using Xunit; + +namespace Bit.Api.Test.Billing.Models.Requests; + +public class PreviewPremiumUpgradeProrationRequestTests +{ + [Theory] + [InlineData(ProductTierType.Families, PlanType.FamiliesAnnually)] + [InlineData(ProductTierType.Teams, PlanType.TeamsAnnually)] + [InlineData(ProductTierType.Enterprise, PlanType.EnterpriseAnnually)] + public void ToDomain_ValidTierTypes_ReturnsPlanType(ProductTierType tierType, PlanType expectedPlanType) + { + // Arrange + var sut = new PreviewPremiumUpgradeProrationRequest + { + TargetProductTierType = tierType, + BillingAddress = new MinimalBillingAddressRequest + { + Country = "US", + PostalCode = "12345" + } + }; + + // Act + var (planType, billingAddress) = sut.ToDomain(); + + // Assert + Assert.Equal(expectedPlanType, planType); + Assert.Equal("US", billingAddress.Country); + Assert.Equal("12345", billingAddress.PostalCode); + } + + [Theory] + [InlineData(ProductTierType.Free)] + [InlineData(ProductTierType.TeamsStarter)] + public void ToDomain_InvalidTierTypes_ThrowsInvalidOperationException(ProductTierType tierType) + { + // Arrange + var sut = new PreviewPremiumUpgradeProrationRequest + { + TargetProductTierType = tierType, + BillingAddress = new MinimalBillingAddressRequest + { + Country = "US", + PostalCode = "12345" + } + }; + + // Act & Assert + var exception = Assert.Throws(() => sut.ToDomain()); + Assert.Contains($"Cannot upgrade Premium subscription to {tierType} plan", exception.Message); + } +} diff --git a/test/Api.Test/Billing/Models/Requests/UpgradePremiumToOrganizationRequestTests.cs b/test/Api.Test/Billing/Models/Requests/UpgradePremiumToOrganizationRequestTests.cs new file mode 100644 index 0000000000..2d3bdb7b14 --- /dev/null +++ b/test/Api.Test/Billing/Models/Requests/UpgradePremiumToOrganizationRequestTests.cs @@ -0,0 +1,62 @@ +using Bit.Api.Billing.Models.Requests.Payment; +using Bit.Api.Billing.Models.Requests.Premium; +using Bit.Core.Billing.Enums; +using Xunit; + +namespace Bit.Api.Test.Billing.Models.Requests; + +public class UpgradePremiumToOrganizationRequestTests +{ + [Theory] + [InlineData(ProductTierType.Families, PlanType.FamiliesAnnually)] + [InlineData(ProductTierType.Teams, PlanType.TeamsAnnually)] + [InlineData(ProductTierType.Enterprise, PlanType.EnterpriseAnnually)] + public void ToDomain_ValidTierTypes_ReturnsPlanType(ProductTierType tierType, PlanType expectedPlanType) + { + // Arrange + var sut = new UpgradePremiumToOrganizationRequest + { + OrganizationName = "Test Organization", + Key = "encrypted-key", + TargetProductTierType = tierType, + BillingAddress = new MinimalBillingAddressRequest + { + Country = "US", + PostalCode = "12345" + } + }; + + // Act + var (organizationName, key, planType, billingAddress) = sut.ToDomain(); + + // Assert + Assert.Equal("Test Organization", organizationName); + Assert.Equal("encrypted-key", key); + Assert.Equal(expectedPlanType, planType); + Assert.Equal("US", billingAddress.Country); + Assert.Equal("12345", billingAddress.PostalCode); + } + + [Theory] + [InlineData(ProductTierType.Free)] + [InlineData(ProductTierType.TeamsStarter)] + public void ToDomain_InvalidTierTypes_ThrowsInvalidOperationException(ProductTierType tierType) + { + // Arrange + var sut = new UpgradePremiumToOrganizationRequest + { + OrganizationName = "Test Organization", + Key = "encrypted-key", + TargetProductTierType = tierType, + BillingAddress = new MinimalBillingAddressRequest + { + Country = "US", + PostalCode = "12345" + } + }; + + // Act & Assert + var exception = Assert.Throws(() => sut.ToDomain()); + Assert.Contains($"Cannot upgrade Premium subscription to {tierType} plan", exception.Message); + } +} diff --git a/test/Core.Test/Billing/Premium/Commands/PreviewPremiumUpgradeProrationCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/PreviewPremiumUpgradeProrationCommandTests.cs new file mode 100644 index 0000000000..c2af07f633 --- /dev/null +++ b/test/Core.Test/Billing/Premium/Commands/PreviewPremiumUpgradeProrationCommandTests.cs @@ -0,0 +1,777 @@ +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Premium.Commands; +using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Test.Billing.Mocks.Plans; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Stripe; +using Xunit; +using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan; + +namespace Bit.Core.Test.Billing.Premium.Commands; + +public class PreviewPremiumUpgradeProrationCommandTests +{ + private readonly ILogger _logger = Substitute.For>(); + private readonly IPricingClient _pricingClient = Substitute.For(); + private readonly IStripeAdapter _stripeAdapter = Substitute.For(); + private readonly PreviewPremiumUpgradeProrationCommand _command; + + public PreviewPremiumUpgradeProrationCommandTests() + { + _command = new PreviewPremiumUpgradeProrationCommand( + _logger, + _pricingClient, + _stripeAdapter); + } + + [Theory, BitAutoData] + public async Task Run_UserWithoutPremium_ReturnsBadRequest(User user, BillingAddress billingAddress) + { + // Arrange + user.Premium = false; + + // Act + var result = await _command.Run(user, PlanType.TeamsAnnually, billingAddress); + + // Assert + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal("User does not have an active Premium subscription.", badRequest.Response); + } + + [Theory, BitAutoData] + public async Task Run_UserWithoutGatewaySubscriptionId_ReturnsBadRequest(User user, BillingAddress billingAddress) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = null; + + // Act + var result = await _command.Run(user, PlanType.TeamsAnnually, billingAddress); + + // Assert + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal("User does not have an active Premium subscription.", badRequest.Response); + } + + [Theory, BitAutoData] + public async Task Run_ValidUpgrade_ReturnsProrationAmounts(User user, BillingAddress billingAddress) + { + // Arrange - Setup valid Premium user + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + // Setup Premium plans + var premiumPlan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "premium-annually", + Price = 10m, + Provided = 1 + }, + Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "storage-gb-annually", + Price = 4m, + Provided = 1 + } + }; + + var premiumPlans = new List { premiumPlan }; + + // Setup current Stripe subscription + var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var currentPeriodEnd = now.AddMonths(6); + var currentSubscription = new Subscription + { + Id = "sub_123", + Customer = new Customer + { + Id = "cus_123", + Discount = null + }, + Items = new StripeList + { + Data = new List + { + new() + { + Id = "si_premium", + Price = new Price { Id = "premium-annually" }, + CurrentPeriodEnd = currentPeriodEnd + } + } + } + }; + + // Setup target organization plan + var targetPlan = new TeamsPlan(isAnnual: true); + + // Setup invoice preview response + var invoice = new Invoice + { + Total = 5000, // $50.00 + TotalTaxes = new List + { + new() { Amount = 500 } // $5.00 + }, + Lines = new StripeList + { + Data = new List + { + new() { Amount = 5000 } // $50.00 for new plan + } + }, + PeriodEnd = now + }; + + // Configure mocks + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(targetPlan); + _stripeAdapter.GetSubscriptionAsync( + "sub_123", + Arg.Any()) + .Returns(currentSubscription); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(invoice); + + // Act + var result = await _command.Run(user, PlanType.TeamsAnnually, billingAddress); + + // Assert + Assert.True(result.IsT0); + var proration = result.AsT0; + Assert.Equal(50.00m, proration.NewPlanProratedAmount); + Assert.Equal(0m, proration.Credit); + Assert.Equal(5.00m, proration.Tax); + Assert.Equal(50.00m, proration.Total); + Assert.Equal(6, proration.NewPlanProratedMonths); // 6 months remaining + } + + [Theory, BitAutoData] + public async Task Run_ValidUpgrade_ExtractsProrationCredit(User user, BillingAddress billingAddress) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var premiumPlan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "premium-annually", + Price = 10m, + Provided = 1 + }, + Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "storage-gb-annually", + Price = 4m, + Provided = 1 + } + }; + var premiumPlans = new List { premiumPlan }; + + // Use fixed time to avoid DateTime.UtcNow differences + var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var currentPeriodEnd = now.AddDays(45); // 1.5 months ~ 2 months rounded + var currentSubscription = new Subscription + { + Id = "sub_123", + Customer = new Customer { Id = "cus_123", Discount = null }, + Items = new StripeList + { + Data = new List + { + new() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = currentPeriodEnd } + } + } + }; + + var targetPlan = new TeamsPlan(isAnnual: true); + + // Invoice with negative line item (proration credit) + var invoice = new Invoice + { + Total = 4000, // $40.00 + TotalTaxes = new List { new() { Amount = 400 } }, // $4.00 + Lines = new StripeList + { + Data = new List + { + new() { Amount = -1000 }, // -$10.00 credit from unused Premium + new() { Amount = 5000 } // $50.00 for new plan + } + }, + PeriodEnd = now + }; + + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(targetPlan); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()) + .Returns(currentSubscription); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(invoice); + + // Act + var result = await _command.Run(user, PlanType.TeamsAnnually, billingAddress); + + // Assert + Assert.True(result.IsT0); + var proration = result.AsT0; + Assert.Equal(50.00m, proration.NewPlanProratedAmount); + Assert.Equal(10.00m, proration.Credit); // Proration credit + Assert.Equal(4.00m, proration.Tax); + Assert.Equal(40.00m, proration.Total); + Assert.Equal(2, proration.NewPlanProratedMonths); // 45 days rounds to 2 months + } + + [Theory, BitAutoData] + public async Task Run_ValidUpgrade_AlwaysUsesOneSeat(User user, BillingAddress billingAddress) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var premiumPlan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "premium-annually", + Price = 10m, + Provided = 1 + }, + Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "storage-gb-annually", + Price = 4m, + Provided = 1 + } + }; + var premiumPlans = new List { premiumPlan }; + + var currentSubscription = new Subscription + { + Id = "sub_123", + Customer = new Customer { Id = "cus_123", Discount = null }, + Items = new StripeList + { + Data = new List + { + new() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) } + } + } + }; + + var targetPlan = new TeamsPlan(isAnnual: true); + + var invoice = new Invoice + { + Total = 5000, + TotalTaxes = new List { new() { Amount = 500 } }, + Lines = new StripeList { Data = new List { new() { Amount = 5000 } } }, + PeriodEnd = DateTime.UtcNow + }; + + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(targetPlan); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()) + .Returns(currentSubscription); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(invoice); + + // Act + await _command.Run(user, PlanType.TeamsAnnually, billingAddress); + + // Assert - Verify that the subscription item quantity is always 1 and has Id + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync( + Arg.Is(options => + options.SubscriptionDetails.Items.Any(item => + item.Id == "si_premium" && + item.Price == targetPlan.PasswordManager.StripeSeatPlanId && + item.Quantity == 1))); + } + + [Theory, BitAutoData] + public async Task Run_ValidUpgrade_DeletesPremiumSubscriptionItems(User user, BillingAddress billingAddress) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var premiumPlan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "premium-annually", + Price = 10m, + Provided = 1 + }, + Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "storage-gb-annually", + Price = 4m, + Provided = 1 + } + }; + var premiumPlans = new List { premiumPlan }; + + var currentSubscription = new Subscription + { + Id = "sub_123", + Customer = new Customer { Id = "cus_123", Discount = null }, + Items = new StripeList + { + Data = new List + { + new() { Id = "si_password_manager", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) }, + new() { Id = "si_storage", Price = new Price { Id = "storage-gb-annually" }, CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) } + } + } + }; + + var targetPlan = new TeamsPlan(isAnnual: true); + + var invoice = new Invoice + { + Total = 5000, + TotalTaxes = new List { new() { Amount = 500 } }, + Lines = new StripeList { Data = new List { new() { Amount = 5000 } } }, + PeriodEnd = DateTime.UtcNow + }; + + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(targetPlan); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()) + .Returns(currentSubscription); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(invoice); + + // Act + await _command.Run(user, PlanType.TeamsAnnually, billingAddress); + + // Assert - Verify password manager item is modified and storage item is deleted + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync( + Arg.Is(options => + // Password manager item should be modified to new plan price, not deleted + options.SubscriptionDetails.Items.Any(item => + item.Id == "si_password_manager" && + item.Price == targetPlan.PasswordManager.StripeSeatPlanId && + item.Deleted != true) && + // Storage item should be deleted + options.SubscriptionDetails.Items.Any(item => + item.Id == "si_storage" && item.Deleted == true))); + } + + [Theory, BitAutoData] + public async Task Run_NonSeatBasedPlan_UsesStripePlanId(User user, BillingAddress billingAddress) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var premiumPlan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "premium-annually", + Price = 10m, + Provided = 1 + }, + Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "storage-gb-annually", + Price = 4m, + Provided = 1 + } + }; + var premiumPlans = new List { premiumPlan }; + + var currentSubscription = new Subscription + { + Id = "sub_123", + Customer = new Customer { Id = "cus_123", Discount = null }, + Items = new StripeList + { + Data = new List + { + new() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) } + } + } + }; + + var targetPlan = new FamiliesPlan(); // families is non seat based + + var invoice = new Invoice + { + Total = 5000, + TotalTaxes = new List { new() { Amount = 500 } }, + Lines = new StripeList { Data = new List { new() { Amount = 5000 } } }, + PeriodEnd = DateTime.UtcNow + }; + + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(targetPlan); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()) + .Returns(currentSubscription); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(invoice); + + // Act + await _command.Run(user, PlanType.FamiliesAnnually, billingAddress); + + // Assert - Verify non-seat-based plan uses StripePlanId with quantity 1 and modifies existing item + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync( + Arg.Is(options => + options.SubscriptionDetails.Items.Any(item => + item.Id == "si_premium" && + item.Price == targetPlan.PasswordManager.StripePlanId && + item.Quantity == 1))); + } + + [Theory, BitAutoData] + public async Task Run_ValidUpgrade_CreatesCorrectInvoicePreviewOptions(User user, BillingAddress billingAddress) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + billingAddress.Country = "US"; + billingAddress.PostalCode = "12345"; + + var premiumPlan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "premium-annually", + Price = 10m, + Provided = 1 + }, + Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "storage-gb-annually", + Price = 4m, + Provided = 1 + } + }; + var premiumPlans = new List { premiumPlan }; + + var currentSubscription = new Subscription + { + Id = "sub_123", + Customer = new Customer { Id = "cus_123", Discount = null }, + Items = new StripeList + { + Data = new List + { + new() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) } + } + } + }; + + var targetPlan = new TeamsPlan(isAnnual: true); + + var invoice = new Invoice + { + Total = 5000, + TotalTaxes = new List { new() { Amount = 500 } }, + Lines = new StripeList { Data = new List { new() { Amount = 5000 } } }, + PeriodEnd = DateTime.UtcNow + }; + + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(targetPlan); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()) + .Returns(currentSubscription); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(invoice); + + // Act + await _command.Run(user, PlanType.TeamsAnnually, billingAddress); + + // Assert - Verify all invoice preview options are correct + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync( + Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Customer == "cus_123" && + options.Subscription == "sub_123" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.SubscriptionDetails.ProrationBehavior == "always_invoice")); + } + + [Theory, BitAutoData] + public async Task Run_SeatBasedPlan_UsesStripeSeatPlanId(User user, BillingAddress billingAddress) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var premiumPlan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "premium-annually", + Price = 10m, + Provided = 1 + }, + Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "storage-gb-annually", + Price = 4m, + Provided = 1 + } + }; + var premiumPlans = new List { premiumPlan }; + + var currentSubscription = new Subscription + { + Id = "sub_123", + Customer = new Customer { Id = "cus_123", Discount = null }, + Items = new StripeList + { + Data = new List + { + new() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) } + } + } + }; + + // Use Teams which is seat-based + var targetPlan = new TeamsPlan(isAnnual: true); + + var invoice = new Invoice + { + Total = 5000, + TotalTaxes = new List { new() { Amount = 500 } }, + Lines = new StripeList { Data = new List { new() { Amount = 5000 } } }, + PeriodEnd = DateTime.UtcNow + }; + + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(targetPlan); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()) + .Returns(currentSubscription); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(invoice); + + // Act + await _command.Run(user, PlanType.TeamsAnnually, billingAddress); + + // Assert - Verify seat-based plan uses StripeSeatPlanId with quantity 1 and modifies existing item + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync( + Arg.Is(options => + options.SubscriptionDetails.Items.Any(item => + item.Id == "si_premium" && + item.Price == targetPlan.PasswordManager.StripeSeatPlanId && + item.Quantity == 1))); + } + + [Theory] + [InlineData(0, 1)] // Less than 15 days, minimum 1 month + [InlineData(1, 1)] // 1 day = 1 month minimum + [InlineData(14, 1)] // 14 days = 1 month minimum + [InlineData(15, 1)] // 15 days rounds to 1 month + [InlineData(30, 1)] // 30 days = 1 month + [InlineData(44, 1)] // 44 days rounds to 1 month + [InlineData(45, 2)] // 45 days rounds to 2 months + [InlineData(60, 2)] // 60 days = 2 months + [InlineData(90, 3)] // 90 days = 3 months + [InlineData(180, 6)] // 180 days = 6 months + [InlineData(365, 12)] // 365 days rounds to 12 months + public async Task Run_ValidUpgrade_CalculatesNewPlanProratedMonthsCorrectly(int daysRemaining, int expectedMonths) + { + // Arrange + var user = new User + { + Premium = true, + GatewaySubscriptionId = "sub_123", + GatewayCustomerId = "cus_123" + }; + var billingAddress = new Core.Billing.Payment.Models.BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var premiumPlan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "premium-annually", + Price = 10m, + Provided = 1 + }, + Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "storage-gb-annually", + Price = 4m, + Provided = 1 + } + }; + var premiumPlans = new List { premiumPlan }; + + // Use fixed time to avoid DateTime.UtcNow differences + var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var currentPeriodEnd = now.AddDays(daysRemaining); + var currentSubscription = new Subscription + { + Id = "sub_123", + Customer = new Customer { Id = "cus_123", Discount = null }, + Items = new StripeList + { + Data = new List + { + new() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = currentPeriodEnd } + } + } + }; + + var targetPlan = new TeamsPlan(isAnnual: true); + + var invoice = new Invoice + { + Total = 5000, + TotalTaxes = new List { new() { Amount = 500 } }, + Lines = new StripeList + { + Data = new List { new() { Amount = 5000 } } + }, + PeriodEnd = now + }; + + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(targetPlan); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()) + .Returns(currentSubscription); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(invoice); + + // Act + var result = await _command.Run(user, PlanType.TeamsAnnually, billingAddress); + + // Assert + Assert.True(result.IsT0); + var proration = result.AsT0; + Assert.Equal(expectedMonths, proration.NewPlanProratedMonths); + } + + [Theory, BitAutoData] + public async Task Run_ValidUpgrade_ReturnsNewPlanProratedAmountCorrectly(User user, BillingAddress billingAddress) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var premiumPlan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "premium-annually", + Price = 10m, + Provided = 1 + }, + Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "storage-gb-annually", + Price = 4m, + Provided = 1 + } + }; + var premiumPlans = new List { premiumPlan }; + + var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var currentPeriodEnd = now.AddMonths(3); + var currentSubscription = new Subscription + { + Id = "sub_123", + Customer = new Customer { Id = "cus_123", Discount = null }, + Items = new StripeList + { + Data = new List + { + new() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = currentPeriodEnd } + } + } + }; + + var targetPlan = new TeamsPlan(isAnnual: true); + + // Invoice showing new plan cost, credit, and net + var invoice = new Invoice + { + Total = 4500, // $45.00 net after $5 credit + TotalTaxes = new List { new() { Amount = 450 } }, // $4.50 + Lines = new StripeList + { + Data = new List + { + new() { Amount = -500 }, // -$5.00 credit + new() { Amount = 5000 } // $50.00 for new plan + } + }, + PeriodEnd = now + }; + + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(targetPlan); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()) + .Returns(currentSubscription); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(invoice); + + // Act + var result = await _command.Run(user, PlanType.TeamsAnnually, billingAddress); + + // Assert + Assert.True(result.IsT0); + var proration = result.AsT0; + + Assert.Equal(50.00m, proration.NewPlanProratedAmount); + Assert.Equal(5.00m, proration.Credit); + Assert.Equal(4.50m, proration.Tax); + Assert.Equal(45.00m, proration.Total); + } +} + diff --git a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs index ea07c7f81d..b4fd0e2d21 100644 --- a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs @@ -37,7 +37,6 @@ public class UpgradePremiumToOrganizationCommandTests NameLocalizationKey = ""; DescriptionLocalizationKey = ""; CanBeUsedByBusiness = true; - TrialPeriodDays = null; HasSelfHost = false; HasPolicies = false; HasGroups = false; @@ -86,10 +85,8 @@ public class UpgradePremiumToOrganizationCommandTests string? stripePlanId = null, string? stripeSeatPlanId = null, string? stripePremiumAccessPlanId = null, - string? stripeStoragePlanId = null) - { - return new TestPlan(planType, stripePlanId, stripeSeatPlanId, stripePremiumAccessPlanId, stripeStoragePlanId); - } + string? stripeStoragePlanId = null) => + new TestPlan(planType, stripePlanId, stripeSeatPlanId, stripePremiumAccessPlanId, stripeStoragePlanId); private static PremiumPlan CreateTestPremiumPlan( string seatPriceId = "premium-annually", @@ -151,6 +148,9 @@ public class UpgradePremiumToOrganizationCommandTests _applicationCacheService); } + private static Core.Billing.Payment.Models.BillingAddress CreateTestBillingAddress() => + new() { Country = "US", PostalCode = "12345" }; + [Theory, BitAutoData] public async Task Run_UserNotPremium_ReturnsBadRequest(User user) { @@ -158,7 +158,7 @@ public class UpgradePremiumToOrganizationCommandTests user.Premium = false; // Act - var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually); + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress()); // Assert Assert.True(result.IsT1); @@ -174,7 +174,7 @@ public class UpgradePremiumToOrganizationCommandTests user.GatewaySubscriptionId = null; // Act - var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually); + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress()); // Assert Assert.True(result.IsT1); @@ -190,7 +190,7 @@ public class UpgradePremiumToOrganizationCommandTests user.GatewaySubscriptionId = ""; // Act - var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually); + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress()); // Assert Assert.True(result.IsT1); @@ -245,7 +245,7 @@ public class UpgradePremiumToOrganizationCommandTests _userService.SaveUserAsync(user).Returns(Task.CompletedTask); // Act - var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually); + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress()); // Assert Assert.True(result.IsT0); @@ -253,9 +253,8 @@ public class UpgradePremiumToOrganizationCommandTests await _stripeAdapter.Received(1).UpdateSubscriptionAsync( "sub_123", Arg.Is(opts => - opts.Items.Count == 2 && // 1 deleted + 1 seat (no storage) - opts.Items.Any(i => i.Deleted == true) && - opts.Items.Any(i => i.Price == "teams-seat-annually" && i.Quantity == 1))); + opts.Items.Count == 1 && // Only 1 item: modify existing password manager item (no storage to delete) + opts.Items.Any(i => i.Id == "si_premium" && i.Price == "teams-seat-annually" && i.Quantity == 1 && i.Deleted != true))); await _organizationRepository.Received(1).CreateAsync(Arg.Is(o => o.Name == "My Organization" && @@ -320,7 +319,7 @@ public class UpgradePremiumToOrganizationCommandTests _userService.SaveUserAsync(user).Returns(Task.CompletedTask); // Act - var result = await _command.Run(user, "My Families Org", "encrypted-key", PlanType.FamiliesAnnually); + var result = await _command.Run(user, "My Families Org", "encrypted-key", PlanType.FamiliesAnnually, CreateTestBillingAddress()); // Assert Assert.True(result.IsT0); @@ -328,9 +327,8 @@ public class UpgradePremiumToOrganizationCommandTests await _stripeAdapter.Received(1).UpdateSubscriptionAsync( "sub_123", Arg.Is(opts => - opts.Items.Count == 2 && // 1 deleted + 1 plan - opts.Items.Any(i => i.Deleted == true) && - opts.Items.Any(i => i.Price == "families-plan-annually" && i.Quantity == 1))); + opts.Items.Count == 1 && // Only 1 item: modify existing password manager item (no storage to delete) + opts.Items.Any(i => i.Id == "si_premium" && i.Price == "families-plan-annually" && i.Quantity == 1 && i.Deleted != true))); await _organizationRepository.Received(1).CreateAsync(Arg.Is(o => o.Name == "My Families Org")); @@ -383,7 +381,7 @@ public class UpgradePremiumToOrganizationCommandTests _userService.SaveUserAsync(user).Returns(Task.CompletedTask); // Act - var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually); + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress()); // Assert Assert.True(result.IsT0); @@ -448,19 +446,18 @@ public class UpgradePremiumToOrganizationCommandTests _userService.SaveUserAsync(user).Returns(Task.CompletedTask); // Act - var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually); + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress()); // Assert Assert.True(result.IsT0); - // Verify that BOTH legacy items (password manager + storage) are deleted by ID + // Verify that legacy password manager item is modified and legacy storage is deleted await _stripeAdapter.Received(1).UpdateSubscriptionAsync( "sub_123", Arg.Is(opts => - opts.Items.Count == 3 && // 2 deleted (legacy PM + legacy storage) + 1 new seat - opts.Items.Count(i => i.Deleted == true && i.Id == "si_premium_legacy") == 1 && // Legacy PM deleted - opts.Items.Count(i => i.Deleted == true && i.Id == "si_storage_legacy") == 1 && // Legacy storage deleted - opts.Items.Any(i => i.Price == "teams-seat-annually" && i.Quantity == 1))); + opts.Items.Count == 2 && // 1 modified (legacy PM to new price) + 1 deleted (legacy storage) + opts.Items.Count(i => i.Id == "si_premium_legacy" && i.Price == "teams-seat-annually" && i.Quantity == 1 && i.Deleted != true) == 1 && // Legacy PM modified + opts.Items.Count(i => i.Deleted == true && i.Id == "si_storage_legacy") == 1)); // Legacy storage deleted } [Theory, BitAutoData] @@ -515,20 +512,19 @@ public class UpgradePremiumToOrganizationCommandTests _userService.SaveUserAsync(user).Returns(Task.CompletedTask); // Act - var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually); + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress()); // Assert Assert.True(result.IsT0); - // Verify that ONLY the premium password manager item is deleted (not other products) - // Note: We delete the specific premium item by ID, so other products are untouched + // Verify that ONLY the premium password manager item is modified (not other products) + // Note: We modify the specific premium item by ID, so other products are untouched await _stripeAdapter.Received(1).UpdateSubscriptionAsync( "sub_123", Arg.Is(opts => - opts.Items.Count == 2 && // 1 deleted (premium password manager) + 1 new seat - opts.Items.Count(i => i.Deleted == true && i.Id == "si_premium") == 1 && // Premium item deleted by ID - opts.Items.Count(i => i.Id == "si_other_product") == 0 && // Other product NOT in update (untouched) - opts.Items.Any(i => i.Price == "teams-seat-annually" && i.Quantity == 1))); + opts.Items.Count == 1 && // Only modify premium password manager item + opts.Items.Count(i => i.Id == "si_premium" && i.Price == "teams-seat-annually" && i.Quantity == 1 && i.Deleted != true) == 1 && // Premium item modified + opts.Items.Count(i => i.Id == "si_other_product") == 0)); // Other product NOT in update (untouched) } [Theory, BitAutoData] @@ -584,7 +580,7 @@ public class UpgradePremiumToOrganizationCommandTests _userService.SaveUserAsync(user).Returns(Task.CompletedTask); // Act - var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually); + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress()); // Assert Assert.True(result.IsT0); @@ -593,8 +589,8 @@ public class UpgradePremiumToOrganizationCommandTests await _stripeAdapter.Received(1).UpdateSubscriptionAsync( "sub_123", Arg.Is(opts => - opts.Items.Count == 3 && // 2 deleted (premium + storage) + 1 new seat - opts.Items.Count(i => i.Deleted == true) == 2)); + opts.Items.Count == 2 && // 1 modified (premium to new price) + 1 deleted (storage) + opts.Items.Count(i => i.Deleted == true) == 1)); } [Theory, BitAutoData] @@ -629,11 +625,385 @@ public class UpgradePremiumToOrganizationCommandTests _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); // Act - var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually); + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress()); // Assert Assert.True(result.IsT1); var badRequest = result.AsT1; - Assert.Equal("Premium subscription item not found.", badRequest.Response); + Assert.Equal("Premium subscription password manager item not found.", badRequest.Response); + } + + [Theory, BitAutoData] + public async Task Run_UpdatesCustomerBillingAddress(User user) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var mockSubscription = new Subscription + { + Id = "sub_123", + Items = new StripeList + { + Data = new List + { + new SubscriptionItem + { + Id = "si_premium", + Price = new Price { Id = "premium-annually" } + } + } + }, + Metadata = new Dictionary() + }; + + var mockPremiumPlans = CreateTestPremiumPlansList(); + var mockPlan = CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually"); + + _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription); + _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()).Returns(mockSubscription); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(new Customer())); + _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); + _userService.SaveUserAsync(user).Returns(Task.CompletedTask); + + var billingAddress = new Core.Billing.Payment.Models.BillingAddress { Country = "US", PostalCode = "12345" }; + + // Act + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, billingAddress); + + // Assert + Assert.True(result.IsT0); + + await _stripeAdapter.Received(1).UpdateCustomerAsync( + "cus_123", + Arg.Is(opts => + opts.Address.Country == "US" && + opts.Address.PostalCode == "12345")); + } + + [Theory, BitAutoData] + public async Task Run_EnablesAutomaticTaxOnSubscription(User user) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var mockSubscription = new Subscription + { + Id = "sub_123", + Items = new StripeList + { + Data = new List + { + new SubscriptionItem + { + Id = "si_premium", + Price = new Price { Id = "premium-annually" } + } + } + }, + Metadata = new Dictionary() + }; + + var mockPremiumPlans = CreateTestPremiumPlansList(); + var mockPlan = CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually"); + + _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription); + _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()).Returns(mockSubscription); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(new Customer())); + _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); + _userService.SaveUserAsync(user).Returns(Task.CompletedTask); + + // Act + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress()); + + // Assert + Assert.True(result.IsT0); + + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( + "sub_123", + Arg.Is(opts => + opts.AutomaticTax != null && + opts.AutomaticTax.Enabled == true)); + } + + [Theory, BitAutoData] + public async Task Run_UsesAlwaysInvoiceProrationBehavior(User user) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var mockSubscription = new Subscription + { + Id = "sub_123", + Items = new StripeList + { + Data = new List + { + new SubscriptionItem + { + Id = "si_premium", + Price = new Price { Id = "premium-annually" } + } + } + }, + Metadata = new Dictionary() + }; + + var mockPremiumPlans = CreateTestPremiumPlansList(); + var mockPlan = CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually"); + + _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription); + _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()).Returns(mockSubscription); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(new Customer())); + _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); + _userService.SaveUserAsync(user).Returns(Task.CompletedTask); + + // Act + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress()); + + // Assert + Assert.True(result.IsT0); + + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( + "sub_123", + Arg.Is(opts => + opts.ProrationBehavior == "always_invoice")); + } + + [Theory, BitAutoData] + public async Task Run_ModifiesExistingSubscriptionItem_NotDeleteAndRecreate(User user) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var mockSubscription = new Subscription + { + Id = "sub_123", + Items = new StripeList + { + Data = new List + { + new SubscriptionItem + { + Id = "si_premium", + Price = new Price { Id = "premium-annually" } + } + } + }, + Metadata = new Dictionary() + }; + + var mockPremiumPlans = CreateTestPremiumPlansList(); + var mockPlan = CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually"); + + _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription); + _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()).Returns(mockSubscription); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(new Customer())); + _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); + _userService.SaveUserAsync(user).Returns(Task.CompletedTask); + + // Act + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress()); + + // Assert + Assert.True(result.IsT0); + + // Verify that the subscription item was modified, not deleted + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( + "sub_123", + Arg.Is(opts => + // Should have an item with the original ID being modified + opts.Items.Any(item => + item.Id == "si_premium" && + item.Price == "teams-seat-annually" && + item.Quantity == 1 && + item.Deleted != true))); + } + + [Theory, BitAutoData] + public async Task Run_CreatesOrganizationWithCorrectSettings(User user) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var mockSubscription = new Subscription + { + Id = "sub_123", + Items = new StripeList + { + Data = new List + { + new SubscriptionItem + { + Id = "si_premium", + Price = new Price { Id = "premium-annually" } + } + } + }, + Metadata = new Dictionary() + }; + + var mockPremiumPlans = CreateTestPremiumPlansList(); + var mockPlan = CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually"); + + _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription); + _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()).Returns(mockSubscription); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(new Customer())); + _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); + _userService.SaveUserAsync(user).Returns(Task.CompletedTask); + + // Act + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress()); + + // Assert + Assert.True(result.IsT0); + + await _organizationRepository.Received(1).CreateAsync( + Arg.Is(org => + org.Name == "My Organization" && + org.BillingEmail == user.Email && + org.PlanType == PlanType.TeamsAnnually && + org.Seats == 1 && + org.Gateway == GatewayType.Stripe && + org.GatewayCustomerId == "cus_123" && + org.GatewaySubscriptionId == "sub_123" && + org.Enabled == true)); + } + + [Theory, BitAutoData] + public async Task Run_CreatesOrganizationApiKeyWithCorrectType(User user) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var mockSubscription = new Subscription + { + Id = "sub_123", + Items = new StripeList + { + Data = new List + { + new SubscriptionItem + { + Id = "si_premium", + Price = new Price { Id = "premium-annually" } + } + } + }, + Metadata = new Dictionary() + }; + + var mockPremiumPlans = CreateTestPremiumPlansList(); + var mockPlan = CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually"); + + _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription); + _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()).Returns(mockSubscription); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(new Customer())); + _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); + _userService.SaveUserAsync(user).Returns(Task.CompletedTask); + + // Act + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress()); + + // Assert + Assert.True(result.IsT0); + + await _organizationApiKeyRepository.Received(1).CreateAsync( + Arg.Is(apiKey => + apiKey.Type == OrganizationApiKeyType.Default && + !string.IsNullOrEmpty(apiKey.ApiKey))); + } + + [Theory, BitAutoData] + public async Task Run_CreatesOrganizationUserAsOwnerWithAllPermissions(User user) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var mockSubscription = new Subscription + { + Id = "sub_123", + Items = new StripeList + { + Data = new List + { + new SubscriptionItem + { + Id = "si_premium", + Price = new Price { Id = "premium-annually" } + } + } + }, + Metadata = new Dictionary() + }; + + var mockPremiumPlans = CreateTestPremiumPlansList(); + var mockPlan = CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually"); + + _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription); + _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()).Returns(mockSubscription); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(new Customer())); + _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); + _userService.SaveUserAsync(user).Returns(Task.CompletedTask); + + // Act + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress()); + + // Assert + Assert.True(result.IsT0); + + await _organizationUserRepository.Received(1).CreateAsync( + Arg.Is(orgUser => + orgUser.UserId == user.Id && + orgUser.Type == OrganizationUserType.Owner && + orgUser.Status == OrganizationUserStatusType.Confirmed)); } }