1
0
mirror of https://github.com/bitwarden/server synced 2026-02-07 20:23:49 +00:00

[PM-29599] create proration preview endpoint

This commit is contained in:
Kyle Denney
2026-01-15 18:28:18 -06:00
parent ebb0712e33
commit 75c49b9b58
9 changed files with 866 additions and 28 deletions

View File

@@ -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<IResult> 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,21 @@ public class TaxController(
{
var (purchase, billingAddress) = request.ToDomain();
var result = await previewPremiumTaxCommand.Run(purchase, billingAddress);
return Handle(result.Map(pair => new
{
pair.Tax,
pair.Total
}));
return Handle(result.Map(pair => new { pair.Tax, pair.Total }));
}
[HttpPost("premium/subscriptions/upgrade")]
public async Task<IResult> PreviewPremiumUpgradeProrationAsync(
[BindNever] User user,
[FromBody] PreviewPremiumUpgradeProrationRequest request)
{
var (tierType, billingAddress) = request.ToDomain();
var result = await previewPremiumUpgradeProrationCommand.Run(
user,
tierType,
billingAddress);
return Handle(result.Map(pair => new { pair.Tax, pair.Total, pair.Credit }));
}
}

View File

@@ -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
{

View File

@@ -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
{

View File

@@ -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
{

View File

@@ -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
{

View File

@@ -0,0 +1,18 @@
using System.ComponentModel.DataAnnotations;
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]
public required ProductTierType TargetProductTierType { get; set; }
[Required]
public required MinimalBillingAddressRequest BillingAddress { get; set; }
public (ProductTierType, BillingAddress) ToDomain() =>
(TargetProductTierType, BillingAddress.ToDomain());
}

View File

@@ -59,6 +59,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<ICreatePremiumCloudHostedSubscriptionCommand, CreatePremiumCloudHostedSubscriptionCommand>();
services.AddScoped<ICreatePremiumSelfHostedSubscriptionCommand, CreatePremiumSelfHostedSubscriptionCommand>();
services.AddTransient<IPreviewPremiumTaxCommand, PreviewPremiumTaxCommand>();
services.AddScoped<IPreviewPremiumUpgradeProrationCommand, PreviewPremiumUpgradeProrationCommand>();
services.AddScoped<IUpdatePremiumStorageCommand, UpdatePremiumStorageCommand>();
services.AddScoped<IUpgradePremiumToOrganizationCommand, UpgradePremiumToOrganizationCommand>();
}

View File

@@ -0,0 +1,157 @@
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.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Microsoft.Extensions.Logging;
using Stripe;
namespace Bit.Core.Billing.Premium.Commands;
/// <summary>
/// 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.
/// </summary>
public interface IPreviewPremiumUpgradeProrationCommand
{
/// <summary>
/// Calculates the tax, total cost, and proration credit for upgrading a Premium subscription to an Organization plan.
/// </summary>
/// <param name="user">The user with an active Premium subscription.</param>
/// <param name="targetProductTierType">The target organization tier (Families, Teams, or Enterprise).</param>
/// <param name="billingAddress">The billing address for tax calculation.</param>
/// <returns>A tuple containing the tax amount, total cost, and proration credit from unused Premium time.</returns>
Task<BillingCommandResult<(decimal Tax, decimal Total, decimal Credit)>> Run(
User user,
ProductTierType targetProductTierType,
BillingAddress billingAddress);
}
public class PreviewPremiumUpgradeProrationCommand(
ILogger<PreviewPremiumUpgradeProrationCommand> logger,
IPricingClient pricingClient,
IStripeAdapter stripeAdapter)
: BaseBillingCommand<PreviewPremiumUpgradeProrationCommand>(logger),
IPreviewPremiumUpgradeProrationCommand
{
public Task<BillingCommandResult<(decimal Tax, decimal Total, decimal Credit)>> Run(
User user,
ProductTierType targetProductTierType,
BillingAddress billingAddress) => HandleAsync<(decimal, decimal, decimal)>(async () =>
{
if (user is not { Premium: true, GatewaySubscriptionId: not null and not "" })
{
return new BadRequest("User does not have an active Premium subscription.");
}
if (targetProductTierType is not (ProductTierType.Families or ProductTierType.Teams or ProductTierType.Enterprise))
{
return new BadRequest($"Cannot upgrade Premium subscription to {targetProductTierType} plan.");
}
// Convert ProductTierType to PlanType (for premium upgrade, the only choice is annual plans so we can assume that cadence)
var targetPlanType = targetProductTierType switch
{
ProductTierType.Families => PlanType.FamiliesAnnually,
ProductTierType.Teams => PlanType.TeamsAnnually,
ProductTierType.Enterprise => PlanType.EnterpriseAnnually,
_ => throw new InvalidOperationException($"Unexpected ProductTierType: {targetProductTierType}")
};
// Hardcode seats to 1 for upgrade flow
const int seats = 1;
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<InvoiceSubscriptionDetailsItemOptions>
{
// Delete the user's specific password manager item
new() { Id = passwordManagerItem.Id, Deleted = true }
};
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
});
}
if (targetPlan.HasNonSeatBasedPasswordManagerPlan())
{
subscriptionItems.Add(new InvoiceSubscriptionDetailsItemOptions
{
Price = targetPlan.PasswordManager.StripePlanId,
Quantity = 1
});
}
else
{
subscriptionItems.Add(new InvoiceSubscriptionDetailsItemOptions
{
Price = targetPlan.PasswordManager.StripeSeatPlanId,
Quantity = seats
});
}
var options = new InvoiceCreatePreviewOptions
{
AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true },
Currency = "usd",
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 amounts = GetAmounts(invoicePreview);
return amounts;
});
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 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;
}
}

View File

@@ -0,0 +1,661 @@
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<PreviewPremiumUpgradeProrationCommand> _logger = Substitute.For<ILogger<PreviewPremiumUpgradeProrationCommand>>();
private readonly IPricingClient _pricingClient = Substitute.For<IPricingClient>();
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
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, ProductTierType.Teams, 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, ProductTierType.Teams, 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> { premiumPlan };
// Setup current Stripe subscription
var currentSubscription = new Subscription
{
Id = "sub_123",
Customer = new Customer
{
Id = "cus_123",
Discount = null
},
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new()
{
Id = "si_premium",
Price = new Price { Id = "premium-annually" }
}
}
}
};
// 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<InvoiceTotalTax>
{
new() { Amount = 500 } // $5.00
}
};
// Configure mocks
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(targetPlan);
_stripeAdapter.GetSubscriptionAsync(
"sub_123",
Arg.Any<SubscriptionGetOptions>())
.Returns(currentSubscription);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(invoice);
// Act
var result = await _command.Run(user, ProductTierType.Teams, billingAddress);
// 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);
}
[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> { premiumPlan };
var currentSubscription = new Subscription
{
Id = "sub_123",
Customer = new Customer { Id = "cus_123", Discount = null },
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new() { Id = "si_premium", Price = new Price { Id = "premium-annually" } }
}
}
};
var targetPlan = new TeamsPlan(isAnnual: true);
// Invoice with negative line item (proration credit)
var invoice = new Invoice
{
Total = 4000, // $40.00
TotalTaxes = new List<InvoiceTotalTax> { new() { Amount = 400 } }, // $4.00
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem>
{
new() { Amount = -1000 }, // -$10.00 credit from unused Premium
new() { Amount = 5000 } // $50.00 for new plan
}
}
};
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(targetPlan);
_stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any<SubscriptionGetOptions>())
.Returns(currentSubscription);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(invoice);
// Act
var result = await _command.Run(user, ProductTierType.Teams, billingAddress);
// 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
}
[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> { premiumPlan };
var currentSubscription = new Subscription
{
Id = "sub_123",
Customer = new Customer { Id = "cus_123", Discount = null },
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new() { Id = "si_premium", Price = new Price { Id = "premium-annually" } }
}
}
};
var targetPlan = new TeamsPlan(isAnnual: true);
var invoice = new Invoice
{
Total = 5000,
TotalTaxes = new List<InvoiceTotalTax> { new() { Amount = 500 } }
};
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(targetPlan);
_stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any<SubscriptionGetOptions>())
.Returns(currentSubscription);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(invoice);
// Act
await _command.Run(user, ProductTierType.Teams, billingAddress);
// Assert - Verify that the subscription item quantity is always 1
await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(
Arg.Is<InvoiceCreatePreviewOptions>(options =>
options.SubscriptionDetails.Items.Any(item =>
item.Price == targetPlan.PasswordManager.StripeSeatPlanId &&
item.Quantity == 1)));
}
[Theory]
[InlineData(ProductTierType.Families, PlanType.FamiliesAnnually)]
[InlineData(ProductTierType.Teams, PlanType.TeamsAnnually)]
[InlineData(ProductTierType.Enterprise, PlanType.EnterpriseAnnually)]
public async Task Run_ProductTierTypeConversion_MapsToCorrectPlanType(
ProductTierType productTierType,
PlanType expectedPlanType)
{
// Arrange
var user = new User
{
Premium = true,
GatewaySubscriptionId = "sub_123",
GatewayCustomerId = "cus_123"
};
var billingAddress = new 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> { premiumPlan };
var currentSubscription = new Subscription
{
Id = "sub_123",
Customer = new Customer { Id = "cus_123", Discount = null },
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new() { Id = "si_premium", Price = new Price { Id = "premium-annually" } }
}
}
};
var targetPlan = new TeamsPlan(isAnnual: true);
var invoice = new Invoice
{
Total = 5000,
TotalTaxes = new List<InvoiceTotalTax> { new() { Amount = 500 } }
};
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_pricingClient.GetPlanOrThrow(expectedPlanType).Returns(targetPlan);
_stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any<SubscriptionGetOptions>())
.Returns(currentSubscription);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(invoice);
// Act
await _command.Run(user, productTierType, billingAddress);
// Assert - Verify that the correct PlanType was used
await _pricingClient.Received(1).GetPlanOrThrow(expectedPlanType);
}
[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> { premiumPlan };
var currentSubscription = new Subscription
{
Id = "sub_123",
Customer = new Customer { Id = "cus_123", Discount = null },
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new() { Id = "si_password_manager", Price = new Price { Id = "premium-annually" } },
new() { Id = "si_storage", Price = new Price { Id = "storage-gb-annually" } }
}
}
};
var targetPlan = new TeamsPlan(isAnnual: true);
var invoice = new Invoice
{
Total = 5000,
TotalTaxes = new List<InvoiceTotalTax> { new() { Amount = 500 } }
};
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(targetPlan);
_stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any<SubscriptionGetOptions>())
.Returns(currentSubscription);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(invoice);
// Act
await _command.Run(user, ProductTierType.Teams, billingAddress);
// Assert - Verify both password manager and storage items are marked as deleted
await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(
Arg.Is<InvoiceCreatePreviewOptions>(options =>
options.SubscriptionDetails.Items.Any(item =>
item.Id == "si_password_manager" && item.Deleted == true) &&
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> { premiumPlan };
var currentSubscription = new Subscription
{
Id = "sub_123",
Customer = new Customer { Id = "cus_123", Discount = null },
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new() { Id = "si_premium", Price = new Price { Id = "premium-annually" } }
}
}
};
var targetPlan = new FamiliesPlan(); // families is non seat based
var invoice = new Invoice
{
Total = 5000,
TotalTaxes = new List<InvoiceTotalTax> { new() { Amount = 500 } }
};
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(targetPlan);
_stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any<SubscriptionGetOptions>())
.Returns(currentSubscription);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(invoice);
// Act
await _command.Run(user, ProductTierType.Families, billingAddress);
// Assert - Verify non-seat-based plan uses StripePlanId with quantity 1
await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(
Arg.Is<InvoiceCreatePreviewOptions>(options =>
options.SubscriptionDetails.Items.Any(item =>
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> { premiumPlan };
var currentSubscription = new Subscription
{
Id = "sub_123",
Customer = new Customer { Id = "cus_123", Discount = null },
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new() { Id = "si_premium", Price = new Price { Id = "premium-annually" } }
}
}
};
var targetPlan = new TeamsPlan(isAnnual: true);
var invoice = new Invoice
{
Total = 5000,
TotalTaxes = new List<InvoiceTotalTax> { new() { Amount = 500 } }
};
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(targetPlan);
_stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any<SubscriptionGetOptions>())
.Returns(currentSubscription);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(invoice);
// Act
await _command.Run(user, ProductTierType.Teams, billingAddress);
// Assert - Verify all invoice preview options are correct
await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(
Arg.Is<InvoiceCreatePreviewOptions>(options =>
options.AutomaticTax.Enabled == true &&
options.Currency == "usd" &&
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_TeamsStarterTierType_ReturnsBadRequest(User user, BillingAddress billingAddress)
{
// Arrange
user.Premium = true;
user.GatewaySubscriptionId = "sub_123";
user.GatewayCustomerId = "cus_123";
// Act
var result = await _command.Run(user, ProductTierType.TeamsStarter, billingAddress);
// Assert
Assert.True(result.IsT1);
var badRequest = result.AsT1;
Assert.Equal("Cannot upgrade Premium subscription to TeamsStarter plan.", badRequest.Response);
}
[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> { premiumPlan };
var currentSubscription = new Subscription
{
Id = "sub_123",
Customer = new Customer { Id = "cus_123", Discount = null },
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new() { Id = "si_premium", Price = new Price { Id = "premium-annually" } }
}
}
};
// Use Teams which is seat-based
var targetPlan = new TeamsPlan(isAnnual: true);
var invoice = new Invoice
{
Total = 5000,
TotalTaxes = new List<InvoiceTotalTax> { new() { Amount = 500 } }
};
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(targetPlan);
_stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any<SubscriptionGetOptions>())
.Returns(currentSubscription);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(invoice);
// Act
await _command.Run(user, ProductTierType.Teams, billingAddress);
// Assert - Verify seat-based plan uses StripeSeatPlanId with quantity 1
await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(
Arg.Is<InvoiceCreatePreviewOptions>(options =>
options.SubscriptionDetails.Items.Any(item =>
item.Price == targetPlan.PasswordManager.StripeSeatPlanId &&
item.Quantity == 1)));
}
}