mirror of
https://github.com/bitwarden/server.git
synced 2026-01-31 14:13:18 +08:00
changes to proration behavior
and returning more properties from the proration endpoint
This commit is contained in:
@@ -70,6 +70,13 @@ public class PreviewInvoiceController(
|
|||||||
planType,
|
planType,
|
||||||
billingAddress);
|
billingAddress);
|
||||||
|
|
||||||
return Handle(result.Map(pair => new { pair.Tax, pair.Total, pair.Credit }));
|
return Handle(result.Map(proration => new
|
||||||
|
{
|
||||||
|
NewPlanProratedTotal = proration.NewPlanProratedAmount,
|
||||||
|
proration.Credit,
|
||||||
|
proration.Tax,
|
||||||
|
proration.Total,
|
||||||
|
proration.NewPlanProratedMonths
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
using Bit.Core.Billing.Constants;
|
using Bit.Core.Billing.Constants;
|
||||||
using Bit.Core.Billing.Enums;
|
using Bit.Core.Billing.Enums;
|
||||||
using Bit.Core.Billing.Payment.Models;
|
using Bit.Core.Billing.Payment.Models;
|
||||||
|
using Bit.Core.Billing.Premium.Models;
|
||||||
using Bit.Core.Billing.Pricing;
|
using Bit.Core.Billing.Pricing;
|
||||||
using Bit.Core.Billing.Services;
|
using Bit.Core.Billing.Services;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
@@ -22,8 +23,8 @@ public interface IPreviewPremiumUpgradeProrationCommand
|
|||||||
/// <param name="user">The user with an active Premium subscription.</param>
|
/// <param name="user">The user with an active Premium subscription.</param>
|
||||||
/// <param name="targetPlanType">The target organization plan type.</param>
|
/// <param name="targetPlanType">The target organization plan type.</param>
|
||||||
/// <param name="billingAddress">The billing address for tax calculation.</param>
|
/// <param name="billingAddress">The billing address for tax calculation.</param>
|
||||||
/// <returns>A tuple containing the tax amount, total cost, and proration credit from unused Premium time.</returns>
|
/// <returns>The proration details for the upgrade including costs, credits, tax, and time remaining.</returns>
|
||||||
Task<BillingCommandResult<(decimal Tax, decimal Total, decimal Credit)>> Run(
|
Task<BillingCommandResult<PremiumUpgradeProration>> Run(
|
||||||
User user,
|
User user,
|
||||||
PlanType targetPlanType,
|
PlanType targetPlanType,
|
||||||
BillingAddress billingAddress);
|
BillingAddress billingAddress);
|
||||||
@@ -36,19 +37,16 @@ public class PreviewPremiumUpgradeProrationCommand(
|
|||||||
: BaseBillingCommand<PreviewPremiumUpgradeProrationCommand>(logger),
|
: BaseBillingCommand<PreviewPremiumUpgradeProrationCommand>(logger),
|
||||||
IPreviewPremiumUpgradeProrationCommand
|
IPreviewPremiumUpgradeProrationCommand
|
||||||
{
|
{
|
||||||
public Task<BillingCommandResult<(decimal Tax, decimal Total, decimal Credit)>> Run(
|
public Task<BillingCommandResult<PremiumUpgradeProration>> Run(
|
||||||
User user,
|
User user,
|
||||||
PlanType targetPlanType,
|
PlanType targetPlanType,
|
||||||
BillingAddress billingAddress) => HandleAsync<(decimal, decimal, decimal)>(async () =>
|
BillingAddress billingAddress) => HandleAsync<PremiumUpgradeProration>(async () =>
|
||||||
{
|
{
|
||||||
if (user is not { Premium: true, GatewaySubscriptionId: not null and not "" })
|
if (user is not { Premium: true, GatewaySubscriptionId: not null and not "" })
|
||||||
{
|
{
|
||||||
return new BadRequest("User does not have an active Premium subscription.");
|
return new BadRequest("User does not have an active Premium subscription.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hardcode seats to 1 for upgrade flow
|
|
||||||
const int seats = 1;
|
|
||||||
|
|
||||||
var currentSubscription = await stripeAdapter.GetSubscriptionAsync(
|
var currentSubscription = await stripeAdapter.GetSubscriptionAsync(
|
||||||
user.GatewaySubscriptionId,
|
user.GatewaySubscriptionId,
|
||||||
new SubscriptionGetOptions { Expand = ["customer"] });
|
new SubscriptionGetOptions { Expand = ["customer"] });
|
||||||
@@ -63,11 +61,7 @@ public class PreviewPremiumUpgradeProrationCommand(
|
|||||||
|
|
||||||
var usersPremiumPlan = premiumPlans.First(p => p.Seat.StripePriceId == passwordManagerItem.Price.Id);
|
var usersPremiumPlan = premiumPlans.First(p => p.Seat.StripePriceId == passwordManagerItem.Price.Id);
|
||||||
var targetPlan = await pricingClient.GetPlanOrThrow(targetPlanType);
|
var targetPlan = await pricingClient.GetPlanOrThrow(targetPlanType);
|
||||||
var subscriptionItems = new List<InvoiceSubscriptionDetailsItemOptions>
|
var subscriptionItems = new List<InvoiceSubscriptionDetailsItemOptions>();
|
||||||
{
|
|
||||||
// Delete the user's specific password manager item
|
|
||||||
new() { Id = passwordManagerItem.Id, Deleted = true }
|
|
||||||
};
|
|
||||||
var storageItem = currentSubscription.Items.Data.FirstOrDefault(i =>
|
var storageItem = currentSubscription.Items.Data.FirstOrDefault(i =>
|
||||||
i.Price.Id == usersPremiumPlan.Storage.StripePriceId);
|
i.Price.Id == usersPremiumPlan.Storage.StripePriceId);
|
||||||
|
|
||||||
@@ -81,10 +75,12 @@ public class PreviewPremiumUpgradeProrationCommand(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hardcode seats to 1 for upgrade flow
|
||||||
if (targetPlan.HasNonSeatBasedPasswordManagerPlan())
|
if (targetPlan.HasNonSeatBasedPasswordManagerPlan())
|
||||||
{
|
{
|
||||||
subscriptionItems.Add(new InvoiceSubscriptionDetailsItemOptions
|
subscriptionItems.Add(new InvoiceSubscriptionDetailsItemOptions
|
||||||
{
|
{
|
||||||
|
Id = passwordManagerItem.Id,
|
||||||
Price = targetPlan.PasswordManager.StripePlanId,
|
Price = targetPlan.PasswordManager.StripePlanId,
|
||||||
Quantity = 1
|
Quantity = 1
|
||||||
});
|
});
|
||||||
@@ -93,8 +89,9 @@ public class PreviewPremiumUpgradeProrationCommand(
|
|||||||
{
|
{
|
||||||
subscriptionItems.Add(new InvoiceSubscriptionDetailsItemOptions
|
subscriptionItems.Add(new InvoiceSubscriptionDetailsItemOptions
|
||||||
{
|
{
|
||||||
|
Id = passwordManagerItem.Id,
|
||||||
Price = targetPlan.PasswordManager.StripeSeatPlanId,
|
Price = targetPlan.PasswordManager.StripeSeatPlanId,
|
||||||
Quantity = seats
|
Quantity = 1
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,21 +111,26 @@ public class PreviewPremiumUpgradeProrationCommand(
|
|||||||
SubscriptionDetails = new InvoiceSubscriptionDetailsOptions
|
SubscriptionDetails = new InvoiceSubscriptionDetailsOptions
|
||||||
{
|
{
|
||||||
Items = subscriptionItems,
|
Items = subscriptionItems,
|
||||||
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations
|
ProrationBehavior = StripeConstants.ProrationBehavior.AlwaysInvoice
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
var invoicePreview = await stripeAdapter.CreateInvoicePreviewAsync(options);
|
var invoicePreview = await stripeAdapter.CreateInvoicePreviewAsync(options);
|
||||||
var amounts = GetAmounts(invoicePreview);
|
var proration = GetProration(invoicePreview, passwordManagerItem);
|
||||||
|
|
||||||
return amounts;
|
return proration;
|
||||||
});
|
});
|
||||||
|
|
||||||
private static (decimal, decimal, decimal) GetAmounts(Invoice invoicePreview) => (
|
private static PremiumUpgradeProration GetProration(Invoice invoicePreview, SubscriptionItem passwordManagerItem) => new()
|
||||||
Convert.ToDecimal(invoicePreview.TotalTaxes.Sum(invoiceTotalTax => invoiceTotalTax.Amount)) / 100,
|
{
|
||||||
Convert.ToDecimal(invoicePreview.Total) / 100,
|
NewPlanProratedAmount = GetNewPlanProratedAmountFromInvoice(invoicePreview),
|
||||||
GetProrationCreditFromInvoice(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)
|
private static decimal GetProrationCreditFromInvoice(Invoice invoicePreview)
|
||||||
{
|
{
|
||||||
@@ -139,4 +141,26 @@ public class PreviewPremiumUpgradeProrationCommand(
|
|||||||
|
|
||||||
return Convert.ToDecimal(prorationCredit) / 100;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,13 +88,6 @@ public class UpgradePremiumToOrganizationCommand(
|
|||||||
// Build the list of subscription item updates
|
// Build the list of subscription item updates
|
||||||
var subscriptionItemOptions = new List<SubscriptionItemOptions>();
|
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
|
// Delete the storage item if it exists for this user's plan
|
||||||
var storageItem = currentSubscription.Items.Data.FirstOrDefault(i =>
|
var storageItem = currentSubscription.Items.Data.FirstOrDefault(i =>
|
||||||
i.Price.Id == usersPremiumPlan.Storage.StripePriceId);
|
i.Price.Id == usersPremiumPlan.Storage.StripePriceId);
|
||||||
@@ -116,6 +109,7 @@ public class UpgradePremiumToOrganizationCommand(
|
|||||||
{
|
{
|
||||||
subscriptionItemOptions.Add(new SubscriptionItemOptions
|
subscriptionItemOptions.Add(new SubscriptionItemOptions
|
||||||
{
|
{
|
||||||
|
Id = passwordManagerItem.Id,
|
||||||
Price = targetPlan.PasswordManager.StripePlanId,
|
Price = targetPlan.PasswordManager.StripePlanId,
|
||||||
Quantity = 1
|
Quantity = 1
|
||||||
});
|
});
|
||||||
@@ -124,6 +118,7 @@ public class UpgradePremiumToOrganizationCommand(
|
|||||||
{
|
{
|
||||||
subscriptionItemOptions.Add(new SubscriptionItemOptions
|
subscriptionItemOptions.Add(new SubscriptionItemOptions
|
||||||
{
|
{
|
||||||
|
Id = passwordManagerItem.Id,
|
||||||
Price = targetPlan.PasswordManager.StripeSeatPlanId,
|
Price = targetPlan.PasswordManager.StripeSeatPlanId,
|
||||||
Quantity = seats
|
Quantity = seats
|
||||||
});
|
});
|
||||||
@@ -136,7 +131,8 @@ public class UpgradePremiumToOrganizationCommand(
|
|||||||
var subscriptionUpdateOptions = new SubscriptionUpdateOptions
|
var subscriptionUpdateOptions = new SubscriptionUpdateOptions
|
||||||
{
|
{
|
||||||
Items = subscriptionItemOptions,
|
Items = subscriptionItemOptions,
|
||||||
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations,
|
ProrationBehavior = StripeConstants.ProrationBehavior.AlwaysInvoice,
|
||||||
|
BillingCycleAnchor = SubscriptionBillingCycleAnchor.Unchanged,
|
||||||
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true },
|
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true },
|
||||||
Metadata = new Dictionary<string, string>
|
Metadata = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
@@ -149,11 +145,6 @@ public class UpgradePremiumToOrganizationCommand(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (targetPlan.TrialPeriodDays.HasValue)
|
|
||||||
{
|
|
||||||
subscriptionUpdateOptions.TrialEnd = DateTime.UtcNow.AddDays((double)targetPlan.TrialPeriodDays);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the Organization entity
|
// Create the Organization entity
|
||||||
var organization = new Organization
|
var organization = new Organization
|
||||||
{
|
{
|
||||||
@@ -161,7 +152,7 @@ public class UpgradePremiumToOrganizationCommand(
|
|||||||
Name = organizationName,
|
Name = organizationName,
|
||||||
BillingEmail = user.Email,
|
BillingEmail = user.Email,
|
||||||
PlanType = targetPlan.Type,
|
PlanType = targetPlan.Type,
|
||||||
Seats = (short)seats,
|
Seats = seats,
|
||||||
MaxCollections = targetPlan.PasswordManager.MaxCollections,
|
MaxCollections = targetPlan.PasswordManager.MaxCollections,
|
||||||
MaxStorageGb = targetPlan.PasswordManager.BaseStorageGb,
|
MaxStorageGb = targetPlan.PasswordManager.BaseStorageGb,
|
||||||
UsePolicies = targetPlan.HasPolicies,
|
UsePolicies = targetPlan.HasPolicies,
|
||||||
|
|||||||
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; }
|
||||||
|
}
|
||||||
@@ -91,6 +91,8 @@ public class PreviewPremiumUpgradeProrationCommandTests
|
|||||||
var premiumPlans = new List<PremiumPlan> { premiumPlan };
|
var premiumPlans = new List<PremiumPlan> { premiumPlan };
|
||||||
|
|
||||||
// Setup current Stripe subscription
|
// 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
|
var currentSubscription = new Subscription
|
||||||
{
|
{
|
||||||
Id = "sub_123",
|
Id = "sub_123",
|
||||||
@@ -106,7 +108,8 @@ public class PreviewPremiumUpgradeProrationCommandTests
|
|||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Id = "si_premium",
|
Id = "si_premium",
|
||||||
Price = new Price { Id = "premium-annually" }
|
Price = new Price { Id = "premium-annually" },
|
||||||
|
CurrentPeriodEnd = currentPeriodEnd
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,7 +125,15 @@ public class PreviewPremiumUpgradeProrationCommandTests
|
|||||||
TotalTaxes = new List<InvoiceTotalTax>
|
TotalTaxes = new List<InvoiceTotalTax>
|
||||||
{
|
{
|
||||||
new() { Amount = 500 } // $5.00
|
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
|
// Configure mocks
|
||||||
@@ -140,10 +151,12 @@ public class PreviewPremiumUpgradeProrationCommandTests
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.True(result.IsT0);
|
Assert.True(result.IsT0);
|
||||||
var (tax, total, credit) = result.AsT0;
|
var proration = result.AsT0;
|
||||||
Assert.Equal(5.00m, tax);
|
Assert.Equal(50.00m, proration.NewPlanProratedAmount);
|
||||||
Assert.Equal(50.00m, total);
|
Assert.Equal(0m, proration.Credit);
|
||||||
Assert.Equal(0m, credit);
|
Assert.Equal(5.00m, proration.Tax);
|
||||||
|
Assert.Equal(50.00m, proration.Total);
|
||||||
|
Assert.Equal(6, proration.NewPlanProratedMonths); // 6 months remaining
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
@@ -174,6 +187,9 @@ public class PreviewPremiumUpgradeProrationCommandTests
|
|||||||
};
|
};
|
||||||
var premiumPlans = new List<PremiumPlan> { premiumPlan };
|
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
|
var currentSubscription = new Subscription
|
||||||
{
|
{
|
||||||
Id = "sub_123",
|
Id = "sub_123",
|
||||||
@@ -182,7 +198,7 @@ public class PreviewPremiumUpgradeProrationCommandTests
|
|||||||
{
|
{
|
||||||
Data = new List<SubscriptionItem>
|
Data = new List<SubscriptionItem>
|
||||||
{
|
{
|
||||||
new() { Id = "si_premium", Price = new Price { Id = "premium-annually" } }
|
new() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = currentPeriodEnd }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -201,7 +217,8 @@ public class PreviewPremiumUpgradeProrationCommandTests
|
|||||||
new() { Amount = -1000 }, // -$10.00 credit from unused Premium
|
new() { Amount = -1000 }, // -$10.00 credit from unused Premium
|
||||||
new() { Amount = 5000 } // $50.00 for new plan
|
new() { Amount = 5000 } // $50.00 for new plan
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
PeriodEnd = now
|
||||||
};
|
};
|
||||||
|
|
||||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||||
@@ -216,10 +233,12 @@ public class PreviewPremiumUpgradeProrationCommandTests
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.True(result.IsT0);
|
Assert.True(result.IsT0);
|
||||||
var (tax, total, credit) = result.AsT0;
|
var proration = result.AsT0;
|
||||||
Assert.Equal(4.00m, tax);
|
Assert.Equal(50.00m, proration.NewPlanProratedAmount);
|
||||||
Assert.Equal(40.00m, total);
|
Assert.Equal(10.00m, proration.Credit); // Proration credit
|
||||||
Assert.Equal(10.00m, 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]
|
[Theory, BitAutoData]
|
||||||
@@ -258,7 +277,7 @@ public class PreviewPremiumUpgradeProrationCommandTests
|
|||||||
{
|
{
|
||||||
Data = new List<SubscriptionItem>
|
Data = new List<SubscriptionItem>
|
||||||
{
|
{
|
||||||
new() { Id = "si_premium", Price = new Price { Id = "premium-annually" } }
|
new() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -268,7 +287,9 @@ public class PreviewPremiumUpgradeProrationCommandTests
|
|||||||
var invoice = new Invoice
|
var invoice = new Invoice
|
||||||
{
|
{
|
||||||
Total = 5000,
|
Total = 5000,
|
||||||
TotalTaxes = new List<InvoiceTotalTax> { new() { Amount = 500 } }
|
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.ListPremiumPlans().Returns(premiumPlans);
|
||||||
@@ -281,10 +302,11 @@ public class PreviewPremiumUpgradeProrationCommandTests
|
|||||||
// Act
|
// Act
|
||||||
await _command.Run(user, PlanType.TeamsAnnually, billingAddress);
|
await _command.Run(user, PlanType.TeamsAnnually, billingAddress);
|
||||||
|
|
||||||
// Assert - Verify that the subscription item quantity is always 1
|
// Assert - Verify that the subscription item quantity is always 1 and has Id
|
||||||
await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(
|
await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(
|
||||||
Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||||
options.SubscriptionDetails.Items.Any(item =>
|
options.SubscriptionDetails.Items.Any(item =>
|
||||||
|
item.Id == "si_premium" &&
|
||||||
item.Price == targetPlan.PasswordManager.StripeSeatPlanId &&
|
item.Price == targetPlan.PasswordManager.StripeSeatPlanId &&
|
||||||
item.Quantity == 1)));
|
item.Quantity == 1)));
|
||||||
}
|
}
|
||||||
@@ -325,8 +347,8 @@ public class PreviewPremiumUpgradeProrationCommandTests
|
|||||||
{
|
{
|
||||||
Data = new List<SubscriptionItem>
|
Data = new List<SubscriptionItem>
|
||||||
{
|
{
|
||||||
new() { Id = "si_password_manager", Price = new Price { Id = "premium-annually" } },
|
new() { Id = "si_password_manager", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) },
|
||||||
new() { Id = "si_storage", Price = new Price { Id = "storage-gb-annually" } }
|
new() { Id = "si_storage", Price = new Price { Id = "storage-gb-annually" }, CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -336,7 +358,9 @@ public class PreviewPremiumUpgradeProrationCommandTests
|
|||||||
var invoice = new Invoice
|
var invoice = new Invoice
|
||||||
{
|
{
|
||||||
Total = 5000,
|
Total = 5000,
|
||||||
TotalTaxes = new List<InvoiceTotalTax> { new() { Amount = 500 } }
|
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.ListPremiumPlans().Returns(premiumPlans);
|
||||||
@@ -349,11 +373,15 @@ public class PreviewPremiumUpgradeProrationCommandTests
|
|||||||
// Act
|
// Act
|
||||||
await _command.Run(user, PlanType.TeamsAnnually, billingAddress);
|
await _command.Run(user, PlanType.TeamsAnnually, billingAddress);
|
||||||
|
|
||||||
// Assert - Verify both password manager and storage items are marked as deleted
|
// Assert - Verify password manager item is modified and storage item is deleted
|
||||||
await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(
|
await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(
|
||||||
Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||||
|
// Password manager item should be modified to new plan price, not deleted
|
||||||
options.SubscriptionDetails.Items.Any(item =>
|
options.SubscriptionDetails.Items.Any(item =>
|
||||||
item.Id == "si_password_manager" && item.Deleted == true) &&
|
item.Id == "si_password_manager" &&
|
||||||
|
item.Price == targetPlan.PasswordManager.StripeSeatPlanId &&
|
||||||
|
item.Deleted != true) &&
|
||||||
|
// Storage item should be deleted
|
||||||
options.SubscriptionDetails.Items.Any(item =>
|
options.SubscriptionDetails.Items.Any(item =>
|
||||||
item.Id == "si_storage" && item.Deleted == true)));
|
item.Id == "si_storage" && item.Deleted == true)));
|
||||||
}
|
}
|
||||||
@@ -394,7 +422,7 @@ public class PreviewPremiumUpgradeProrationCommandTests
|
|||||||
{
|
{
|
||||||
Data = new List<SubscriptionItem>
|
Data = new List<SubscriptionItem>
|
||||||
{
|
{
|
||||||
new() { Id = "si_premium", Price = new Price { Id = "premium-annually" } }
|
new() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -404,7 +432,9 @@ public class PreviewPremiumUpgradeProrationCommandTests
|
|||||||
var invoice = new Invoice
|
var invoice = new Invoice
|
||||||
{
|
{
|
||||||
Total = 5000,
|
Total = 5000,
|
||||||
TotalTaxes = new List<InvoiceTotalTax> { new() { Amount = 500 } }
|
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.ListPremiumPlans().Returns(premiumPlans);
|
||||||
@@ -417,10 +447,11 @@ public class PreviewPremiumUpgradeProrationCommandTests
|
|||||||
// Act
|
// Act
|
||||||
await _command.Run(user, PlanType.FamiliesAnnually, billingAddress);
|
await _command.Run(user, PlanType.FamiliesAnnually, billingAddress);
|
||||||
|
|
||||||
// Assert - Verify non-seat-based plan uses StripePlanId with quantity 1
|
// Assert - Verify non-seat-based plan uses StripePlanId with quantity 1 and modifies existing item
|
||||||
await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(
|
await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(
|
||||||
Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||||
options.SubscriptionDetails.Items.Any(item =>
|
options.SubscriptionDetails.Items.Any(item =>
|
||||||
|
item.Id == "si_premium" &&
|
||||||
item.Price == targetPlan.PasswordManager.StripePlanId &&
|
item.Price == targetPlan.PasswordManager.StripePlanId &&
|
||||||
item.Quantity == 1)));
|
item.Quantity == 1)));
|
||||||
}
|
}
|
||||||
@@ -463,7 +494,7 @@ public class PreviewPremiumUpgradeProrationCommandTests
|
|||||||
{
|
{
|
||||||
Data = new List<SubscriptionItem>
|
Data = new List<SubscriptionItem>
|
||||||
{
|
{
|
||||||
new() { Id = "si_premium", Price = new Price { Id = "premium-annually" } }
|
new() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -473,7 +504,9 @@ public class PreviewPremiumUpgradeProrationCommandTests
|
|||||||
var invoice = new Invoice
|
var invoice = new Invoice
|
||||||
{
|
{
|
||||||
Total = 5000,
|
Total = 5000,
|
||||||
TotalTaxes = new List<InvoiceTotalTax> { new() { Amount = 500 } }
|
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.ListPremiumPlans().Returns(premiumPlans);
|
||||||
@@ -494,7 +527,7 @@ public class PreviewPremiumUpgradeProrationCommandTests
|
|||||||
options.Subscription == "sub_123" &&
|
options.Subscription == "sub_123" &&
|
||||||
options.CustomerDetails.Address.Country == "US" &&
|
options.CustomerDetails.Address.Country == "US" &&
|
||||||
options.CustomerDetails.Address.PostalCode == "12345" &&
|
options.CustomerDetails.Address.PostalCode == "12345" &&
|
||||||
options.SubscriptionDetails.ProrationBehavior == "create_prorations"));
|
options.SubscriptionDetails.ProrationBehavior == "always_invoice"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
@@ -533,7 +566,7 @@ public class PreviewPremiumUpgradeProrationCommandTests
|
|||||||
{
|
{
|
||||||
Data = new List<SubscriptionItem>
|
Data = new List<SubscriptionItem>
|
||||||
{
|
{
|
||||||
new() { Id = "si_premium", Price = new Price { Id = "premium-annually" } }
|
new() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -544,7 +577,9 @@ public class PreviewPremiumUpgradeProrationCommandTests
|
|||||||
var invoice = new Invoice
|
var invoice = new Invoice
|
||||||
{
|
{
|
||||||
Total = 5000,
|
Total = 5000,
|
||||||
TotalTaxes = new List<InvoiceTotalTax> { new() { Amount = 500 } }
|
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.ListPremiumPlans().Returns(premiumPlans);
|
||||||
@@ -557,11 +592,186 @@ public class PreviewPremiumUpgradeProrationCommandTests
|
|||||||
// Act
|
// Act
|
||||||
await _command.Run(user, PlanType.TeamsAnnually, billingAddress);
|
await _command.Run(user, PlanType.TeamsAnnually, billingAddress);
|
||||||
|
|
||||||
// Assert - Verify seat-based plan uses StripeSeatPlanId with quantity 1
|
// Assert - Verify seat-based plan uses StripeSeatPlanId with quantity 1 and modifies existing item
|
||||||
await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(
|
await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(
|
||||||
Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||||
options.SubscriptionDetails.Items.Any(item =>
|
options.SubscriptionDetails.Items.Any(item =>
|
||||||
|
item.Id == "si_premium" &&
|
||||||
item.Price == targetPlan.PasswordManager.StripeSeatPlanId &&
|
item.Price == targetPlan.PasswordManager.StripeSeatPlanId &&
|
||||||
item.Quantity == 1)));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -256,9 +256,8 @@ public class UpgradePremiumToOrganizationCommandTests
|
|||||||
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
|
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
|
||||||
"sub_123",
|
"sub_123",
|
||||||
Arg.Is<SubscriptionUpdateOptions>(opts =>
|
Arg.Is<SubscriptionUpdateOptions>(opts =>
|
||||||
opts.Items.Count == 2 && // 1 deleted + 1 seat (no storage)
|
opts.Items.Count == 1 && // Only 1 item: modify existing password manager item (no storage to delete)
|
||||||
opts.Items.Any(i => i.Deleted == true) &&
|
opts.Items.Any(i => i.Id == "si_premium" && i.Price == "teams-seat-annually" && i.Quantity == 1 && i.Deleted != true)));
|
||||||
opts.Items.Any(i => i.Price == "teams-seat-annually" && i.Quantity == 1)));
|
|
||||||
|
|
||||||
await _organizationRepository.Received(1).CreateAsync(Arg.Is<Organization>(o =>
|
await _organizationRepository.Received(1).CreateAsync(Arg.Is<Organization>(o =>
|
||||||
o.Name == "My Organization" &&
|
o.Name == "My Organization" &&
|
||||||
@@ -331,9 +330,8 @@ public class UpgradePremiumToOrganizationCommandTests
|
|||||||
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
|
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
|
||||||
"sub_123",
|
"sub_123",
|
||||||
Arg.Is<SubscriptionUpdateOptions>(opts =>
|
Arg.Is<SubscriptionUpdateOptions>(opts =>
|
||||||
opts.Items.Count == 2 && // 1 deleted + 1 plan
|
opts.Items.Count == 1 && // Only 1 item: modify existing password manager item (no storage to delete)
|
||||||
opts.Items.Any(i => i.Deleted == true) &&
|
opts.Items.Any(i => i.Id == "si_premium" && i.Price == "families-plan-annually" && i.Quantity == 1 && i.Deleted != true)));
|
||||||
opts.Items.Any(i => i.Price == "families-plan-annually" && i.Quantity == 1)));
|
|
||||||
|
|
||||||
await _organizationRepository.Received(1).CreateAsync(Arg.Is<Organization>(o =>
|
await _organizationRepository.Received(1).CreateAsync(Arg.Is<Organization>(o =>
|
||||||
o.Name == "My Families Org"));
|
o.Name == "My Families Org"));
|
||||||
@@ -461,14 +459,13 @@ public class UpgradePremiumToOrganizationCommandTests
|
|||||||
// Assert
|
// Assert
|
||||||
Assert.True(result.IsT0);
|
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(
|
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
|
||||||
"sub_123",
|
"sub_123",
|
||||||
Arg.Is<SubscriptionUpdateOptions>(opts =>
|
Arg.Is<SubscriptionUpdateOptions>(opts =>
|
||||||
opts.Items.Count == 3 && // 2 deleted (legacy PM + legacy storage) + 1 new seat
|
opts.Items.Count == 2 && // 1 modified (legacy PM to new price) + 1 deleted (legacy storage)
|
||||||
opts.Items.Count(i => i.Deleted == true && i.Id == "si_premium_legacy") == 1 && // Legacy PM deleted
|
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
|
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)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
@@ -528,15 +525,14 @@ public class UpgradePremiumToOrganizationCommandTests
|
|||||||
// Assert
|
// Assert
|
||||||
Assert.True(result.IsT0);
|
Assert.True(result.IsT0);
|
||||||
|
|
||||||
// Verify that ONLY the premium password manager item is deleted (not other products)
|
// Verify that ONLY the premium password manager item is modified (not other products)
|
||||||
// Note: We delete the specific premium item by ID, so other products are untouched
|
// Note: We modify the specific premium item by ID, so other products are untouched
|
||||||
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
|
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
|
||||||
"sub_123",
|
"sub_123",
|
||||||
Arg.Is<SubscriptionUpdateOptions>(opts =>
|
Arg.Is<SubscriptionUpdateOptions>(opts =>
|
||||||
opts.Items.Count == 2 && // 1 deleted (premium password manager) + 1 new seat
|
opts.Items.Count == 1 && // Only modify premium password manager item
|
||||||
opts.Items.Count(i => i.Deleted == true && i.Id == "si_premium") == 1 && // Premium item deleted by ID
|
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)
|
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)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
@@ -603,8 +599,8 @@ public class UpgradePremiumToOrganizationCommandTests
|
|||||||
Arg.Is<SubscriptionUpdateOptions>(opts =>
|
Arg.Is<SubscriptionUpdateOptions>(opts =>
|
||||||
opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousAdditionalStorage) &&
|
opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousAdditionalStorage) &&
|
||||||
opts.Metadata[StripeConstants.MetadataKeys.PreviousAdditionalStorage] == "5" &&
|
opts.Metadata[StripeConstants.MetadataKeys.PreviousAdditionalStorage] == "5" &&
|
||||||
opts.Items.Count == 3 && // 2 deleted (premium + storage) + 1 new seat
|
opts.Items.Count == 2 && // 1 modified (premium to new price) + 1 deleted (storage)
|
||||||
opts.Items.Count(i => i.Deleted == true) == 2));
|
opts.Items.Count(i => i.Deleted == true) == 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
@@ -647,77 +643,6 @@ public class UpgradePremiumToOrganizationCommandTests
|
|||||||
Assert.Equal("Premium subscription password manager item not found.", badRequest.Response);
|
Assert.Equal("Premium subscription password manager item not found.", badRequest.Response);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
|
||||||
public async Task Run_PlanWithTrialPeriod_SetsTrialEnd(User user)
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
user.Premium = true;
|
|
||||||
user.GatewaySubscriptionId = "sub_123";
|
|
||||||
user.GatewayCustomerId = "cus_123";
|
|
||||||
|
|
||||||
var currentPeriodEnd = DateTime.UtcNow.AddMonths(1);
|
|
||||||
var mockSubscription = new Subscription
|
|
||||||
{
|
|
||||||
Id = "sub_123",
|
|
||||||
Items = new StripeList<SubscriptionItem>
|
|
||||||
{
|
|
||||||
Data = new List<SubscriptionItem>
|
|
||||||
{
|
|
||||||
new SubscriptionItem
|
|
||||||
{
|
|
||||||
Id = "si_premium",
|
|
||||||
Price = new Price { Id = "premium-annually" },
|
|
||||||
CurrentPeriodEnd = currentPeriodEnd
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Metadata = new Dictionary<string, string>()
|
|
||||||
};
|
|
||||||
|
|
||||||
var mockPremiumPlans = CreateTestPremiumPlansList();
|
|
||||||
|
|
||||||
// Create a plan with a trial period
|
|
||||||
var mockPlan = CreateTestPlan(
|
|
||||||
PlanType.TeamsAnnually,
|
|
||||||
stripeSeatPlanId: "teams-seat-annually",
|
|
||||||
trialPeriodDays: 7
|
|
||||||
);
|
|
||||||
|
|
||||||
// Capture the subscription update options to verify TrialEnd is set
|
|
||||||
SubscriptionUpdateOptions capturedOptions = null;
|
|
||||||
|
|
||||||
_stripeAdapter.GetSubscriptionAsync("sub_123")
|
|
||||||
.Returns(mockSubscription);
|
|
||||||
_pricingClient.ListPremiumPlans().Returns(mockPremiumPlans);
|
|
||||||
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan);
|
|
||||||
_stripeAdapter.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Do<SubscriptionUpdateOptions>(opts => capturedOptions = opts))
|
|
||||||
.Returns(Task.FromResult(mockSubscription));
|
|
||||||
_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 testStartTime = DateTime.UtcNow;
|
|
||||||
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress());
|
|
||||||
var testEndTime = DateTime.UtcNow;
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.True(result.IsT0);
|
|
||||||
|
|
||||||
await _stripeAdapter.Received(1).UpdateSubscriptionAsync("sub_123", Arg.Any<SubscriptionUpdateOptions>());
|
|
||||||
|
|
||||||
Assert.NotNull(capturedOptions);
|
|
||||||
Assert.NotNull(capturedOptions.TrialEnd);
|
|
||||||
|
|
||||||
// TrialEnd is AnyOf<DateTime?, SubscriptionTrialEnd> - verify it's a DateTime
|
|
||||||
var trialEndDateTime = capturedOptions.TrialEnd.Value as DateTime?;
|
|
||||||
Assert.NotNull(trialEndDateTime);
|
|
||||||
Assert.True(trialEndDateTime.Value >= testStartTime.AddDays(7));
|
|
||||||
Assert.True(trialEndDateTime.Value <= testEndTime.AddDays(7));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task Run_UpdatesCustomerBillingAddress(User user)
|
public async Task Run_UpdatesCustomerBillingAddress(User user)
|
||||||
{
|
{
|
||||||
@@ -823,4 +748,272 @@ public class UpgradePremiumToOrganizationCommandTests
|
|||||||
opts.AutomaticTax != null &&
|
opts.AutomaticTax != null &&
|
||||||
opts.AutomaticTax.Enabled == true));
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user