1
0
mirror of https://github.com/bitwarden/server synced 2025-12-06 00:03:34 +00:00

[PM 26682]milestone 2d display discount on subscription page (#6542)

* The discount badge implementation

* Address the claude pr comments

* Add more unit testing

* Add more test

* used existing flag

* Add the coupon Ids

* Add more code documentation

* Add some recommendation from claude

* Fix addition comments and prs

* Add more integration test

* Fix some comment and add more test

* rename the test methods

* Add more unit test and comments

* Resolve the null issues

* Add more test

* reword the comments

* Rename Variable

* Some code refactoring

* Change the coupon ID to milestone-2c

* Fix the failing Test
This commit is contained in:
cyprain-okeke
2025-11-12 20:38:21 +01:00
committed by GitHub
parent de90108e0f
commit f0ec201745
10 changed files with 2460 additions and 59 deletions

View File

@@ -4,6 +4,7 @@ using Bit.Api.Models.Request;
using Bit.Api.Models.Request.Accounts;
using Bit.Api.Models.Response;
using Bit.Api.Utilities;
using Bit.Core;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Business;
@@ -24,7 +25,8 @@ namespace Bit.Api.Billing.Controllers;
public class AccountsController(
IUserService userService,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IUserAccountKeysQuery userAccountKeysQuery) : Controller
IUserAccountKeysQuery userAccountKeysQuery,
IFeatureService featureService) : Controller
{
[HttpPost("premium")]
public async Task<PaymentResponseModel> PostPremiumAsync(
@@ -84,16 +86,24 @@ public class AccountsController(
throw new UnauthorizedAccessException();
}
if (!globalSettings.SelfHosted && user.Gateway != null)
// Only cloud-hosted users with payment gateways have subscription and discount information
if (!globalSettings.SelfHosted)
{
var subscriptionInfo = await paymentService.GetSubscriptionAsync(user);
var license = await userService.GenerateLicenseAsync(user, subscriptionInfo);
return new SubscriptionResponseModel(user, subscriptionInfo, license);
}
else if (!globalSettings.SelfHosted)
{
var license = await userService.GenerateLicenseAsync(user);
return new SubscriptionResponseModel(user, license);
if (user.Gateway != null)
{
// Note: PM23341_Milestone_2 is the feature flag for the overall Milestone 2 initiative (PM-23341).
// This specific implementation (PM-26682) adds discount display functionality as part of that initiative.
// The feature flag controls the broader Milestone 2 feature set, not just this specific task.
var includeMilestone2Discount = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2);
var subscriptionInfo = await paymentService.GetSubscriptionAsync(user);
var license = await userService.GenerateLicenseAsync(user, subscriptionInfo);
return new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount);
}
else
{
var license = await userService.GenerateLicenseAsync(user);
return new SubscriptionResponseModel(user, license);
}
}
else
{

View File

@@ -1,6 +1,4 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Models.Business;
using Bit.Core.Entities;
using Bit.Core.Models.Api;
@@ -11,7 +9,17 @@ namespace Bit.Api.Models.Response;
public class SubscriptionResponseModel : ResponseModel
{
public SubscriptionResponseModel(User user, SubscriptionInfo subscription, UserLicense license)
/// <param name="user">The user entity containing storage and premium subscription information</param>
/// <param name="subscription">Subscription information retrieved from the payment provider (Stripe/Braintree)</param>
/// <param name="license">The user's license containing expiration and feature entitlements</param>
/// <param name="includeMilestone2Discount">
/// Whether to include discount information in the response.
/// Set to true when the PM23341_Milestone_2 feature flag is enabled AND
/// you want to expose Milestone 2 discount information to the client.
/// The discount will only be included if it matches the specific Milestone 2 coupon ID.
/// </param>
public SubscriptionResponseModel(User user, SubscriptionInfo subscription, UserLicense license, bool includeMilestone2Discount = false)
: base("subscription")
{
Subscription = subscription.Subscription != null ? new BillingSubscription(subscription.Subscription) : null;
@@ -22,9 +30,14 @@ public class SubscriptionResponseModel : ResponseModel
MaxStorageGb = user.MaxStorageGb;
License = license;
Expiration = License.Expires;
// Only display the Milestone 2 subscription discount on the subscription page.
CustomerDiscount = ShouldIncludeMilestone2Discount(includeMilestone2Discount, subscription.CustomerDiscount)
? new BillingCustomerDiscount(subscription.CustomerDiscount!)
: null;
}
public SubscriptionResponseModel(User user, UserLicense license = null)
public SubscriptionResponseModel(User user, UserLicense? license = null)
: base("subscription")
{
StorageName = user.Storage.HasValue ? CoreHelpers.ReadableBytesSize(user.Storage.Value) : null;
@@ -38,21 +51,109 @@ public class SubscriptionResponseModel : ResponseModel
}
}
public string StorageName { get; set; }
public string? StorageName { get; set; }
public double? StorageGb { get; set; }
public short? MaxStorageGb { get; set; }
public BillingSubscriptionUpcomingInvoice UpcomingInvoice { get; set; }
public BillingSubscription Subscription { get; set; }
public UserLicense License { get; set; }
public BillingSubscriptionUpcomingInvoice? UpcomingInvoice { get; set; }
public BillingSubscription? Subscription { get; set; }
/// <summary>
/// Customer discount information from Stripe for the Milestone 2 subscription discount.
/// Only includes the specific Milestone 2 coupon (cm3nHfO1) when it's a perpetual discount (no expiration).
/// This is for display purposes only and does not affect Stripe's automatic discount application.
/// Other discounts may still apply in Stripe billing but are not included in this response.
/// <para>
/// Null when:
/// - The PM23341_Milestone_2 feature flag is disabled
/// - There is no active discount
/// - The discount coupon ID doesn't match the Milestone 2 coupon (cm3nHfO1)
/// - The instance is self-hosted
/// </para>
/// </summary>
public BillingCustomerDiscount? CustomerDiscount { get; set; }
public UserLicense? License { get; set; }
public DateTime? Expiration { get; set; }
/// <summary>
/// Determines whether the Milestone 2 discount should be included in the response.
/// </summary>
/// <param name="includeMilestone2Discount">Whether the feature flag is enabled and discount should be considered.</param>
/// <param name="customerDiscount">The customer discount from subscription info, if any.</param>
/// <returns>True if the discount should be included; false otherwise.</returns>
private static bool ShouldIncludeMilestone2Discount(
bool includeMilestone2Discount,
SubscriptionInfo.BillingCustomerDiscount? customerDiscount)
{
return includeMilestone2Discount &&
customerDiscount != null &&
customerDiscount.Id == StripeConstants.CouponIDs.Milestone2SubscriptionDiscount &&
customerDiscount.Active;
}
}
public class BillingCustomerDiscount(SubscriptionInfo.BillingCustomerDiscount discount)
/// <summary>
/// Customer discount information from Stripe billing.
/// </summary>
public class BillingCustomerDiscount
{
public string Id { get; } = discount.Id;
public bool Active { get; } = discount.Active;
public decimal? PercentOff { get; } = discount.PercentOff;
public List<string> AppliesTo { get; } = discount.AppliesTo;
/// <summary>
/// The Stripe coupon ID (e.g., "cm3nHfO1").
/// </summary>
public string? Id { get; }
/// <summary>
/// Whether the discount is a recurring/perpetual discount with no expiration date.
/// <para>
/// This property is true only when the discount has no end date, meaning it applies
/// indefinitely to all future renewals. This is a product decision for Milestone 2
/// to only display perpetual discounts in the UI.
/// </para>
/// <para>
/// Note: This does NOT indicate whether the discount is "currently active" in the billing sense.
/// A discount with a future end date is functionally active and will be applied by Stripe,
/// but this property will be false because it has an expiration date.
/// </para>
/// </summary>
public bool Active { get; }
/// <summary>
/// Percentage discount applied to the subscription (e.g., 20.0 for 20% off).
/// Null if this is an amount-based discount.
/// </summary>
public decimal? PercentOff { get; }
/// <summary>
/// Fixed amount discount in USD (e.g., 14.00 for $14 off).
/// Converted from Stripe's cent-based values (1400 cents → $14.00).
/// Null if this is a percentage-based discount.
/// Note: Stripe stores amounts in the smallest currency unit. This value is always in USD.
/// </summary>
public decimal? AmountOff { get; }
/// <summary>
/// List of Stripe product IDs that this discount applies to (e.g., ["prod_premium", "prod_families"]).
/// <para>
/// Null: discount applies to all products with no restrictions (AppliesTo not specified in Stripe).
/// Empty list: discount restricted to zero products (edge case - AppliesTo.Products = [] in Stripe).
/// Non-empty list: discount applies only to the specified product IDs.
/// </para>
/// </summary>
public IReadOnlyList<string>? AppliesTo { get; }
/// <summary>
/// Creates a BillingCustomerDiscount from a SubscriptionInfo.BillingCustomerDiscount.
/// </summary>
/// <param name="discount">The discount to convert. Must not be null.</param>
/// <exception cref="ArgumentNullException">Thrown when discount is null.</exception>
public BillingCustomerDiscount(SubscriptionInfo.BillingCustomerDiscount discount)
{
ArgumentNullException.ThrowIfNull(discount);
Id = discount.Id;
Active = discount.Active;
PercentOff = discount.PercentOff;
AmountOff = discount.AmountOff;
AppliesTo = discount.AppliesTo;
}
}
public class BillingSubscription
@@ -83,10 +184,10 @@ public class BillingSubscription
public DateTime? PeriodEndDate { get; set; }
public DateTime? CancelledDate { get; set; }
public bool CancelAtEndDate { get; set; }
public string Status { get; set; }
public string? Status { get; set; }
public bool Cancelled { get; set; }
public IEnumerable<BillingSubscriptionItem> Items { get; set; } = new List<BillingSubscriptionItem>();
public string CollectionMethod { get; set; }
public string? CollectionMethod { get; set; }
public DateTime? SuspensionDate { get; set; }
public DateTime? UnpaidPeriodEndDate { get; set; }
public int? GracePeriod { get; set; }
@@ -104,11 +205,11 @@ public class BillingSubscription
AddonSubscriptionItem = item.AddonSubscriptionItem;
}
public string ProductId { get; set; }
public string Name { get; set; }
public string? ProductId { get; set; }
public string? Name { get; set; }
public decimal Amount { get; set; }
public int Quantity { get; set; }
public string Interval { get; set; }
public string? Interval { get; set; }
public bool SponsoredSubscriptionItem { get; set; }
public bool AddonSubscriptionItem { get; set; }
}

View File

@@ -22,7 +22,7 @@ public static class StripeConstants
{
public const string LegacyMSPDiscount = "msp-discount-35";
public const string SecretsManagerStandalone = "sm-standalone";
public const string Milestone2SubscriptionDiscount = "cm3nHfO1";
public const string Milestone2SubscriptionDiscount = "milestone-2c";
public static class MSPDiscounts
{

View File

@@ -1,58 +1,118 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Extensions;
using Stripe;
#nullable enable
namespace Bit.Core.Models.Business;
public class SubscriptionInfo
{
public BillingCustomerDiscount CustomerDiscount { get; set; }
public BillingSubscription Subscription { get; set; }
public BillingUpcomingInvoice UpcomingInvoice { get; set; }
/// <summary>
/// Converts Stripe's minor currency units (cents) to major currency units (dollars).
/// IMPORTANT: Only supports USD. All Bitwarden subscriptions are USD-only.
/// </summary>
private const decimal StripeMinorUnitDivisor = 100M;
/// <summary>
/// Converts Stripe's minor currency units (cents) to major currency units (dollars).
/// Preserves null semantics to distinguish between "no amount" (null) and "zero amount" (0.00m).
/// </summary>
/// <param name="amountInCents">The amount in Stripe's minor currency units (e.g., cents for USD).</param>
/// <returns>The amount in major currency units (e.g., dollars for USD), or null if the input is null.</returns>
private static decimal? ConvertFromStripeMinorUnits(long? amountInCents)
{
return amountInCents.HasValue ? amountInCents.Value / StripeMinorUnitDivisor : null;
}
public BillingCustomerDiscount? CustomerDiscount { get; set; }
public BillingSubscription? Subscription { get; set; }
public BillingUpcomingInvoice? UpcomingInvoice { get; set; }
/// <summary>
/// Represents customer discount information from Stripe billing.
/// </summary>
public class BillingCustomerDiscount
{
public BillingCustomerDiscount() { }
/// <summary>
/// Creates a BillingCustomerDiscount from a Stripe Discount object.
/// </summary>
/// <param name="discount">The Stripe discount containing coupon and expiration information.</param>
public BillingCustomerDiscount(Discount discount)
{
Id = discount.Coupon?.Id;
// Active = true only for perpetual/recurring discounts (no end date)
// This is intentional for Milestone 2 - only perpetual discounts are shown in UI
Active = discount.End == null;
PercentOff = discount.Coupon?.PercentOff;
AppliesTo = discount.Coupon?.AppliesTo?.Products ?? [];
AmountOff = ConvertFromStripeMinorUnits(discount.Coupon?.AmountOff);
// Stripe's CouponAppliesTo.Products is already IReadOnlyList<string>, so no conversion needed
AppliesTo = discount.Coupon?.AppliesTo?.Products;
}
public string Id { get; set; }
/// <summary>
/// The Stripe coupon ID (e.g., "cm3nHfO1").
/// Note: Only specific coupon IDs are displayed in the UI based on feature flag configuration,
/// though Stripe may apply additional discounts that are not shown.
/// </summary>
public string? Id { get; set; }
/// <summary>
/// True only for perpetual/recurring discounts (End == null).
/// False for any discount with an expiration date, even if not yet expired.
/// Product decision for Milestone 2: only show perpetual discounts in UI.
/// </summary>
public bool Active { get; set; }
/// <summary>
/// Percentage discount applied to the subscription (e.g., 20.0 for 20% off).
/// Null if this is an amount-based discount.
/// </summary>
public decimal? PercentOff { get; set; }
public List<string> AppliesTo { get; set; }
/// <summary>
/// Fixed amount discount in USD (e.g., 14.00 for $14 off).
/// Converted from Stripe's cent-based values (1400 cents → $14.00).
/// Null if this is a percentage-based discount.
/// </summary>
public decimal? AmountOff { get; set; }
/// <summary>
/// List of Stripe product IDs that this discount applies to (e.g., ["prod_premium", "prod_families"]).
/// <para>
/// Null: discount applies to all products with no restrictions (AppliesTo not specified in Stripe).
/// Empty list: discount restricted to zero products (edge case - AppliesTo.Products = [] in Stripe).
/// Non-empty list: discount applies only to the specified product IDs.
/// </para>
/// </summary>
public IReadOnlyList<string>? AppliesTo { get; set; }
}
public class BillingSubscription
{
public BillingSubscription(Subscription sub)
{
Status = sub.Status;
TrialStartDate = sub.TrialStart;
TrialEndDate = sub.TrialEnd;
var currentPeriod = sub.GetCurrentPeriod();
Status = sub?.Status;
TrialStartDate = sub?.TrialStart;
TrialEndDate = sub?.TrialEnd;
var currentPeriod = sub?.GetCurrentPeriod();
if (currentPeriod != null)
{
var (start, end) = currentPeriod.Value;
PeriodStartDate = start;
PeriodEndDate = end;
}
CancelledDate = sub.CanceledAt;
CancelAtEndDate = sub.CancelAtPeriodEnd;
Cancelled = sub.Status == "canceled" || sub.Status == "unpaid" || sub.Status == "incomplete_expired";
if (sub.Items?.Data != null)
CancelledDate = sub?.CanceledAt;
CancelAtEndDate = sub?.CancelAtPeriodEnd ?? false;
var status = sub?.Status;
Cancelled = status == "canceled" || status == "unpaid" || status == "incomplete_expired";
if (sub?.Items?.Data != null)
{
Items = sub.Items.Data.Select(i => new BillingSubscriptionItem(i));
}
CollectionMethod = sub.CollectionMethod;
GracePeriod = sub.CollectionMethod == "charge_automatically"
CollectionMethod = sub?.CollectionMethod;
GracePeriod = sub?.CollectionMethod == "charge_automatically"
? 14
: 30;
}
@@ -64,10 +124,10 @@ public class SubscriptionInfo
public TimeSpan? PeriodDuration => PeriodEndDate - PeriodStartDate;
public DateTime? CancelledDate { get; set; }
public bool CancelAtEndDate { get; set; }
public string Status { get; set; }
public string? Status { get; set; }
public bool Cancelled { get; set; }
public IEnumerable<BillingSubscriptionItem> Items { get; set; } = new List<BillingSubscriptionItem>();
public string CollectionMethod { get; set; }
public string? CollectionMethod { get; set; }
public DateTime? SuspensionDate { get; set; }
public DateTime? UnpaidPeriodEndDate { get; set; }
public int GracePeriod { get; set; }
@@ -80,7 +140,7 @@ public class SubscriptionInfo
{
ProductId = item.Plan.ProductId;
Name = item.Plan.Nickname;
Amount = item.Plan.Amount.GetValueOrDefault() / 100M;
Amount = ConvertFromStripeMinorUnits(item.Plan.Amount) ?? 0;
Interval = item.Plan.Interval;
if (item.Metadata != null)
@@ -90,15 +150,15 @@ public class SubscriptionInfo
}
Quantity = (int)item.Quantity;
SponsoredSubscriptionItem = Utilities.StaticStore.SponsoredPlans.Any(p => p.StripePlanId == item.Plan.Id);
SponsoredSubscriptionItem = item.Plan != null && Utilities.StaticStore.SponsoredPlans.Any(p => p.StripePlanId == item.Plan.Id);
}
public bool AddonSubscriptionItem { get; set; }
public string ProductId { get; set; }
public string Name { get; set; }
public string? ProductId { get; set; }
public string? Name { get; set; }
public decimal Amount { get; set; }
public int Quantity { get; set; }
public string Interval { get; set; }
public string? Interval { get; set; }
public bool SponsoredSubscriptionItem { get; set; }
}
}
@@ -109,7 +169,7 @@ public class SubscriptionInfo
public BillingUpcomingInvoice(Invoice inv)
{
Amount = inv.AmountDue / 100M;
Amount = ConvertFromStripeMinorUnits(inv.AmountDue) ?? 0;
Date = inv.Created;
}

View File

@@ -643,9 +643,21 @@ public class StripePaymentService : IPaymentService
var subscription = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId,
new SubscriptionGetOptions { Expand = ["customer.discount.coupon.applies_to", "discounts.coupon.applies_to", "test_clock"] });
if (subscription == null)
{
return subscriptionInfo;
}
subscriptionInfo.Subscription = new SubscriptionInfo.BillingSubscription(subscription);
var discount = subscription.Customer.Discount ?? subscription.Discounts.FirstOrDefault();
// Discount selection priority:
// 1. Customer-level discount (applies to all subscriptions for the customer)
// 2. First subscription-level discount (if multiple exist, FirstOrDefault() selects the first one)
// Note: When multiple subscription-level discounts exist, only the first one is used.
// This matches Stripe's behavior where the first discount in the list is applied.
// Defensive null checks: Even though we expand "customer" and "discounts", external APIs
// may not always return the expected data structure, so we use null-safe operators.
var discount = subscription.Customer?.Discount ?? subscription.Discounts?.FirstOrDefault();
if (discount != null)
{

View File

@@ -0,0 +1,800 @@
using System.Security.Claims;
using Bit.Api.Billing.Controllers;
using Bit.Core;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Models.Business;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.KeyManagement.Queries.Interfaces;
using Bit.Core.Models.Business;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Test.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using NSubstitute;
using Stripe;
using Xunit;
namespace Bit.Api.Test.Billing.Controllers;
[SubscriptionInfoCustomize]
public class AccountsControllerTests : IDisposable
{
private const string TestMilestone2CouponId = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount;
private readonly IUserService _userService;
private readonly IFeatureService _featureService;
private readonly IPaymentService _paymentService;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IUserAccountKeysQuery _userAccountKeysQuery;
private readonly GlobalSettings _globalSettings;
private readonly AccountsController _sut;
public AccountsControllerTests()
{
_userService = Substitute.For<IUserService>();
_featureService = Substitute.For<IFeatureService>();
_paymentService = Substitute.For<IPaymentService>();
_twoFactorIsEnabledQuery = Substitute.For<ITwoFactorIsEnabledQuery>();
_userAccountKeysQuery = Substitute.For<IUserAccountKeysQuery>();
_globalSettings = new GlobalSettings { SelfHosted = false };
_sut = new AccountsController(
_userService,
_twoFactorIsEnabledQuery,
_userAccountKeysQuery,
_featureService
);
}
public void Dispose()
{
_sut?.Dispose();
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WhenFeatureFlagEnabled_IncludesDiscount(
User user,
SubscriptionInfo subscriptionInfo,
UserLicense license)
{
// Arrange
subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = TestMilestone2CouponId,
Active = true,
PercentOff = 20m,
AmountOff = null,
AppliesTo = new List<string> { "product1" }
};
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
user.Gateway = GatewayType.Stripe; // User has payment gateway
// Act
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
// Assert
Assert.NotNull(result);
Assert.NotNull(result.CustomerDiscount);
Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id);
Assert.Equal(20m, result.CustomerDiscount.PercentOff);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WhenFeatureFlagDisabled_ExcludesDiscount(
User user,
SubscriptionInfo subscriptionInfo,
UserLicense license)
{
// Arrange
subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = TestMilestone2CouponId,
Active = true,
PercentOff = 20m,
AmountOff = null,
AppliesTo = new List<string> { "product1" }
};
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(false);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
user.Gateway = GatewayType.Stripe; // User has payment gateway
// Act
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
// Assert
Assert.NotNull(result);
Assert.Null(result.CustomerDiscount); // Should be null when feature flag is disabled
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WithNonMatchingCouponId_ExcludesDiscount(
User user,
SubscriptionInfo subscriptionInfo,
UserLicense license)
{
// Arrange
subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = "different-coupon-id", // Non-matching coupon ID
Active = true,
PercentOff = 20m,
AmountOff = null,
AppliesTo = new List<string> { "product1" }
};
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
user.Gateway = GatewayType.Stripe; // User has payment gateway
// Act
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
// Assert
Assert.NotNull(result);
Assert.Null(result.CustomerDiscount); // Should be null when coupon ID doesn't match
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WhenSelfHosted_ReturnsBasicResponse(User user)
{
// Arrange
var selfHostedSettings = new GlobalSettings { SelfHosted = true };
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
// Act
var result = await _sut.GetSubscriptionAsync(selfHostedSettings, _paymentService);
// Assert
Assert.NotNull(result);
Assert.Null(result.CustomerDiscount);
await _paymentService.DidNotReceive().GetSubscriptionAsync(Arg.Any<User>());
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WhenNoGateway_ExcludesDiscount(User user, UserLicense license)
{
// Arrange
user.Gateway = null; // No gateway configured
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_userService.GenerateLicenseAsync(user).Returns(license);
// Act
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
// Assert
Assert.NotNull(result);
Assert.Null(result.CustomerDiscount); // Should be null when no gateway
await _paymentService.DidNotReceive().GetSubscriptionAsync(Arg.Any<User>());
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WithInactiveDiscount_ExcludesDiscount(
User user,
SubscriptionInfo subscriptionInfo,
UserLicense license)
{
// Arrange
subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = TestMilestone2CouponId,
Active = false, // Inactive discount
PercentOff = 20m,
AmountOff = null,
AppliesTo = new List<string> { "product1" }
};
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
user.Gateway = GatewayType.Stripe; // User has payment gateway
// Act
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
// Assert
Assert.NotNull(result);
Assert.Null(result.CustomerDiscount); // Should be null when discount is inactive
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_FullPipeline_ConvertsStripeDiscountToApiResponse(
User user,
UserLicense license)
{
// Arrange - Create a Stripe Discount object with real structure
var stripeDiscount = new Discount
{
Coupon = new Coupon
{
Id = TestMilestone2CouponId,
PercentOff = 25m,
AmountOff = 1400, // 1400 cents = $14.00
AppliesTo = new CouponAppliesTo
{
Products = new List<string> { "prod_premium", "prod_families" }
}
},
End = null // Active discount
};
// Convert Stripe Discount to BillingCustomerDiscount (simulating what StripePaymentService does)
var billingDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount);
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = billingDiscount
};
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
user.Gateway = GatewayType.Stripe;
// Act
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
// Assert - Verify full pipeline conversion
Assert.NotNull(result);
Assert.NotNull(result.CustomerDiscount);
// Verify Stripe data correctly converted to API response
Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id);
Assert.True(result.CustomerDiscount.Active);
Assert.Equal(25m, result.CustomerDiscount.PercentOff);
// Verify cents-to-dollars conversion (1400 cents -> $14.00)
Assert.Equal(14.00m, result.CustomerDiscount.AmountOff);
// Verify AppliesTo products are preserved
Assert.NotNull(result.CustomerDiscount.AppliesTo);
Assert.Equal(2, result.CustomerDiscount.AppliesTo.Count());
Assert.Contains("prod_premium", result.CustomerDiscount.AppliesTo);
Assert.Contains("prod_families", result.CustomerDiscount.AppliesTo);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_FullPipeline_WithFeatureFlagToggle_ControlsVisibility(
User user,
UserLicense license)
{
// Arrange - Create Stripe Discount
var stripeDiscount = new Discount
{
Coupon = new Coupon
{
Id = TestMilestone2CouponId,
PercentOff = 20m
},
End = null
};
var billingDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount);
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = billingDiscount
};
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
user.Gateway = GatewayType.Stripe;
// Act & Assert - Feature flag ENABLED
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
var resultWithFlag = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
Assert.NotNull(resultWithFlag.CustomerDiscount);
// Act & Assert - Feature flag DISABLED
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(false);
var resultWithoutFlag = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
Assert.Null(resultWithoutFlag.CustomerDiscount);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_IntegrationTest_CompletePipelineFromStripeToApiResponse(
User user,
UserLicense license)
{
// Arrange - Create a real Stripe Discount object as it would come from Stripe API
var stripeDiscount = new Discount
{
Coupon = new Coupon
{
Id = TestMilestone2CouponId,
PercentOff = 30m,
AmountOff = 2000, // 2000 cents = $20.00
AppliesTo = new CouponAppliesTo
{
Products = new List<string> { "prod_premium", "prod_families", "prod_teams" }
}
},
End = null // Active discount (no end date)
};
// Step 1: Map Stripe Discount through SubscriptionInfo.BillingCustomerDiscount
// This simulates what StripePaymentService.GetSubscriptionAsync does
var billingCustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount);
// Verify the mapping worked correctly
Assert.Equal(TestMilestone2CouponId, billingCustomerDiscount.Id);
Assert.True(billingCustomerDiscount.Active);
Assert.Equal(30m, billingCustomerDiscount.PercentOff);
Assert.Equal(20.00m, billingCustomerDiscount.AmountOff); // Converted from cents
Assert.NotNull(billingCustomerDiscount.AppliesTo);
Assert.Equal(3, billingCustomerDiscount.AppliesTo.Count);
// Step 2: Create SubscriptionInfo with the mapped discount
// This simulates what StripePaymentService returns
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = billingCustomerDiscount
};
// Step 3: Set up controller dependencies
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
user.Gateway = GatewayType.Stripe;
// Act - Step 4: Call AccountsController.GetSubscriptionAsync
// This exercises the complete pipeline:
// - Retrieves subscriptionInfo from paymentService (with discount from Stripe)
// - Maps through SubscriptionInfo.BillingCustomerDiscount (already done above)
// - Filters in SubscriptionResponseModel constructor (based on feature flag, coupon ID, active status)
// - Returns via AccountsController
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
// Assert - Verify the complete pipeline worked end-to-end
Assert.NotNull(result);
Assert.NotNull(result.CustomerDiscount);
// Verify Stripe Discount → SubscriptionInfo.BillingCustomerDiscount mapping
// (verified above, but confirming it made it through)
// Verify SubscriptionInfo.BillingCustomerDiscount → SubscriptionResponseModel.BillingCustomerDiscount filtering
// The filter should pass because:
// - includeMilestone2Discount = true (feature flag enabled)
// - subscription.CustomerDiscount != null
// - subscription.CustomerDiscount.Id == Milestone2SubscriptionDiscount
// - subscription.CustomerDiscount.Active = true
Assert.Equal(TestMilestone2CouponId, result.CustomerDiscount.Id);
Assert.True(result.CustomerDiscount.Active);
Assert.Equal(30m, result.CustomerDiscount.PercentOff);
Assert.Equal(20.00m, result.CustomerDiscount.AmountOff); // Verify cents-to-dollars conversion
// Verify AppliesTo products are preserved through the entire pipeline
Assert.NotNull(result.CustomerDiscount.AppliesTo);
Assert.Equal(3, result.CustomerDiscount.AppliesTo.Count());
Assert.Contains("prod_premium", result.CustomerDiscount.AppliesTo);
Assert.Contains("prod_families", result.CustomerDiscount.AppliesTo);
Assert.Contains("prod_teams", result.CustomerDiscount.AppliesTo);
// Verify the payment service was called correctly
await _paymentService.Received(1).GetSubscriptionAsync(user);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_IntegrationTest_MultipleDiscountsInSubscription_PrefersCustomerDiscount(
User user,
UserLicense license)
{
// Arrange - Create Stripe subscription with multiple discounts
// Customer discount should be preferred over subscription discounts
var customerDiscount = new Discount
{
Coupon = new Coupon
{
Id = TestMilestone2CouponId,
PercentOff = 30m,
AmountOff = null
},
End = null
};
var subscriptionDiscount1 = new Discount
{
Coupon = new Coupon
{
Id = "other-coupon-1",
PercentOff = 10m
},
End = null
};
var subscriptionDiscount2 = new Discount
{
Coupon = new Coupon
{
Id = "other-coupon-2",
PercentOff = 15m
},
End = null
};
// Map through SubscriptionInfo.BillingCustomerDiscount
var billingCustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(customerDiscount);
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = billingCustomerDiscount
};
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
user.Gateway = GatewayType.Stripe;
// Act
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
// Assert - Should use customer discount, not subscription discounts
Assert.NotNull(result);
Assert.NotNull(result.CustomerDiscount);
Assert.Equal(TestMilestone2CouponId, result.CustomerDiscount.Id);
Assert.Equal(30m, result.CustomerDiscount.PercentOff);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_IntegrationTest_BothPercentOffAndAmountOffPresent_HandlesEdgeCase(
User user,
UserLicense license)
{
// Arrange - Edge case: Stripe coupon with both PercentOff and AmountOff
// This tests the scenario mentioned in BillingCustomerDiscountTests.cs line 212-232
var stripeDiscount = new Discount
{
Coupon = new Coupon
{
Id = TestMilestone2CouponId,
PercentOff = 25m,
AmountOff = 2000, // 2000 cents = $20.00
AppliesTo = new CouponAppliesTo
{
Products = new List<string> { "prod_premium" }
}
},
End = null
};
// Map through SubscriptionInfo.BillingCustomerDiscount
var billingCustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount);
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = billingCustomerDiscount
};
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
user.Gateway = GatewayType.Stripe;
// Act
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
// Assert - Both values should be preserved through the pipeline
Assert.NotNull(result);
Assert.NotNull(result.CustomerDiscount);
Assert.Equal(TestMilestone2CouponId, result.CustomerDiscount.Id);
Assert.Equal(25m, result.CustomerDiscount.PercentOff);
Assert.Equal(20.00m, result.CustomerDiscount.AmountOff); // Converted from cents
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_IntegrationTest_BillingSubscriptionMapsThroughPipeline(
User user,
UserLicense license)
{
// Arrange - Create Stripe subscription with subscription details
var stripeSubscription = new Subscription
{
Id = "sub_test123",
Status = "active",
TrialStart = DateTime.UtcNow.AddDays(-30),
TrialEnd = DateTime.UtcNow.AddDays(-20),
CanceledAt = null,
CancelAtPeriodEnd = false,
CollectionMethod = "charge_automatically"
};
// Map through SubscriptionInfo.BillingSubscription
var billingSubscription = new SubscriptionInfo.BillingSubscription(stripeSubscription);
var subscriptionInfo = new SubscriptionInfo
{
Subscription = billingSubscription,
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = TestMilestone2CouponId,
Active = true,
PercentOff = 20m
}
};
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
user.Gateway = GatewayType.Stripe;
// Act
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
// Assert - Verify BillingSubscription mapped through pipeline
Assert.NotNull(result);
Assert.NotNull(result.Subscription);
Assert.Equal("active", result.Subscription.Status);
Assert.Equal(14, result.Subscription.GracePeriod); // charge_automatically = 14 days
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_IntegrationTest_BillingUpcomingInvoiceMapsThroughPipeline(
User user,
UserLicense license)
{
// Arrange - Create Stripe invoice for upcoming invoice
var stripeInvoice = new Invoice
{
AmountDue = 2000, // 2000 cents = $20.00
Created = DateTime.UtcNow.AddDays(1)
};
// Map through SubscriptionInfo.BillingUpcomingInvoice
var billingUpcomingInvoice = new SubscriptionInfo.BillingUpcomingInvoice(stripeInvoice);
var subscriptionInfo = new SubscriptionInfo
{
UpcomingInvoice = billingUpcomingInvoice,
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = TestMilestone2CouponId,
Active = true,
PercentOff = 20m
}
};
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
user.Gateway = GatewayType.Stripe;
// Act
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
// Assert - Verify BillingUpcomingInvoice mapped through pipeline
Assert.NotNull(result);
Assert.NotNull(result.UpcomingInvoice);
Assert.Equal(20.00m, result.UpcomingInvoice.Amount); // Converted from cents
Assert.NotNull(result.UpcomingInvoice.Date);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_IntegrationTest_CompletePipelineWithAllComponents(
User user,
UserLicense license)
{
// Arrange - Complete Stripe objects for full pipeline test
var stripeDiscount = new Discount
{
Coupon = new Coupon
{
Id = TestMilestone2CouponId,
PercentOff = 20m,
AmountOff = 1000, // $10.00
AppliesTo = new CouponAppliesTo
{
Products = new List<string> { "prod_premium", "prod_families" }
}
},
End = null
};
var stripeSubscription = new Subscription
{
Id = "sub_test123",
Status = "active",
CollectionMethod = "charge_automatically"
};
var stripeInvoice = new Invoice
{
AmountDue = 1500, // $15.00
Created = DateTime.UtcNow.AddDays(7)
};
// Map through SubscriptionInfo (simulating StripePaymentService)
var billingCustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount);
var billingSubscription = new SubscriptionInfo.BillingSubscription(stripeSubscription);
var billingUpcomingInvoice = new SubscriptionInfo.BillingUpcomingInvoice(stripeInvoice);
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = billingCustomerDiscount,
Subscription = billingSubscription,
UpcomingInvoice = billingUpcomingInvoice
};
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
user.Gateway = GatewayType.Stripe;
// Act - Full pipeline: Stripe → SubscriptionInfo → SubscriptionResponseModel → API response
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
// Assert - Verify all components mapped correctly through the pipeline
Assert.NotNull(result);
// Verify discount
Assert.NotNull(result.CustomerDiscount);
Assert.Equal(TestMilestone2CouponId, result.CustomerDiscount.Id);
Assert.Equal(20m, result.CustomerDiscount.PercentOff);
Assert.Equal(10.00m, result.CustomerDiscount.AmountOff);
Assert.NotNull(result.CustomerDiscount.AppliesTo);
Assert.Equal(2, result.CustomerDiscount.AppliesTo.Count());
// Verify subscription
Assert.NotNull(result.Subscription);
Assert.Equal("active", result.Subscription.Status);
Assert.Equal(14, result.Subscription.GracePeriod);
// Verify upcoming invoice
Assert.NotNull(result.UpcomingInvoice);
Assert.Equal(15.00m, result.UpcomingInvoice.Amount);
Assert.NotNull(result.UpcomingInvoice.Date);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_SelfHosted_WithDiscountFlagEnabled_NeverIncludesDiscount(User user)
{
// Arrange - Self-hosted user with discount flag enabled (should still return null)
var selfHostedSettings = new GlobalSettings { SelfHosted = true };
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); // Flag enabled
// Act
var result = await _sut.GetSubscriptionAsync(selfHostedSettings, _paymentService);
// Assert - Should never include discount for self-hosted, even with flag enabled
Assert.NotNull(result);
Assert.Null(result.CustomerDiscount);
await _paymentService.DidNotReceive().GetSubscriptionAsync(Arg.Any<User>());
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_NullGateway_WithDiscountFlagEnabled_NeverIncludesDiscount(
User user,
UserLicense license)
{
// Arrange - User with null gateway and discount flag enabled (should still return null)
user.Gateway = null; // No gateway configured
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
_sut.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_userService.GenerateLicenseAsync(user).Returns(license);
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); // Flag enabled
// Act
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
// Assert - Should never include discount when no gateway, even with flag enabled
Assert.NotNull(result);
Assert.Null(result.CustomerDiscount);
await _paymentService.DidNotReceive().GetSubscriptionAsync(Arg.Any<User>());
}
}

View File

@@ -0,0 +1,400 @@
using Bit.Api.Models.Response;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Models.Business;
using Bit.Core.Entities;
using Bit.Core.Models.Business;
using Bit.Test.Common.AutoFixture.Attributes;
using Stripe;
using Xunit;
namespace Bit.Api.Test.Models.Response;
public class SubscriptionResponseModelTests
{
[Theory]
[BitAutoData]
public void Constructor_IncludeMilestone2DiscountTrueMatchingCouponId_ReturnsDiscount(
User user,
UserLicense license)
{
// Arrange
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, // Matching coupon ID
Active = true,
PercentOff = 20m,
AmountOff = null,
AppliesTo = new List<string> { "product1" }
}
};
// Act
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true);
// Assert
Assert.NotNull(result.CustomerDiscount);
Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id);
Assert.True(result.CustomerDiscount.Active);
Assert.Equal(20m, result.CustomerDiscount.PercentOff);
Assert.Null(result.CustomerDiscount.AmountOff);
Assert.NotNull(result.CustomerDiscount.AppliesTo);
Assert.Single(result.CustomerDiscount.AppliesTo);
}
[Theory]
[BitAutoData]
public void Constructor_IncludeMilestone2DiscountTrueNonMatchingCouponId_ReturnsNull(
User user,
UserLicense license)
{
// Arrange
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = "different-coupon-id", // Non-matching coupon ID
Active = true,
PercentOff = 20m,
AmountOff = null,
AppliesTo = new List<string> { "product1" }
}
};
// Act
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true);
// Assert
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public void Constructor_IncludeMilestone2DiscountFalseMatchingCouponId_ReturnsNull(
User user,
UserLicense license)
{
// Arrange
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, // Matching coupon ID
Active = true,
PercentOff = 20m,
AmountOff = null,
AppliesTo = new List<string> { "product1" }
}
};
// Act
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: false);
// Assert - Should be null because includeMilestone2Discount is false
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public void Constructor_NullCustomerDiscount_ReturnsNull(
User user,
UserLicense license)
{
// Arrange
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = null
};
// Act
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true);
// Assert
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public void Constructor_AmountOffDiscountMatchingCouponId_ReturnsDiscount(
User user,
UserLicense license)
{
// Arrange
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount,
Active = true,
PercentOff = null,
AmountOff = 14.00m, // Already converted from cents in BillingCustomerDiscount
AppliesTo = new List<string>()
}
};
// Act
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true);
// Assert
Assert.NotNull(result.CustomerDiscount);
Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id);
Assert.Null(result.CustomerDiscount.PercentOff);
Assert.Equal(14.00m, result.CustomerDiscount.AmountOff);
}
[Theory]
[BitAutoData]
public void Constructor_DefaultIncludeMilestone2DiscountParameter_ReturnsNull(
User user,
UserLicense license)
{
// Arrange
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount,
Active = true,
PercentOff = 20m
}
};
// Act - Using default parameter (includeMilestone2Discount defaults to false)
var result = new SubscriptionResponseModel(user, subscriptionInfo, license);
// Assert
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public void Constructor_NullDiscountIdIncludeMilestone2DiscountTrue_ReturnsNull(
User user,
UserLicense license)
{
// Arrange
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = null, // Null discount ID
Active = true,
PercentOff = 20m,
AmountOff = null,
AppliesTo = new List<string> { "product1" }
}
};
// Act
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true);
// Assert
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public void Constructor_MatchingCouponIdInactiveDiscount_ReturnsNull(
User user,
UserLicense license)
{
// Arrange
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, // Matching coupon ID
Active = false, // Inactive discount
PercentOff = 20m,
AmountOff = null,
AppliesTo = new List<string> { "product1" }
}
};
// Act
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true);
// Assert
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public void Constructor_UserOnly_SetsBasicProperties(User user)
{
// Arrange
user.Storage = 5368709120; // 5 GB in bytes
user.MaxStorageGb = (short)10;
user.PremiumExpirationDate = DateTime.UtcNow.AddMonths(12);
// Act
var result = new SubscriptionResponseModel(user);
// Assert
Assert.NotNull(result.StorageName);
Assert.Equal(5.0, result.StorageGb);
Assert.Equal((short)10, result.MaxStorageGb);
Assert.Equal(user.PremiumExpirationDate, result.Expiration);
Assert.Null(result.License);
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public void Constructor_UserAndLicense_IncludesLicense(User user, UserLicense license)
{
// Arrange
user.Storage = 1073741824; // 1 GB in bytes
user.MaxStorageGb = (short)5;
// Act
var result = new SubscriptionResponseModel(user, license);
// Assert
Assert.NotNull(result.License);
Assert.Equal(license, result.License);
Assert.Equal(1.0, result.StorageGb);
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public void Constructor_NullStorage_SetsStorageToZero(User user)
{
// Arrange
user.Storage = null;
// Act
var result = new SubscriptionResponseModel(user);
// Assert
Assert.Null(result.StorageName);
Assert.Equal(0, result.StorageGb);
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public void Constructor_NullLicense_ExcludesLicense(User user)
{
// Act
var result = new SubscriptionResponseModel(user, null);
// Assert
Assert.Null(result.License);
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public void Constructor_BothPercentOffAndAmountOffPresent_HandlesEdgeCase(
User user,
UserLicense license)
{
// Arrange - Edge case: Both PercentOff and AmountOff present
// This tests the scenario where Stripe coupon has both discount types
var subscriptionInfo = new SubscriptionInfo
{
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount,
Active = true,
PercentOff = 25m,
AmountOff = 20.00m, // Already converted from cents
AppliesTo = new List<string> { "prod_premium" }
}
};
// Act
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true);
// Assert - Both values should be preserved
Assert.NotNull(result.CustomerDiscount);
Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id);
Assert.Equal(25m, result.CustomerDiscount.PercentOff);
Assert.Equal(20.00m, result.CustomerDiscount.AmountOff);
Assert.NotNull(result.CustomerDiscount.AppliesTo);
Assert.Single(result.CustomerDiscount.AppliesTo);
}
[Theory]
[BitAutoData]
public void Constructor_WithSubscriptionAndInvoice_MapsAllProperties(
User user,
UserLicense license)
{
// Arrange - Test with Subscription, UpcomingInvoice, and CustomerDiscount
var stripeSubscription = new Subscription
{
Id = "sub_test123",
Status = "active",
CollectionMethod = "charge_automatically"
};
var stripeInvoice = new Invoice
{
AmountDue = 1500, // 1500 cents = $15.00
Created = DateTime.UtcNow.AddDays(7)
};
var subscriptionInfo = new SubscriptionInfo
{
Subscription = new SubscriptionInfo.BillingSubscription(stripeSubscription),
UpcomingInvoice = new SubscriptionInfo.BillingUpcomingInvoice(stripeInvoice),
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount,
Active = true,
PercentOff = 20m,
AmountOff = null,
AppliesTo = new List<string> { "prod_premium" }
}
};
// Act
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true);
// Assert - Verify all properties are mapped correctly
Assert.NotNull(result.Subscription);
Assert.Equal("active", result.Subscription.Status);
Assert.Equal(14, result.Subscription.GracePeriod); // charge_automatically = 14 days
Assert.NotNull(result.UpcomingInvoice);
Assert.Equal(15.00m, result.UpcomingInvoice.Amount);
Assert.NotNull(result.UpcomingInvoice.Date);
Assert.NotNull(result.CustomerDiscount);
Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id);
Assert.True(result.CustomerDiscount.Active);
Assert.Equal(20m, result.CustomerDiscount.PercentOff);
}
[Theory]
[BitAutoData]
public void Constructor_WithNullSubscriptionAndInvoice_HandlesNullsGracefully(
User user,
UserLicense license)
{
// Arrange - Test with null Subscription and UpcomingInvoice
var subscriptionInfo = new SubscriptionInfo
{
Subscription = null,
UpcomingInvoice = null,
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
{
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount,
Active = true,
PercentOff = 20m
}
};
// Act
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true);
// Assert - Null Subscription and UpcomingInvoice should be handled gracefully
Assert.Null(result.Subscription);
Assert.Null(result.UpcomingInvoice);
Assert.NotNull(result.CustomerDiscount);
}
}

View File

@@ -0,0 +1,497 @@
using Bit.Core.Models.Business;
using Bit.Test.Common.AutoFixture.Attributes;
using Stripe;
using Xunit;
namespace Bit.Core.Test.Models.Business;
public class BillingCustomerDiscountTests
{
[Theory]
[BitAutoData]
public void Constructor_PercentageDiscount_SetsIdActivePercentOffAndAppliesTo(string couponId)
{
// Arrange
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = 25.5m,
AmountOff = null,
AppliesTo = new CouponAppliesTo
{
Products = new List<string> { "product1", "product2" }
}
},
End = null // Active discount
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Equal(couponId, result.Id);
Assert.True(result.Active);
Assert.Equal(25.5m, result.PercentOff);
Assert.Null(result.AmountOff);
Assert.NotNull(result.AppliesTo);
Assert.Equal(2, result.AppliesTo.Count);
Assert.Contains("product1", result.AppliesTo);
Assert.Contains("product2", result.AppliesTo);
}
[Theory]
[BitAutoData]
public void Constructor_AmountDiscount_ConvertsFromCentsToDollars(string couponId)
{
// Arrange - Stripe sends 1400 cents for $14.00
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = null,
AmountOff = 1400, // 1400 cents
AppliesTo = new CouponAppliesTo
{
Products = new List<string>()
}
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Equal(couponId, result.Id);
Assert.True(result.Active);
Assert.Null(result.PercentOff);
Assert.Equal(14.00m, result.AmountOff); // Converted to dollars
Assert.NotNull(result.AppliesTo);
Assert.Empty(result.AppliesTo);
}
[Theory]
[BitAutoData]
public void Constructor_InactiveDiscount_SetsActiveToFalse(string couponId)
{
// Arrange
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = 15m
},
End = DateTime.UtcNow.AddDays(-1) // Expired discount
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Equal(couponId, result.Id);
Assert.False(result.Active);
Assert.Equal(15m, result.PercentOff);
}
[Fact]
public void Constructor_NullCoupon_SetsDiscountPropertiesToNull()
{
// Arrange
var discount = new Discount
{
Coupon = null,
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Null(result.Id);
Assert.True(result.Active);
Assert.Null(result.PercentOff);
Assert.Null(result.AmountOff);
Assert.Null(result.AppliesTo);
}
[Theory]
[BitAutoData]
public void Constructor_NullAmountOff_SetsAmountOffToNull(string couponId)
{
// Arrange
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = 10m,
AmountOff = null
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Null(result.AmountOff);
}
[Theory]
[BitAutoData]
public void Constructor_ZeroAmountOff_ConvertsCorrectly(string couponId)
{
// Arrange
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
AmountOff = 0
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Equal(0m, result.AmountOff);
}
[Theory]
[BitAutoData]
public void Constructor_LargeAmountOff_ConvertsCorrectly(string couponId)
{
// Arrange - $100.00 discount
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
AmountOff = 10000 // 10000 cents = $100.00
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Equal(100.00m, result.AmountOff);
}
[Theory]
[BitAutoData]
public void Constructor_SmallAmountOff_ConvertsCorrectly(string couponId)
{
// Arrange - $0.50 discount
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
AmountOff = 50 // 50 cents = $0.50
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Equal(0.50m, result.AmountOff);
}
[Theory]
[BitAutoData]
public void Constructor_BothDiscountTypes_SetsPercentOffAndAmountOff(string couponId)
{
// Arrange - Coupon with both percentage and amount (edge case)
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = 20m,
AmountOff = 500 // $5.00
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Equal(20m, result.PercentOff);
Assert.Equal(5.00m, result.AmountOff);
}
[Theory]
[BitAutoData]
public void Constructor_WithNullAppliesTo_SetsAppliesToNull(string couponId)
{
// Arrange
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = 10m,
AppliesTo = null
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Null(result.AppliesTo);
}
[Theory]
[BitAutoData]
public void Constructor_WithNullProductsList_SetsAppliesToNull(string couponId)
{
// Arrange
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = 10m,
AppliesTo = new CouponAppliesTo
{
Products = null
}
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Null(result.AppliesTo);
}
[Theory]
[BitAutoData]
public void Constructor_WithDecimalAmountOff_RoundsCorrectly(string couponId)
{
// Arrange - 1425 cents = $14.25
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
AmountOff = 1425
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Equal(14.25m, result.AmountOff);
}
[Fact]
public void Constructor_DefaultConstructor_InitializesAllPropertiesToNullOrFalse()
{
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount();
// Assert
Assert.Null(result.Id);
Assert.False(result.Active);
Assert.Null(result.PercentOff);
Assert.Null(result.AmountOff);
Assert.Null(result.AppliesTo);
}
[Theory]
[BitAutoData]
public void Constructor_WithFutureEndDate_SetsActiveToFalse(string couponId)
{
// Arrange - Discount expires in the future
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = 20m
},
End = DateTime.UtcNow.AddDays(30) // Expires in 30 days
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.False(result.Active); // Should be inactive because End is not null
}
[Theory]
[BitAutoData]
public void Constructor_WithPastEndDate_SetsActiveToFalse(string couponId)
{
// Arrange - Discount already expired
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = 20m
},
End = DateTime.UtcNow.AddDays(-30) // Expired 30 days ago
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.False(result.Active); // Should be inactive because End is not null
}
[Fact]
public void Constructor_WithNullCouponId_SetsIdToNull()
{
// Arrange
var discount = new Discount
{
Coupon = new Coupon
{
Id = null,
PercentOff = 20m
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Null(result.Id);
Assert.True(result.Active);
Assert.Equal(20m, result.PercentOff);
}
[Theory]
[BitAutoData]
public void Constructor_WithNullPercentOff_SetsPercentOffToNull(string couponId)
{
// Arrange
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = null,
AmountOff = 1000
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Null(result.PercentOff);
Assert.Equal(10.00m, result.AmountOff);
}
[Fact]
public void Constructor_WithCompleteStripeDiscount_MapsAllProperties()
{
// Arrange - Comprehensive test with all Stripe Discount properties set
var discount = new Discount
{
Coupon = new Coupon
{
Id = "premium_discount_2024",
PercentOff = 25m,
AmountOff = 1500, // $15.00
AppliesTo = new CouponAppliesTo
{
Products = new List<string> { "prod_premium", "prod_family", "prod_teams" }
}
},
End = null // Active
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert - Verify all properties mapped correctly
Assert.Equal("premium_discount_2024", result.Id);
Assert.True(result.Active);
Assert.Equal(25m, result.PercentOff);
Assert.Equal(15.00m, result.AmountOff);
Assert.NotNull(result.AppliesTo);
Assert.Equal(3, result.AppliesTo.Count);
Assert.Contains("prod_premium", result.AppliesTo);
Assert.Contains("prod_family", result.AppliesTo);
Assert.Contains("prod_teams", result.AppliesTo);
}
[Fact]
public void Constructor_WithMinimalStripeDiscount_HandlesNullsGracefully()
{
// Arrange - Minimal Stripe Discount with most properties null
var discount = new Discount
{
Coupon = new Coupon
{
Id = null,
PercentOff = null,
AmountOff = null,
AppliesTo = null
},
End = DateTime.UtcNow.AddDays(10) // Has end date
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert - Should handle all nulls gracefully
Assert.Null(result.Id);
Assert.False(result.Active);
Assert.Null(result.PercentOff);
Assert.Null(result.AmountOff);
Assert.Null(result.AppliesTo);
}
[Theory]
[BitAutoData]
public void Constructor_WithEmptyProductsList_PreservesEmptyList(string couponId)
{
// Arrange
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = 10m,
AppliesTo = new CouponAppliesTo
{
Products = new List<string>() // Empty but not null
}
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.NotNull(result.AppliesTo);
Assert.Empty(result.AppliesTo);
}
}

View File

@@ -0,0 +1,125 @@
using Bit.Core.Models.Business;
using Stripe;
using Xunit;
namespace Bit.Core.Test.Models.Business;
public class SubscriptionInfoTests
{
[Fact]
public void BillingSubscriptionItem_NullPlan_HandlesGracefully()
{
// Arrange - SubscriptionItem with null Plan
var subscriptionItem = new SubscriptionItem
{
Plan = null,
Quantity = 1
};
// Act
var result = new SubscriptionInfo.BillingSubscription.BillingSubscriptionItem(subscriptionItem);
// Assert - Should handle null Plan gracefully
Assert.Null(result.ProductId);
Assert.Null(result.Name);
Assert.Equal(0m, result.Amount); // Defaults to 0 when Plan is null
Assert.Null(result.Interval);
Assert.Equal(1, result.Quantity);
Assert.False(result.SponsoredSubscriptionItem);
Assert.False(result.AddonSubscriptionItem);
}
[Fact]
public void BillingSubscriptionItem_NullAmount_SetsToZero()
{
// Arrange - SubscriptionItem with Plan but null Amount
var subscriptionItem = new SubscriptionItem
{
Plan = new Plan
{
ProductId = "prod_test",
Nickname = "Test Plan",
Amount = null, // Null amount
Interval = "month"
},
Quantity = 1
};
// Act
var result = new SubscriptionInfo.BillingSubscription.BillingSubscriptionItem(subscriptionItem);
// Assert - Should default to 0 when Amount is null
Assert.Equal("prod_test", result.ProductId);
Assert.Equal("Test Plan", result.Name);
Assert.Equal(0m, result.Amount); // Business rule: defaults to 0 when null
Assert.Equal("month", result.Interval);
Assert.Equal(1, result.Quantity);
}
[Fact]
public void BillingSubscriptionItem_ZeroAmount_PreservesZero()
{
// Arrange - SubscriptionItem with Plan and zero Amount
var subscriptionItem = new SubscriptionItem
{
Plan = new Plan
{
ProductId = "prod_test",
Nickname = "Test Plan",
Amount = 0, // Zero amount (0 cents)
Interval = "month"
},
Quantity = 1
};
// Act
var result = new SubscriptionInfo.BillingSubscription.BillingSubscriptionItem(subscriptionItem);
// Assert - Should preserve zero amount
Assert.Equal("prod_test", result.ProductId);
Assert.Equal("Test Plan", result.Name);
Assert.Equal(0m, result.Amount); // Zero amount preserved
Assert.Equal("month", result.Interval);
}
[Fact]
public void BillingUpcomingInvoice_ZeroAmountDue_ConvertsToZero()
{
// Arrange - Invoice with zero AmountDue
// Note: Stripe's Invoice.AmountDue is non-nullable long, so we test with 0
// The null-coalescing operator (?? 0) in the constructor handles the case where
// ConvertFromStripeMinorUnits returns null, but since AmountDue is non-nullable,
// this test verifies the conversion path works correctly for zero values
var invoice = new Invoice
{
AmountDue = 0, // Zero amount due (0 cents)
Created = DateTime.UtcNow
};
// Act
var result = new SubscriptionInfo.BillingUpcomingInvoice(invoice);
// Assert - Should convert zero correctly
Assert.Equal(0m, result.Amount);
Assert.NotNull(result.Date);
}
[Fact]
public void BillingUpcomingInvoice_ValidAmountDue_ConvertsCorrectly()
{
// Arrange - Invoice with valid AmountDue
var invoice = new Invoice
{
AmountDue = 2500, // 2500 cents = $25.00
Created = DateTime.UtcNow
};
// Act
var result = new SubscriptionInfo.BillingUpcomingInvoice(invoice);
// Assert - Should convert correctly
Assert.Equal(25.00m, result.Amount); // Converted from cents
Assert.NotNull(result.Date);
}
}

View File

@@ -3,6 +3,7 @@ using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models.StaticStore.Plans;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Tax.Requests;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
@@ -515,4 +516,399 @@ public class StripePaymentServiceTests
options.CustomerDetails.TaxExempt == StripeConstants.TaxExempt.Reverse
));
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WithCustomerDiscount_ReturnsDiscountFromCustomer(
SutProvider<StripePaymentService> sutProvider,
User subscriber)
{
// Arrange
subscriber.Gateway = GatewayType.Stripe;
subscriber.GatewayCustomerId = "cus_test123";
subscriber.GatewaySubscriptionId = "sub_test123";
var customerDiscount = new Discount
{
Coupon = new Coupon
{
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount,
PercentOff = 20m,
AmountOff = 1400
},
End = null
};
var subscription = new Subscription
{
Id = "sub_test123",
Status = "active",
CollectionMethod = "charge_automatically",
Customer = new Customer
{
Discount = customerDiscount
},
Discounts = new List<Discount>(), // Empty list
Items = new StripeList<SubscriptionItem> { Data = [] }
};
sutProvider.GetDependency<IStripeAdapter>()
.SubscriptionGetAsync(
subscriber.GatewaySubscriptionId,
Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
// Act
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
// Assert
Assert.NotNull(result.CustomerDiscount);
Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id);
Assert.Equal(20m, result.CustomerDiscount.PercentOff);
Assert.Equal(14.00m, result.CustomerDiscount.AmountOff); // Converted from cents
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WithoutCustomerDiscount_FallsBackToSubscriptionDiscounts(
SutProvider<StripePaymentService> sutProvider,
User subscriber)
{
// Arrange
subscriber.Gateway = GatewayType.Stripe;
subscriber.GatewayCustomerId = "cus_test123";
subscriber.GatewaySubscriptionId = "sub_test123";
var subscriptionDiscount = new Discount
{
Coupon = new Coupon
{
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount,
PercentOff = 15m,
AmountOff = null
},
End = null
};
var subscription = new Subscription
{
Id = "sub_test123",
Status = "active",
CollectionMethod = "charge_automatically",
Customer = new Customer
{
Discount = null // No customer discount
},
Discounts = new List<Discount> { subscriptionDiscount },
Items = new StripeList<SubscriptionItem> { Data = [] }
};
sutProvider.GetDependency<IStripeAdapter>()
.SubscriptionGetAsync(
subscriber.GatewaySubscriptionId,
Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
// Act
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
// Assert - Should use subscription discount as fallback
Assert.NotNull(result.CustomerDiscount);
Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id);
Assert.Equal(15m, result.CustomerDiscount.PercentOff);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WithBothDiscounts_PrefersCustomerDiscount(
SutProvider<StripePaymentService> sutProvider,
User subscriber)
{
// Arrange
subscriber.Gateway = GatewayType.Stripe;
subscriber.GatewayCustomerId = "cus_test123";
subscriber.GatewaySubscriptionId = "sub_test123";
var customerDiscount = new Discount
{
Coupon = new Coupon
{
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount,
PercentOff = 25m
},
End = null
};
var subscriptionDiscount = new Discount
{
Coupon = new Coupon
{
Id = "different-coupon-id",
PercentOff = 10m
},
End = null
};
var subscription = new Subscription
{
Id = "sub_test123",
Status = "active",
CollectionMethod = "charge_automatically",
Customer = new Customer
{
Discount = customerDiscount // Should prefer this
},
Discounts = new List<Discount> { subscriptionDiscount },
Items = new StripeList<SubscriptionItem> { Data = [] }
};
sutProvider.GetDependency<IStripeAdapter>()
.SubscriptionGetAsync(
subscriber.GatewaySubscriptionId,
Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
// Act
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
// Assert - Should prefer customer discount over subscription discount
Assert.NotNull(result.CustomerDiscount);
Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id);
Assert.Equal(25m, result.CustomerDiscount.PercentOff);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WithNoDiscounts_ReturnsNullDiscount(
SutProvider<StripePaymentService> sutProvider,
User subscriber)
{
// Arrange
subscriber.Gateway = GatewayType.Stripe;
subscriber.GatewayCustomerId = "cus_test123";
subscriber.GatewaySubscriptionId = "sub_test123";
var subscription = new Subscription
{
Id = "sub_test123",
Status = "active",
CollectionMethod = "charge_automatically",
Customer = new Customer
{
Discount = null
},
Discounts = new List<Discount>(), // Empty list, no discounts
Items = new StripeList<SubscriptionItem> { Data = [] }
};
sutProvider.GetDependency<IStripeAdapter>()
.SubscriptionGetAsync(
subscriber.GatewaySubscriptionId,
Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
// Act
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
// Assert
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WithMultipleSubscriptionDiscounts_SelectsFirstDiscount(
SutProvider<StripePaymentService> sutProvider,
User subscriber)
{
// Arrange - Multiple subscription-level discounts, no customer discount
subscriber.Gateway = GatewayType.Stripe;
subscriber.GatewayCustomerId = "cus_test123";
subscriber.GatewaySubscriptionId = "sub_test123";
var firstDiscount = new Discount
{
Coupon = new Coupon
{
Id = "coupon-10-percent",
PercentOff = 10m
},
End = null
};
var secondDiscount = new Discount
{
Coupon = new Coupon
{
Id = "coupon-20-percent",
PercentOff = 20m
},
End = null
};
var subscription = new Subscription
{
Id = "sub_test123",
Status = "active",
CollectionMethod = "charge_automatically",
Customer = new Customer
{
Discount = null // No customer discount
},
// Multiple subscription discounts - FirstOrDefault() should select the first one
Discounts = new List<Discount> { firstDiscount, secondDiscount },
Items = new StripeList<SubscriptionItem> { Data = [] }
};
sutProvider.GetDependency<IStripeAdapter>()
.SubscriptionGetAsync(
subscriber.GatewaySubscriptionId,
Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
// Act
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
// Assert - Should select the first discount from the list (FirstOrDefault() behavior)
Assert.NotNull(result.CustomerDiscount);
Assert.Equal("coupon-10-percent", result.CustomerDiscount.Id);
Assert.Equal(10m, result.CustomerDiscount.PercentOff);
// Verify the second discount was not selected
Assert.NotEqual("coupon-20-percent", result.CustomerDiscount.Id);
Assert.NotEqual(20m, result.CustomerDiscount.PercentOff);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WithNullCustomer_HandlesGracefully(
SutProvider<StripePaymentService> sutProvider,
User subscriber)
{
// Arrange - Subscription with null Customer (defensive null check scenario)
subscriber.Gateway = GatewayType.Stripe;
subscriber.GatewayCustomerId = "cus_test123";
subscriber.GatewaySubscriptionId = "sub_test123";
var subscription = new Subscription
{
Id = "sub_test123",
Status = "active",
CollectionMethod = "charge_automatically",
Customer = null, // Customer not expanded or null
Discounts = new List<Discount>(), // Empty discounts
Items = new StripeList<SubscriptionItem> { Data = [] }
};
sutProvider.GetDependency<IStripeAdapter>()
.SubscriptionGetAsync(
subscriber.GatewaySubscriptionId,
Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
// Act
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
// Assert - Should handle null Customer gracefully without throwing NullReferenceException
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WithNullDiscounts_HandlesGracefully(
SutProvider<StripePaymentService> sutProvider,
User subscriber)
{
// Arrange - Subscription with null Discounts (defensive null check scenario)
subscriber.Gateway = GatewayType.Stripe;
subscriber.GatewayCustomerId = "cus_test123";
subscriber.GatewaySubscriptionId = "sub_test123";
var subscription = new Subscription
{
Id = "sub_test123",
Status = "active",
CollectionMethod = "charge_automatically",
Customer = new Customer
{
Discount = null // No customer discount
},
Discounts = null, // Discounts not expanded or null
Items = new StripeList<SubscriptionItem> { Data = [] }
};
sutProvider.GetDependency<IStripeAdapter>()
.SubscriptionGetAsync(
subscriber.GatewaySubscriptionId,
Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
// Act
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
// Assert - Should handle null Discounts gracefully without throwing NullReferenceException
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_VerifiesCorrectExpandOptions(
SutProvider<StripePaymentService> sutProvider,
User subscriber)
{
// Arrange
subscriber.Gateway = GatewayType.Stripe;
subscriber.GatewayCustomerId = "cus_test123";
subscriber.GatewaySubscriptionId = "sub_test123";
var subscription = new Subscription
{
Id = "sub_test123",
Status = "active",
CollectionMethod = "charge_automatically",
Customer = new Customer { Discount = null },
Discounts = new List<Discount>(), // Empty list
Items = new StripeList<SubscriptionItem> { Data = [] }
};
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter
.SubscriptionGetAsync(
Arg.Any<string>(),
Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
// Act
await sutProvider.Sut.GetSubscriptionAsync(subscriber);
// Assert - Verify expand options are correct
await stripeAdapter.Received(1).SubscriptionGetAsync(
subscriber.GatewaySubscriptionId,
Arg.Is<SubscriptionGetOptions>(o =>
o.Expand.Contains("customer.discount.coupon.applies_to") &&
o.Expand.Contains("discounts.coupon.applies_to") &&
o.Expand.Contains("test_clock")));
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WithEmptyGatewaySubscriptionId_ReturnsEmptySubscriptionInfo(
SutProvider<StripePaymentService> sutProvider,
User subscriber)
{
// Arrange
subscriber.GatewaySubscriptionId = null;
// Act
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
// Assert
Assert.NotNull(result);
Assert.Null(result.Subscription);
Assert.Null(result.CustomerDiscount);
Assert.Null(result.UpcomingInvoice);
// Verify no Stripe API calls were made
await sutProvider.GetDependency<IStripeAdapter>()
.DidNotReceive()
.SubscriptionGetAsync(Arg.Any<string>(), Arg.Any<SubscriptionGetOptions>());
}
}