mirror of
https://github.com/bitwarden/server.git
synced 2026-02-04 02:05:30 -06:00
Merge branch 'main' into auth/pm-27084/register-accepts-new-data-types-repush
This commit is contained in:
commit
d9b53c4f55
@ -86,7 +86,7 @@ public class UsersController : Controller
|
||||
return RedirectToAction("Index");
|
||||
}
|
||||
|
||||
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id);
|
||||
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id, withOrganizations: false);
|
||||
|
||||
var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
|
||||
var verifiedDomain = await _userService.IsClaimedByAnyOrganizationAsync(user.Id);
|
||||
|
||||
@ -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,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<IResult> PreviewPremiumUpgradeProrationAsync(
|
||||
[BindNever] User user,
|
||||
[FromBody] PreviewPremiumUpgradeProrationRequest request)
|
||||
{
|
||||
pair.Tax,
|
||||
pair.Total
|
||||
var (planType, billingAddress) = request.ToDomain();
|
||||
|
||||
var result = await previewPremiumUpgradeProrationCommand.Run(
|
||||
user,
|
||||
planType,
|
||||
billingAddress);
|
||||
|
||||
return Handle(result.Map(proration => new
|
||||
{
|
||||
proration.NewPlanProratedAmount,
|
||||
proration.Credit,
|
||||
proration.Tax,
|
||||
proration.Total,
|
||||
proration.NewPlanProratedMonths
|
||||
}));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
{
|
||||
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 => 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.")
|
||||
ProductTierType.Teams => PlanType.TeamsAnnually,
|
||||
ProductTierType.Enterprise => PlanType.EnterpriseAnnually,
|
||||
_ => throw new InvalidOperationException($"Unexpected ProductTierType: {TargetProductTierType}")
|
||||
};
|
||||
|
||||
public (string OrganizationName, string Key, PlanType PlanType) ToDomain() => (OrganizationName, Key, PlanType);
|
||||
}
|
||||
}
|
||||
|
||||
public (string OrganizationName, string Key, PlanType PlanType, Core.Billing.Payment.Models.BillingAddress BillingAddress) ToDomain() =>
|
||||
(OrganizationName, Key, PlanType, BillingAddress.ToDomain());
|
||||
}
|
||||
|
||||
@ -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
|
||||
{
|
||||
@ -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
|
||||
{
|
||||
@ -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
|
||||
{
|
||||
@ -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
|
||||
{
|
||||
@ -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());
|
||||
}
|
||||
@ -74,11 +74,6 @@ public class ImportCiphersController : Controller
|
||||
throw new BadRequestException("You cannot import this much data at once.");
|
||||
}
|
||||
|
||||
if (model.Ciphers.Any(c => c.ArchivedDate.HasValue))
|
||||
{
|
||||
throw new BadRequestException("You cannot import archived items into an organization.");
|
||||
}
|
||||
|
||||
var orgId = new Guid(organizationId);
|
||||
var collections = model.Collections.Select(c => c.ToCollection(orgId)).ToList();
|
||||
|
||||
|
||||
@ -147,16 +147,12 @@ public class WebAuthnTokenProvider : IUserTwoFactorTokenProvider<User>
|
||||
return keys;
|
||||
}
|
||||
|
||||
// Support up to 5 keys
|
||||
for (var i = 1; i <= 5; i++)
|
||||
// Load all WebAuthn credentials stored in metadata. The number of allowed credentials
|
||||
// is controlled by credential registration.
|
||||
foreach (var kvp in provider.MetaData.Where(k => k.Key.StartsWith("Key")))
|
||||
{
|
||||
var keyName = $"Key{i}";
|
||||
if (provider.MetaData.TryGetValue(keyName, out var value))
|
||||
{
|
||||
var key = new TwoFactorProvider.WebAuthnData((dynamic)value);
|
||||
|
||||
keys.Add(new Tuple<string, TwoFactorProvider.WebAuthnData>(keyName, key));
|
||||
}
|
||||
var key = new TwoFactorProvider.WebAuthnData((dynamic)kvp.Value);
|
||||
keys.Add(new Tuple<string, TwoFactorProvider.WebAuthnData>(kvp.Key, key));
|
||||
}
|
||||
|
||||
return keys;
|
||||
|
||||
@ -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>();
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
/// <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="targetPlanType">The target organization plan type.</param>
|
||||
/// <param name="billingAddress">The billing address for tax calculation.</param>
|
||||
/// <returns>The proration details for the upgrade including costs, credits, tax, and time remaining.</returns>
|
||||
Task<BillingCommandResult<PremiumUpgradeProration>> Run(
|
||||
User user,
|
||||
PlanType targetPlanType,
|
||||
BillingAddress billingAddress);
|
||||
}
|
||||
|
||||
public class PreviewPremiumUpgradeProrationCommand(
|
||||
ILogger<PreviewPremiumUpgradeProrationCommand> logger,
|
||||
IPricingClient pricingClient,
|
||||
IStripeAdapter stripeAdapter)
|
||||
: BaseBillingCommand<PreviewPremiumUpgradeProrationCommand>(logger),
|
||||
IPreviewPremiumUpgradeProrationCommand
|
||||
{
|
||||
public Task<BillingCommandResult<PremiumUpgradeProration>> Run(
|
||||
User user,
|
||||
PlanType targetPlanType,
|
||||
BillingAddress billingAddress) => HandleAsync<PremiumUpgradeProration>(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<InvoiceSubscriptionDetailsItemOptions>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -27,12 +27,14 @@ public interface IUpgradePremiumToOrganizationCommand
|
||||
/// <param name="organizationName">The name for the new organization.</param>
|
||||
/// <param name="key">The encrypted organization key for the owner.</param>
|
||||
/// <param name="targetPlanType">The target organization plan type to upgrade to.</param>
|
||||
/// <param name="billingAddress">The billing address for tax calculation.</param>
|
||||
/// <returns>A billing command result indicating success or failure with appropriate error details.</returns>
|
||||
Task<BillingCommandResult<None>> 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<None>(async () =>
|
||||
PlanType targetPlanType,
|
||||
Payment.Models.BillingAddress billingAddress) => HandleAsync<None>(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<SubscriptionItemOptions>();
|
||||
|
||||
// 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<string, string>
|
||||
{
|
||||
[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);
|
||||
|
||||
|
||||
36
src/Core/Billing/Premium/Models/PremiumUpgradeProration.cs
Normal file
36
src/Core/Billing/Premium/Models/PremiumUpgradeProration.cs
Normal file
@ -0,0 +1,36 @@
|
||||
namespace Bit.Core.Billing.Premium.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the proration details for upgrading a Premium user subscription to an Organization plan.
|
||||
/// </summary>
|
||||
public class PremiumUpgradeProration
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public decimal NewPlanProratedAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The credit amount for the unused portion of the current Premium subscription.
|
||||
/// This credit is applied against the cost of the new organization plan.
|
||||
/// </summary>
|
||||
public decimal Credit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The tax amount calculated for the upgrade transaction.
|
||||
/// </summary>
|
||||
public decimal Tax { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The total amount due for the upgrade after applying the credit and adding tax.
|
||||
/// </summary>
|
||||
public decimal Total { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public int NewPlanProratedMonths { get; set; }
|
||||
}
|
||||
@ -76,6 +76,12 @@ public class ImportCiphersCommand : IImportCiphersCommand
|
||||
{
|
||||
cipher.Favorites = $"{{\"{cipher.UserId.ToString().ToUpperInvariant()}\":true}}";
|
||||
}
|
||||
|
||||
if (cipher.UserId.HasValue && cipher.ArchivedDate.HasValue)
|
||||
{
|
||||
cipher.Archives = $"{{\"{cipher.UserId.Value.ToString().ToUpperInvariant()}\":\"" +
|
||||
$"{cipher.ArchivedDate.Value:yyyy-MM-ddTHH:mm:ss.fffffffZ}\"}}";
|
||||
}
|
||||
}
|
||||
|
||||
var userfoldersIds = (await _folderRepository.GetManyByUserIdAsync(importingUserId)).Select(f => f.Id).ToList();
|
||||
@ -135,10 +141,16 @@ public class ImportCiphersCommand : IImportCiphersCommand
|
||||
}
|
||||
}
|
||||
|
||||
// Init. ids for ciphers
|
||||
foreach (var cipher in ciphers)
|
||||
{
|
||||
// Init. ids for ciphers
|
||||
cipher.SetNewId();
|
||||
|
||||
if (cipher.ArchivedDate.HasValue)
|
||||
{
|
||||
cipher.Archives = $"{{\"{importingUserId.ToString().ToUpperInvariant()}\":\"" +
|
||||
$"{cipher.ArchivedDate.Value:yyyy-MM-ddTHH:mm:ss.fffffffZ}\"}}";
|
||||
}
|
||||
}
|
||||
|
||||
var organizationCollectionsIds = (await _collectionRepository.GetManyByOrganizationIdAsync(org.Id)).Select(c => c.Id).ToList();
|
||||
|
||||
@ -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<InvalidOperationException>(() => sut.ToDomain());
|
||||
Assert.Contains($"Cannot upgrade Premium subscription to {tierType} plan", exception.Message);
|
||||
}
|
||||
}
|
||||
@ -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<InvalidOperationException>(() => sut.ToDomain());
|
||||
Assert.Contains($"Cannot upgrade Premium subscription to {tierType} plan", exception.Message);
|
||||
}
|
||||
}
|
||||
@ -806,63 +806,6 @@ public class ImportCiphersControllerTests
|
||||
Arg.Any<Guid>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task PostImportOrganization_ThrowsException_WhenAnyCipherIsArchived(
|
||||
SutProvider<ImportCiphersController> sutProvider,
|
||||
IFixture fixture,
|
||||
User user
|
||||
)
|
||||
{
|
||||
var orgId = Guid.NewGuid();
|
||||
|
||||
sutProvider.GetDependency<GlobalSettings>()
|
||||
.SelfHosted = false;
|
||||
sutProvider.GetDependency<GlobalSettings>()
|
||||
.ImportCiphersLimitation = _organizationCiphersLimitations;
|
||||
|
||||
SetupUserService(sutProvider, user);
|
||||
|
||||
var ciphers = fixture.Build<CipherRequestModel>()
|
||||
.With(_ => _.ArchivedDate, DateTime.UtcNow)
|
||||
.CreateMany(2).ToArray();
|
||||
|
||||
var request = new ImportOrganizationCiphersRequestModel
|
||||
{
|
||||
Collections = new List<CollectionWithIdRequestModel>().ToArray(),
|
||||
Ciphers = ciphers,
|
||||
CollectionRelationships = new List<KeyValuePair<int, int>>().ToArray(),
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<ICurrentContext>()
|
||||
.AccessImportExport(Arg.Any<Guid>())
|
||||
.Returns(false);
|
||||
|
||||
sutProvider.GetDependency<IAuthorizationService>()
|
||||
.AuthorizeAsync(Arg.Any<ClaimsPrincipal>(),
|
||||
Arg.Any<IEnumerable<Collection>>(),
|
||||
Arg.Is<IEnumerable<IAuthorizationRequirement>>(reqs =>
|
||||
reqs.Contains(BulkCollectionOperations.ImportCiphers)))
|
||||
.Returns(AuthorizationResult.Failed());
|
||||
|
||||
sutProvider.GetDependency<IAuthorizationService>()
|
||||
.AuthorizeAsync(Arg.Any<ClaimsPrincipal>(),
|
||||
Arg.Any<IEnumerable<Collection>>(),
|
||||
Arg.Is<IEnumerable<IAuthorizationRequirement>>(reqs =>
|
||||
reqs.Contains(BulkCollectionOperations.Create)))
|
||||
.Returns(AuthorizationResult.Success());
|
||||
|
||||
sutProvider.GetDependency<ICollectionRepository>()
|
||||
.GetManyByOrganizationIdAsync(orgId)
|
||||
.Returns(new List<Collection>());
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(async () =>
|
||||
{
|
||||
await sutProvider.Sut.PostImportOrganization(orgId.ToString(), request);
|
||||
});
|
||||
|
||||
Assert.Equal("You cannot import archived items into an organization.", exception.Message);
|
||||
}
|
||||
|
||||
private static void SetupUserService(SutProvider<ImportCiphersController> sutProvider, User user)
|
||||
{
|
||||
// This is a workaround for the NSubstitute issue with ambiguous arguments
|
||||
|
||||
@ -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<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, 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> { 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<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
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<InvoiceTotalTax>
|
||||
{
|
||||
new() { Amount = 500 } // $5.00
|
||||
},
|
||||
Lines = new StripeList<InvoiceLineItem>
|
||||
{
|
||||
Data = new List<InvoiceLineItem>
|
||||
{
|
||||
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<SubscriptionGetOptions>())
|
||||
.Returns(currentSubscription);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.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> { 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<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
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<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
|
||||
}
|
||||
},
|
||||
PeriodEnd = now
|
||||
};
|
||||
|
||||
_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, 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> { 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" }, CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var targetPlan = new TeamsPlan(isAnnual: true);
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Total = 5000,
|
||||
TotalTaxes = new List<InvoiceTotalTax> { new() { Amount = 500 } },
|
||||
Lines = new StripeList<InvoiceLineItem> { Data = new List<InvoiceLineItem> { new() { Amount = 5000 } } },
|
||||
PeriodEnd = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_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, PlanType.TeamsAnnually, billingAddress);
|
||||
|
||||
// Assert - Verify that the subscription item quantity is always 1 and has Id
|
||||
await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(
|
||||
Arg.Is<InvoiceCreatePreviewOptions>(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> { 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" }, 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<InvoiceTotalTax> { new() { Amount = 500 } },
|
||||
Lines = new StripeList<InvoiceLineItem> { Data = new List<InvoiceLineItem> { new() { Amount = 5000 } } },
|
||||
PeriodEnd = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_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, PlanType.TeamsAnnually, billingAddress);
|
||||
|
||||
// Assert - Verify password manager item is modified and storage item is deleted
|
||||
await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(
|
||||
Arg.Is<InvoiceCreatePreviewOptions>(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> { 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" }, CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var targetPlan = new FamiliesPlan(); // families is non seat based
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Total = 5000,
|
||||
TotalTaxes = new List<InvoiceTotalTax> { new() { Amount = 500 } },
|
||||
Lines = new StripeList<InvoiceLineItem> { Data = new List<InvoiceLineItem> { new() { Amount = 5000 } } },
|
||||
PeriodEnd = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_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, 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<InvoiceCreatePreviewOptions>(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> { 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" }, CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var targetPlan = new TeamsPlan(isAnnual: true);
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Total = 5000,
|
||||
TotalTaxes = new List<InvoiceTotalTax> { new() { Amount = 500 } },
|
||||
Lines = new StripeList<InvoiceLineItem> { Data = new List<InvoiceLineItem> { new() { Amount = 5000 } } },
|
||||
PeriodEnd = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_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, PlanType.TeamsAnnually, billingAddress);
|
||||
|
||||
// Assert - Verify all invoice preview options are correct
|
||||
await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(
|
||||
Arg.Is<InvoiceCreatePreviewOptions>(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> { 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" }, 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<InvoiceTotalTax> { new() { Amount = 500 } },
|
||||
Lines = new StripeList<InvoiceLineItem> { Data = new List<InvoiceLineItem> { new() { Amount = 5000 } } },
|
||||
PeriodEnd = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_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, PlanType.TeamsAnnually, billingAddress);
|
||||
|
||||
// Assert - Verify seat-based plan uses StripeSeatPlanId with quantity 1 and modifies existing item
|
||||
await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(
|
||||
Arg.Is<InvoiceCreatePreviewOptions>(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> { 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<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
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<InvoiceTotalTax> { new() { Amount = 500 } },
|
||||
Lines = new StripeList<InvoiceLineItem>
|
||||
{
|
||||
Data = new List<InvoiceLineItem> { new() { Amount = 5000 } }
|
||||
},
|
||||
PeriodEnd = now
|
||||
};
|
||||
|
||||
_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, 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> { 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<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
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<InvoiceTotalTax> { new() { Amount = 450 } }, // $4.50
|
||||
Lines = new StripeList<InvoiceLineItem>
|
||||
{
|
||||
Data = new List<InvoiceLineItem>
|
||||
{
|
||||
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<SubscriptionGetOptions>())
|
||||
.Returns(currentSubscription);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<SubscriptionUpdateOptions>(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<Organization>(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<SubscriptionUpdateOptions>(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<Organization>(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<SubscriptionUpdateOptions>(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<SubscriptionUpdateOptions>(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<SubscriptionUpdateOptions>(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<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new SubscriptionItem
|
||||
{
|
||||
Id = "si_premium",
|
||||
Price = new Price { Id = "premium-annually" }
|
||||
}
|
||||
}
|
||||
},
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
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<string>(), Arg.Any<SubscriptionUpdateOptions>()).Returns(mockSubscription);
|
||||
_stripeAdapter.UpdateCustomerAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>()).Returns(Task.FromResult(new Customer()));
|
||||
_organizationRepository.CreateAsync(Arg.Any<Organization>()).Returns(callInfo => Task.FromResult(callInfo.Arg<Organization>()));
|
||||
_organizationApiKeyRepository.CreateAsync(Arg.Any<OrganizationApiKey>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationApiKey>()));
|
||||
_organizationUserRepository.CreateAsync(Arg.Any<OrganizationUser>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationUser>()));
|
||||
_applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any<Organization>()).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<CustomerUpdateOptions>(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<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new SubscriptionItem
|
||||
{
|
||||
Id = "si_premium",
|
||||
Price = new Price { Id = "premium-annually" }
|
||||
}
|
||||
}
|
||||
},
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
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<string>(), Arg.Any<SubscriptionUpdateOptions>()).Returns(mockSubscription);
|
||||
_stripeAdapter.UpdateCustomerAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>()).Returns(Task.FromResult(new Customer()));
|
||||
_organizationRepository.CreateAsync(Arg.Any<Organization>()).Returns(callInfo => Task.FromResult(callInfo.Arg<Organization>()));
|
||||
_organizationApiKeyRepository.CreateAsync(Arg.Any<OrganizationApiKey>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationApiKey>()));
|
||||
_organizationUserRepository.CreateAsync(Arg.Any<OrganizationUser>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationUser>()));
|
||||
_applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any<Organization>()).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<SubscriptionUpdateOptions>(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<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new SubscriptionItem
|
||||
{
|
||||
Id = "si_premium",
|
||||
Price = new Price { Id = "premium-annually" }
|
||||
}
|
||||
}
|
||||
},
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
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<string>(), Arg.Any<SubscriptionUpdateOptions>()).Returns(mockSubscription);
|
||||
_stripeAdapter.UpdateCustomerAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>()).Returns(Task.FromResult(new Customer()));
|
||||
_organizationRepository.CreateAsync(Arg.Any<Organization>()).Returns(callInfo => Task.FromResult(callInfo.Arg<Organization>()));
|
||||
_organizationApiKeyRepository.CreateAsync(Arg.Any<OrganizationApiKey>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationApiKey>()));
|
||||
_organizationUserRepository.CreateAsync(Arg.Any<OrganizationUser>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationUser>()));
|
||||
_applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any<Organization>()).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<SubscriptionUpdateOptions>(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<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new SubscriptionItem
|
||||
{
|
||||
Id = "si_premium",
|
||||
Price = new Price { Id = "premium-annually" }
|
||||
}
|
||||
}
|
||||
},
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
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<string>(), Arg.Any<SubscriptionUpdateOptions>()).Returns(mockSubscription);
|
||||
_stripeAdapter.UpdateCustomerAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>()).Returns(Task.FromResult(new Customer()));
|
||||
_organizationRepository.CreateAsync(Arg.Any<Organization>()).Returns(callInfo => Task.FromResult(callInfo.Arg<Organization>()));
|
||||
_organizationApiKeyRepository.CreateAsync(Arg.Any<OrganizationApiKey>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationApiKey>()));
|
||||
_organizationUserRepository.CreateAsync(Arg.Any<OrganizationUser>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationUser>()));
|
||||
_applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any<Organization>()).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<SubscriptionUpdateOptions>(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<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new SubscriptionItem
|
||||
{
|
||||
Id = "si_premium",
|
||||
Price = new Price { Id = "premium-annually" }
|
||||
}
|
||||
}
|
||||
},
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
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<string>(), Arg.Any<SubscriptionUpdateOptions>()).Returns(mockSubscription);
|
||||
_stripeAdapter.UpdateCustomerAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>()).Returns(Task.FromResult(new Customer()));
|
||||
_organizationRepository.CreateAsync(Arg.Any<Organization>()).Returns(callInfo => Task.FromResult(callInfo.Arg<Organization>()));
|
||||
_organizationApiKeyRepository.CreateAsync(Arg.Any<OrganizationApiKey>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationApiKey>()));
|
||||
_organizationUserRepository.CreateAsync(Arg.Any<OrganizationUser>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationUser>()));
|
||||
_applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any<Organization>()).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<Organization>(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<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new SubscriptionItem
|
||||
{
|
||||
Id = "si_premium",
|
||||
Price = new Price { Id = "premium-annually" }
|
||||
}
|
||||
}
|
||||
},
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
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<string>(), Arg.Any<SubscriptionUpdateOptions>()).Returns(mockSubscription);
|
||||
_stripeAdapter.UpdateCustomerAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>()).Returns(Task.FromResult(new Customer()));
|
||||
_organizationRepository.CreateAsync(Arg.Any<Organization>()).Returns(callInfo => Task.FromResult(callInfo.Arg<Organization>()));
|
||||
_organizationApiKeyRepository.CreateAsync(Arg.Any<OrganizationApiKey>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationApiKey>()));
|
||||
_organizationUserRepository.CreateAsync(Arg.Any<OrganizationUser>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationUser>()));
|
||||
_applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any<Organization>()).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<OrganizationApiKey>(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<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new SubscriptionItem
|
||||
{
|
||||
Id = "si_premium",
|
||||
Price = new Price { Id = "premium-annually" }
|
||||
}
|
||||
}
|
||||
},
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
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<string>(), Arg.Any<SubscriptionUpdateOptions>()).Returns(mockSubscription);
|
||||
_stripeAdapter.UpdateCustomerAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>()).Returns(Task.FromResult(new Customer()));
|
||||
_organizationRepository.CreateAsync(Arg.Any<Organization>()).Returns(callInfo => Task.FromResult(callInfo.Arg<Organization>()));
|
||||
_organizationApiKeyRepository.CreateAsync(Arg.Any<OrganizationApiKey>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationApiKey>()));
|
||||
_organizationUserRepository.CreateAsync(Arg.Any<OrganizationUser>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationUser>()));
|
||||
_applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any<Organization>()).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<OrganizationUser>(orgUser =>
|
||||
orgUser.UserId == user.Id &&
|
||||
orgUser.Type == OrganizationUserType.Owner &&
|
||||
orgUser.Status == OrganizationUserStatusType.Confirmed));
|
||||
}
|
||||
}
|
||||
|
||||
@ -326,4 +326,101 @@ public class ImportCiphersAsyncCommandTests
|
||||
|
||||
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncVaultAsync(importingUserId);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ImportIntoIndividualVaultAsync_WithArchivedCiphers_PreservesArchiveStatus(
|
||||
Guid importingUserId,
|
||||
List<CipherDetails> ciphers,
|
||||
SutProvider<ImportCiphersCommand> sutProvider)
|
||||
{
|
||||
var archivedDate = DateTime.UtcNow.AddDays(-1);
|
||||
ciphers[0].UserId = importingUserId;
|
||||
ciphers[0].ArchivedDate = archivedDate;
|
||||
|
||||
sutProvider.GetDependency<IPolicyService>()
|
||||
.AnyPoliciesApplicableToUserAsync(importingUserId, PolicyType.OrganizationDataOwnership)
|
||||
.Returns(false);
|
||||
|
||||
sutProvider.GetDependency<IFolderRepository>()
|
||||
.GetManyByUserIdAsync(importingUserId)
|
||||
.Returns(new List<Folder>());
|
||||
|
||||
var folders = new List<Folder>();
|
||||
var folderRelationships = new List<KeyValuePair<int, int>>();
|
||||
|
||||
await sutProvider.Sut.ImportIntoIndividualVaultAsync(folders, ciphers, folderRelationships, importingUserId);
|
||||
|
||||
await sutProvider.GetDependency<ICipherRepository>()
|
||||
.Received(1)
|
||||
.CreateAsync(importingUserId,
|
||||
Arg.Is<List<CipherDetails>>(c =>
|
||||
c[0].Archives != null &&
|
||||
c[0].Archives.Contains(importingUserId.ToString().ToUpperInvariant()) &&
|
||||
c[0].Archives.Contains(archivedDate.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ"))),
|
||||
Arg.Any<List<Folder>>());
|
||||
}
|
||||
|
||||
/*
|
||||
* Archive functionality is a per-user function. When importing archived ciphers into an organization vault,
|
||||
* the Archives field should be set for the importing user only. This allows the importing user to see
|
||||
* items as archived, while other organization members will not see them as archived.
|
||||
*/
|
||||
[Theory, BitAutoData]
|
||||
public async Task ImportIntoOrganizationalVaultAsync_WithArchivedCiphers_SetsArchivesForImportingUserOnly(
|
||||
Organization organization,
|
||||
Guid importingUserId,
|
||||
OrganizationUser importingOrganizationUser,
|
||||
List<Collection> collections,
|
||||
List<CipherDetails> ciphers,
|
||||
SutProvider<ImportCiphersCommand> sutProvider)
|
||||
{
|
||||
var archivedDate = DateTime.UtcNow.AddDays(-1);
|
||||
organization.MaxCollections = null;
|
||||
importingOrganizationUser.OrganizationId = organization.Id;
|
||||
|
||||
foreach (var collection in collections)
|
||||
{
|
||||
collection.OrganizationId = organization.Id;
|
||||
}
|
||||
|
||||
foreach (var cipher in ciphers)
|
||||
{
|
||||
cipher.OrganizationId = organization.Id;
|
||||
}
|
||||
|
||||
ciphers[0].ArchivedDate = archivedDate;
|
||||
ciphers[0].Archives = null;
|
||||
|
||||
KeyValuePair<int, int>[] collectionRelationships = {
|
||||
new(0, 0),
|
||||
new(1, 1),
|
||||
new(2, 2)
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdAsync(organization.Id)
|
||||
.Returns(organization);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByOrganizationAsync(organization.Id, importingUserId)
|
||||
.Returns(importingOrganizationUser);
|
||||
|
||||
sutProvider.GetDependency<ICollectionRepository>()
|
||||
.GetManyByOrganizationIdAsync(organization.Id)
|
||||
.Returns(new List<Collection>());
|
||||
|
||||
await sutProvider.Sut.ImportIntoOrganizationalVaultAsync(collections, ciphers, collectionRelationships, importingUserId);
|
||||
|
||||
await sutProvider.GetDependency<ICipherRepository>()
|
||||
.Received(1)
|
||||
.CreateAsync(
|
||||
Arg.Is<List<CipherDetails>>(c =>
|
||||
c[0].ArchivedDate == archivedDate &&
|
||||
c[0].Archives != null &&
|
||||
c[0].Archives.Contains(importingUserId.ToString().ToUpperInvariant()) &&
|
||||
c[0].Archives.Contains(archivedDate.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ"))),
|
||||
Arg.Any<IEnumerable<Collection>>(),
|
||||
Arg.Any<IEnumerable<CollectionCipher>>(),
|
||||
Arg.Any<IEnumerable<CollectionUser>>());
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user