diff --git a/src/Api/Billing/Controllers/PreviewInvoiceController.cs b/src/Api/Billing/Controllers/PreviewInvoiceController.cs
index b2e2dcbad9..7e1e6b7da2 100644
--- a/src/Api/Billing/Controllers/PreviewInvoiceController.cs
+++ b/src/Api/Billing/Controllers/PreviewInvoiceController.cs
@@ -70,6 +70,13 @@ public class PreviewInvoiceController(
planType,
billingAddress);
- return Handle(result.Map(pair => new { pair.Tax, pair.Total, pair.Credit }));
+ return Handle(result.Map(proration => new
+ {
+ NewPlanProratedTotal = proration.NewPlanProratedAmount,
+ proration.Credit,
+ proration.Tax,
+ proration.Total,
+ proration.NewPlanProratedMonths
+ }));
}
}
diff --git a/src/Core/Billing/Premium/Commands/PreviewPremiumUpgradeProrationCommand.cs b/src/Core/Billing/Premium/Commands/PreviewPremiumUpgradeProrationCommand.cs
index 4c864677df..af2a8bdacb 100644
--- a/src/Core/Billing/Premium/Commands/PreviewPremiumUpgradeProrationCommand.cs
+++ b/src/Core/Billing/Premium/Commands/PreviewPremiumUpgradeProrationCommand.cs
@@ -2,6 +2,7 @@
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;
@@ -22,8 +23,8 @@ public interface IPreviewPremiumUpgradeProrationCommand
/// The user with an active Premium subscription.
/// The target organization plan type.
/// The billing address for tax calculation.
- /// A tuple containing the tax amount, total cost, and proration credit from unused Premium time.
- Task> Run(
+ /// The proration details for the upgrade including costs, credits, tax, and time remaining.
+ Task> Run(
User user,
PlanType targetPlanType,
BillingAddress billingAddress);
@@ -36,19 +37,16 @@ public class PreviewPremiumUpgradeProrationCommand(
: BaseBillingCommand(logger),
IPreviewPremiumUpgradeProrationCommand
{
- public Task> Run(
+ public Task> Run(
User user,
PlanType targetPlanType,
- BillingAddress billingAddress) => HandleAsync<(decimal, decimal, decimal)>(async () =>
+ 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.");
}
- // Hardcode seats to 1 for upgrade flow
- const int seats = 1;
-
var currentSubscription = await stripeAdapter.GetSubscriptionAsync(
user.GatewaySubscriptionId,
new SubscriptionGetOptions { Expand = ["customer"] });
@@ -63,11 +61,7 @@ public class PreviewPremiumUpgradeProrationCommand(
var usersPremiumPlan = premiumPlans.First(p => p.Seat.StripePriceId == passwordManagerItem.Price.Id);
var targetPlan = await pricingClient.GetPlanOrThrow(targetPlanType);
- var subscriptionItems = new List
- {
- // Delete the user's specific password manager item
- new() { Id = passwordManagerItem.Id, Deleted = true }
- };
+ var subscriptionItems = new List();
var storageItem = currentSubscription.Items.Data.FirstOrDefault(i =>
i.Price.Id == usersPremiumPlan.Storage.StripePriceId);
@@ -81,10 +75,12 @@ public class PreviewPremiumUpgradeProrationCommand(
});
}
+ // Hardcode seats to 1 for upgrade flow
if (targetPlan.HasNonSeatBasedPasswordManagerPlan())
{
subscriptionItems.Add(new InvoiceSubscriptionDetailsItemOptions
{
+ Id = passwordManagerItem.Id,
Price = targetPlan.PasswordManager.StripePlanId,
Quantity = 1
});
@@ -93,8 +89,9 @@ public class PreviewPremiumUpgradeProrationCommand(
{
subscriptionItems.Add(new InvoiceSubscriptionDetailsItemOptions
{
+ Id = passwordManagerItem.Id,
Price = targetPlan.PasswordManager.StripeSeatPlanId,
- Quantity = seats
+ Quantity = 1
});
}
@@ -114,21 +111,26 @@ public class PreviewPremiumUpgradeProrationCommand(
SubscriptionDetails = new InvoiceSubscriptionDetailsOptions
{
Items = subscriptionItems,
- ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations
+ ProrationBehavior = StripeConstants.ProrationBehavior.AlwaysInvoice
}
};
var invoicePreview = await stripeAdapter.CreateInvoicePreviewAsync(options);
- var amounts = GetAmounts(invoicePreview);
+ var proration = GetProration(invoicePreview, passwordManagerItem);
- return amounts;
+ return proration;
});
- private static (decimal, decimal, decimal) GetAmounts(Invoice invoicePreview) => (
- Convert.ToDecimal(invoicePreview.TotalTaxes.Sum(invoiceTotalTax => invoiceTotalTax.Amount)) / 100,
- Convert.ToDecimal(invoicePreview.Total) / 100,
- GetProrationCreditFromInvoice(invoicePreview));
-
+ 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)
{
@@ -139,4 +141,26 @@ public class PreviewPremiumUpgradeProrationCommand(
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 d3e2eb899f..5a879d3d8e 100644
--- a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs
+++ b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs
@@ -88,13 +88,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);
@@ -116,6 +109,7 @@ public class UpgradePremiumToOrganizationCommand(
{
subscriptionItemOptions.Add(new SubscriptionItemOptions
{
+ Id = passwordManagerItem.Id,
Price = targetPlan.PasswordManager.StripePlanId,
Quantity = 1
});
@@ -124,6 +118,7 @@ public class UpgradePremiumToOrganizationCommand(
{
subscriptionItemOptions.Add(new SubscriptionItemOptions
{
+ Id = passwordManagerItem.Id,
Price = targetPlan.PasswordManager.StripeSeatPlanId,
Quantity = seats
});
@@ -136,7 +131,8 @@ 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
{
@@ -149,11 +145,6 @@ public class UpgradePremiumToOrganizationCommand(
}
};
- if (targetPlan.TrialPeriodDays.HasValue)
- {
- subscriptionUpdateOptions.TrialEnd = DateTime.UtcNow.AddDays((double)targetPlan.TrialPeriodDays);
- }
-
// Create the Organization entity
var organization = new Organization
{
@@ -161,7 +152,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,
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/Core.Test/Billing/Premium/Commands/PreviewPremiumUpgradeProrationCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/PreviewPremiumUpgradeProrationCommandTests.cs
index 475c458361..c2af07f633 100644
--- a/test/Core.Test/Billing/Premium/Commands/PreviewPremiumUpgradeProrationCommandTests.cs
+++ b/test/Core.Test/Billing/Premium/Commands/PreviewPremiumUpgradeProrationCommandTests.cs
@@ -91,6 +91,8 @@ public class PreviewPremiumUpgradeProrationCommandTests
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",
@@ -106,7 +108,8 @@ public class PreviewPremiumUpgradeProrationCommandTests
new()
{
Id = "si_premium",
- Price = new Price { Id = "premium-annually" }
+ Price = new Price { Id = "premium-annually" },
+ CurrentPeriodEnd = currentPeriodEnd
}
}
}
@@ -122,7 +125,15 @@ public class PreviewPremiumUpgradeProrationCommandTests
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
@@ -140,10 +151,12 @@ public class PreviewPremiumUpgradeProrationCommandTests
// Assert
Assert.True(result.IsT0);
- var (tax, total, credit) = result.AsT0;
- Assert.Equal(5.00m, tax);
- Assert.Equal(50.00m, total);
- Assert.Equal(0m, credit);
+ 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]
@@ -174,6 +187,9 @@ public class PreviewPremiumUpgradeProrationCommandTests
};
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",
@@ -182,7 +198,7 @@ public class PreviewPremiumUpgradeProrationCommandTests
{
Data = new List
{
- new() { Id = "si_premium", Price = new Price { Id = "premium-annually" } }
+ new() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = currentPeriodEnd }
}
}
};
@@ -201,7 +217,8 @@ public class PreviewPremiumUpgradeProrationCommandTests
new() { Amount = -1000 }, // -$10.00 credit from unused Premium
new() { Amount = 5000 } // $50.00 for new plan
}
- }
+ },
+ PeriodEnd = now
};
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
@@ -216,10 +233,12 @@ public class PreviewPremiumUpgradeProrationCommandTests
// Assert
Assert.True(result.IsT0);
- var (tax, total, credit) = result.AsT0;
- Assert.Equal(4.00m, tax);
- Assert.Equal(40.00m, total);
- Assert.Equal(10.00m, credit); // Proration credit
+ 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]
@@ -258,7 +277,7 @@ public class PreviewPremiumUpgradeProrationCommandTests
{
Data = new List
{
- new() { Id = "si_premium", Price = new Price { Id = "premium-annually" } }
+ new() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) }
}
}
};
@@ -268,7 +287,9 @@ public class PreviewPremiumUpgradeProrationCommandTests
var invoice = new Invoice
{
Total = 5000,
- TotalTaxes = new List { new() { Amount = 500 } }
+ TotalTaxes = new List { new() { Amount = 500 } },
+ Lines = new StripeList { Data = new List { new() { Amount = 5000 } } },
+ PeriodEnd = DateTime.UtcNow
};
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
@@ -281,10 +302,11 @@ public class PreviewPremiumUpgradeProrationCommandTests
// Act
await _command.Run(user, PlanType.TeamsAnnually, billingAddress);
- // Assert - Verify that the subscription item quantity is always 1
+ // 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)));
}
@@ -325,8 +347,8 @@ public class PreviewPremiumUpgradeProrationCommandTests
{
Data = new List
{
- new() { Id = "si_password_manager", Price = new Price { Id = "premium-annually" } },
- new() { Id = "si_storage", Price = new Price { Id = "storage-gb-annually" } }
+ 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) }
}
}
};
@@ -336,7 +358,9 @@ public class PreviewPremiumUpgradeProrationCommandTests
var invoice = new Invoice
{
Total = 5000,
- TotalTaxes = new List { new() { Amount = 500 } }
+ TotalTaxes = new List { new() { Amount = 500 } },
+ Lines = new StripeList { Data = new List { new() { Amount = 5000 } } },
+ PeriodEnd = DateTime.UtcNow
};
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
@@ -349,11 +373,15 @@ public class PreviewPremiumUpgradeProrationCommandTests
// Act
await _command.Run(user, PlanType.TeamsAnnually, billingAddress);
- // Assert - Verify both password manager and storage items are marked as deleted
+ // 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.Deleted == true) &&
+ 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)));
}
@@ -394,7 +422,7 @@ public class PreviewPremiumUpgradeProrationCommandTests
{
Data = new List
{
- new() { Id = "si_premium", Price = new Price { Id = "premium-annually" } }
+ new() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) }
}
}
};
@@ -404,7 +432,9 @@ public class PreviewPremiumUpgradeProrationCommandTests
var invoice = new Invoice
{
Total = 5000,
- TotalTaxes = new List { new() { Amount = 500 } }
+ TotalTaxes = new List { new() { Amount = 500 } },
+ Lines = new StripeList { Data = new List { new() { Amount = 5000 } } },
+ PeriodEnd = DateTime.UtcNow
};
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
@@ -417,10 +447,11 @@ public class PreviewPremiumUpgradeProrationCommandTests
// Act
await _command.Run(user, PlanType.FamiliesAnnually, billingAddress);
- // Assert - Verify non-seat-based plan uses StripePlanId with quantity 1
+ // 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)));
}
@@ -463,7 +494,7 @@ public class PreviewPremiumUpgradeProrationCommandTests
{
Data = new List
{
- new() { Id = "si_premium", Price = new Price { Id = "premium-annually" } }
+ new() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) }
}
}
};
@@ -473,7 +504,9 @@ public class PreviewPremiumUpgradeProrationCommandTests
var invoice = new Invoice
{
Total = 5000,
- TotalTaxes = new List { new() { Amount = 500 } }
+ TotalTaxes = new List { new() { Amount = 500 } },
+ Lines = new StripeList { Data = new List { new() { Amount = 5000 } } },
+ PeriodEnd = DateTime.UtcNow
};
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
@@ -494,7 +527,7 @@ public class PreviewPremiumUpgradeProrationCommandTests
options.Subscription == "sub_123" &&
options.CustomerDetails.Address.Country == "US" &&
options.CustomerDetails.Address.PostalCode == "12345" &&
- options.SubscriptionDetails.ProrationBehavior == "create_prorations"));
+ options.SubscriptionDetails.ProrationBehavior == "always_invoice"));
}
[Theory, BitAutoData]
@@ -533,7 +566,7 @@ public class PreviewPremiumUpgradeProrationCommandTests
{
Data = new List
{
- new() { Id = "si_premium", Price = new Price { Id = "premium-annually" } }
+ new() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) }
}
}
};
@@ -544,7 +577,9 @@ public class PreviewPremiumUpgradeProrationCommandTests
var invoice = new Invoice
{
Total = 5000,
- TotalTaxes = new List { new() { Amount = 500 } }
+ TotalTaxes = new List { new() { Amount = 500 } },
+ Lines = new StripeList { Data = new List { new() { Amount = 5000 } } },
+ PeriodEnd = DateTime.UtcNow
};
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
@@ -557,11 +592,186 @@ public class PreviewPremiumUpgradeProrationCommandTests
// Act
await _command.Run(user, PlanType.TeamsAnnually, billingAddress);
- // Assert - Verify seat-based plan uses StripeSeatPlanId with quantity 1
+ // 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 702e449746..932c486e74 100644
--- a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs
+++ b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs
@@ -256,9 +256,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" &&
@@ -331,9 +330,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"));
@@ -461,14 +459,13 @@ public class UpgradePremiumToOrganizationCommandTests
// 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]
@@ -528,15 +525,14 @@ public class UpgradePremiumToOrganizationCommandTests
// 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]
@@ -603,8 +599,8 @@ public class UpgradePremiumToOrganizationCommandTests
Arg.Is(opts =>
opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousAdditionalStorage) &&
opts.Metadata[StripeConstants.MetadataKeys.PreviousAdditionalStorage] == "5" &&
- 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]
@@ -647,77 +643,6 @@ public class UpgradePremiumToOrganizationCommandTests
Assert.Equal("Premium subscription password manager item not found.", badRequest.Response);
}
- [Theory, BitAutoData]
- public async Task Run_PlanWithTrialPeriod_SetsTrialEnd(User user)
- {
- // Arrange
- user.Premium = true;
- user.GatewaySubscriptionId = "sub_123";
- user.GatewayCustomerId = "cus_123";
-
- var currentPeriodEnd = DateTime.UtcNow.AddMonths(1);
- var mockSubscription = new Subscription
- {
- Id = "sub_123",
- Items = new StripeList
- {
- Data = new List
- {
- new SubscriptionItem
- {
- Id = "si_premium",
- Price = new Price { Id = "premium-annually" },
- CurrentPeriodEnd = currentPeriodEnd
- }
- }
- },
- Metadata = new Dictionary()
- };
-
- var mockPremiumPlans = CreateTestPremiumPlansList();
-
- // Create a plan with a trial period
- var mockPlan = CreateTestPlan(
- PlanType.TeamsAnnually,
- stripeSeatPlanId: "teams-seat-annually",
- trialPeriodDays: 7
- );
-
- // Capture the subscription update options to verify TrialEnd is set
- SubscriptionUpdateOptions capturedOptions = null;
-
- _stripeAdapter.GetSubscriptionAsync("sub_123")
- .Returns(mockSubscription);
- _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans);
- _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan);
- _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Do(opts => capturedOptions = opts))
- .Returns(Task.FromResult(mockSubscription));
- _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 testStartTime = DateTime.UtcNow;
- var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress());
- var testEndTime = DateTime.UtcNow;
-
- // Assert
- Assert.True(result.IsT0);
-
- await _stripeAdapter.Received(1).UpdateSubscriptionAsync("sub_123", Arg.Any());
-
- Assert.NotNull(capturedOptions);
- Assert.NotNull(capturedOptions.TrialEnd);
-
- // TrialEnd is AnyOf - verify it's a DateTime
- var trialEndDateTime = capturedOptions.TrialEnd.Value as DateTime?;
- Assert.NotNull(trialEndDateTime);
- Assert.True(trialEndDateTime.Value >= testStartTime.AddDays(7));
- Assert.True(trialEndDateTime.Value <= testEndTime.AddDays(7));
- }
-
[Theory, BitAutoData]
public async Task Run_UpdatesCustomerBillingAddress(User user)
{
@@ -823,4 +748,272 @@ public class UpgradePremiumToOrganizationCommandTests
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));
+ }
}