Skip to content

Commit f0ec201

Browse files
[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
1 parent de90108 commit f0ec201

10 files changed

Lines changed: 2460 additions & 59 deletions

File tree

src/Api/Billing/Controllers/AccountsController.cs

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using Bit.Api.Models.Request.Accounts;
55
using Bit.Api.Models.Response;
66
using Bit.Api.Utilities;
7+
using Bit.Core;
78
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
89
using Bit.Core.Billing.Models;
910
using Bit.Core.Billing.Models.Business;
@@ -24,7 +25,8 @@ namespace Bit.Api.Billing.Controllers;
2425
public class AccountsController(
2526
IUserService userService,
2627
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
27-
IUserAccountKeysQuery userAccountKeysQuery) : Controller
28+
IUserAccountKeysQuery userAccountKeysQuery,
29+
IFeatureService featureService) : Controller
2830
{
2931
[HttpPost("premium")]
3032
public async Task<PaymentResponseModel> PostPremiumAsync(
@@ -84,16 +86,24 @@ public async Task<SubscriptionResponseModel> GetSubscriptionAsync(
8486
throw new UnauthorizedAccessException();
8587
}
8688

87-
if (!globalSettings.SelfHosted && user.Gateway != null)
89+
// Only cloud-hosted users with payment gateways have subscription and discount information
90+
if (!globalSettings.SelfHosted)
8891
{
89-
var subscriptionInfo = await paymentService.GetSubscriptionAsync(user);
90-
var license = await userService.GenerateLicenseAsync(user, subscriptionInfo);
91-
return new SubscriptionResponseModel(user, subscriptionInfo, license);
92-
}
93-
else if (!globalSettings.SelfHosted)
94-
{
95-
var license = await userService.GenerateLicenseAsync(user);
96-
return new SubscriptionResponseModel(user, license);
92+
if (user.Gateway != null)
93+
{
94+
// Note: PM23341_Milestone_2 is the feature flag for the overall Milestone 2 initiative (PM-23341).
95+
// This specific implementation (PM-26682) adds discount display functionality as part of that initiative.
96+
// The feature flag controls the broader Milestone 2 feature set, not just this specific task.
97+
var includeMilestone2Discount = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2);
98+
var subscriptionInfo = await paymentService.GetSubscriptionAsync(user);
99+
var license = await userService.GenerateLicenseAsync(user, subscriptionInfo);
100+
return new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount);
101+
}
102+
else
103+
{
104+
var license = await userService.GenerateLicenseAsync(user);
105+
return new SubscriptionResponseModel(user, license);
106+
}
97107
}
98108
else
99109
{

src/Api/Models/Response/SubscriptionResponseModel.cs

Lines changed: 120 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
// FIXME: Update this file to be null safe and then delete the line below
2-
#nullable disable
3-
1+
using Bit.Core.Billing.Constants;
42
using Bit.Core.Billing.Models.Business;
53
using Bit.Core.Entities;
64
using Bit.Core.Models.Api;
@@ -11,7 +9,17 @@ namespace Bit.Api.Models.Response;
119

1210
public class SubscriptionResponseModel : ResponseModel
1311
{
14-
public SubscriptionResponseModel(User user, SubscriptionInfo subscription, UserLicense license)
12+
13+
/// <param name="user">The user entity containing storage and premium subscription information</param>
14+
/// <param name="subscription">Subscription information retrieved from the payment provider (Stripe/Braintree)</param>
15+
/// <param name="license">The user's license containing expiration and feature entitlements</param>
16+
/// <param name="includeMilestone2Discount">
17+
/// Whether to include discount information in the response.
18+
/// Set to true when the PM23341_Milestone_2 feature flag is enabled AND
19+
/// you want to expose Milestone 2 discount information to the client.
20+
/// The discount will only be included if it matches the specific Milestone 2 coupon ID.
21+
/// </param>
22+
public SubscriptionResponseModel(User user, SubscriptionInfo subscription, UserLicense license, bool includeMilestone2Discount = false)
1523
: base("subscription")
1624
{
1725
Subscription = subscription.Subscription != null ? new BillingSubscription(subscription.Subscription) : null;
@@ -22,9 +30,14 @@ public SubscriptionResponseModel(User user, SubscriptionInfo subscription, UserL
2230
MaxStorageGb = user.MaxStorageGb;
2331
License = license;
2432
Expiration = License.Expires;
33+
34+
// Only display the Milestone 2 subscription discount on the subscription page.
35+
CustomerDiscount = ShouldIncludeMilestone2Discount(includeMilestone2Discount, subscription.CustomerDiscount)
36+
? new BillingCustomerDiscount(subscription.CustomerDiscount!)
37+
: null;
2538
}
2639

27-
public SubscriptionResponseModel(User user, UserLicense license = null)
40+
public SubscriptionResponseModel(User user, UserLicense? license = null)
2841
: base("subscription")
2942
{
3043
StorageName = user.Storage.HasValue ? CoreHelpers.ReadableBytesSize(user.Storage.Value) : null;
@@ -38,21 +51,109 @@ public SubscriptionResponseModel(User user, UserLicense license = null)
3851
}
3952
}
4053

41-
public string StorageName { get; set; }
54+
public string? StorageName { get; set; }
4255
public double? StorageGb { get; set; }
4356
public short? MaxStorageGb { get; set; }
44-
public BillingSubscriptionUpcomingInvoice UpcomingInvoice { get; set; }
45-
public BillingSubscription Subscription { get; set; }
46-
public UserLicense License { get; set; }
57+
public BillingSubscriptionUpcomingInvoice? UpcomingInvoice { get; set; }
58+
public BillingSubscription? Subscription { get; set; }
59+
/// <summary>
60+
/// Customer discount information from Stripe for the Milestone 2 subscription discount.
61+
/// Only includes the specific Milestone 2 coupon (cm3nHfO1) when it's a perpetual discount (no expiration).
62+
/// This is for display purposes only and does not affect Stripe's automatic discount application.
63+
/// Other discounts may still apply in Stripe billing but are not included in this response.
64+
/// <para>
65+
/// Null when:
66+
/// - The PM23341_Milestone_2 feature flag is disabled
67+
/// - There is no active discount
68+
/// - The discount coupon ID doesn't match the Milestone 2 coupon (cm3nHfO1)
69+
/// - The instance is self-hosted
70+
/// </para>
71+
/// </summary>
72+
public BillingCustomerDiscount? CustomerDiscount { get; set; }
73+
public UserLicense? License { get; set; }
4774
public DateTime? Expiration { get; set; }
75+
76+
/// <summary>
77+
/// Determines whether the Milestone 2 discount should be included in the response.
78+
/// </summary>
79+
/// <param name="includeMilestone2Discount">Whether the feature flag is enabled and discount should be considered.</param>
80+
/// <param name="customerDiscount">The customer discount from subscription info, if any.</param>
81+
/// <returns>True if the discount should be included; false otherwise.</returns>
82+
private static bool ShouldIncludeMilestone2Discount(
83+
bool includeMilestone2Discount,
84+
SubscriptionInfo.BillingCustomerDiscount? customerDiscount)
85+
{
86+
return includeMilestone2Discount &&
87+
customerDiscount != null &&
88+
customerDiscount.Id == StripeConstants.CouponIDs.Milestone2SubscriptionDiscount &&
89+
customerDiscount.Active;
90+
}
4891
}
4992

50-
public class BillingCustomerDiscount(SubscriptionInfo.BillingCustomerDiscount discount)
93+
/// <summary>
94+
/// Customer discount information from Stripe billing.
95+
/// </summary>
96+
public class BillingCustomerDiscount
5197
{
52-
public string Id { get; } = discount.Id;
53-
public bool Active { get; } = discount.Active;
54-
public decimal? PercentOff { get; } = discount.PercentOff;
55-
public List<string> AppliesTo { get; } = discount.AppliesTo;
98+
/// <summary>
99+
/// The Stripe coupon ID (e.g., "cm3nHfO1").
100+
/// </summary>
101+
public string? Id { get; }
102+
103+
/// <summary>
104+
/// Whether the discount is a recurring/perpetual discount with no expiration date.
105+
/// <para>
106+
/// This property is true only when the discount has no end date, meaning it applies
107+
/// indefinitely to all future renewals. This is a product decision for Milestone 2
108+
/// to only display perpetual discounts in the UI.
109+
/// </para>
110+
/// <para>
111+
/// Note: This does NOT indicate whether the discount is "currently active" in the billing sense.
112+
/// A discount with a future end date is functionally active and will be applied by Stripe,
113+
/// but this property will be false because it has an expiration date.
114+
/// </para>
115+
/// </summary>
116+
public bool Active { get; }
117+
118+
/// <summary>
119+
/// Percentage discount applied to the subscription (e.g., 20.0 for 20% off).
120+
/// Null if this is an amount-based discount.
121+
/// </summary>
122+
public decimal? PercentOff { get; }
123+
124+
/// <summary>
125+
/// Fixed amount discount in USD (e.g., 14.00 for $14 off).
126+
/// Converted from Stripe's cent-based values (1400 cents → $14.00).
127+
/// Null if this is a percentage-based discount.
128+
/// Note: Stripe stores amounts in the smallest currency unit. This value is always in USD.
129+
/// </summary>
130+
public decimal? AmountOff { get; }
131+
132+
/// <summary>
133+
/// List of Stripe product IDs that this discount applies to (e.g., ["prod_premium", "prod_families"]).
134+
/// <para>
135+
/// Null: discount applies to all products with no restrictions (AppliesTo not specified in Stripe).
136+
/// Empty list: discount restricted to zero products (edge case - AppliesTo.Products = [] in Stripe).
137+
/// Non-empty list: discount applies only to the specified product IDs.
138+
/// </para>
139+
/// </summary>
140+
public IReadOnlyList<string>? AppliesTo { get; }
141+
142+
/// <summary>
143+
/// Creates a BillingCustomerDiscount from a SubscriptionInfo.BillingCustomerDiscount.
144+
/// </summary>
145+
/// <param name="discount">The discount to convert. Must not be null.</param>
146+
/// <exception cref="ArgumentNullException">Thrown when discount is null.</exception>
147+
public BillingCustomerDiscount(SubscriptionInfo.BillingCustomerDiscount discount)
148+
{
149+
ArgumentNullException.ThrowIfNull(discount);
150+
151+
Id = discount.Id;
152+
Active = discount.Active;
153+
PercentOff = discount.PercentOff;
154+
AmountOff = discount.AmountOff;
155+
AppliesTo = discount.AppliesTo;
156+
}
56157
}
57158

58159
public class BillingSubscription
@@ -83,10 +184,10 @@ public BillingSubscription(SubscriptionInfo.BillingSubscription sub)
83184
public DateTime? PeriodEndDate { get; set; }
84185
public DateTime? CancelledDate { get; set; }
85186
public bool CancelAtEndDate { get; set; }
86-
public string Status { get; set; }
187+
public string? Status { get; set; }
87188
public bool Cancelled { get; set; }
88189
public IEnumerable<BillingSubscriptionItem> Items { get; set; } = new List<BillingSubscriptionItem>();
89-
public string CollectionMethod { get; set; }
190+
public string? CollectionMethod { get; set; }
90191
public DateTime? SuspensionDate { get; set; }
91192
public DateTime? UnpaidPeriodEndDate { get; set; }
92193
public int? GracePeriod { get; set; }
@@ -104,11 +205,11 @@ public BillingSubscriptionItem(SubscriptionInfo.BillingSubscription.BillingSubsc
104205
AddonSubscriptionItem = item.AddonSubscriptionItem;
105206
}
106207

107-
public string ProductId { get; set; }
108-
public string Name { get; set; }
208+
public string? ProductId { get; set; }
209+
public string? Name { get; set; }
109210
public decimal Amount { get; set; }
110211
public int Quantity { get; set; }
111-
public string Interval { get; set; }
212+
public string? Interval { get; set; }
112213
public bool SponsoredSubscriptionItem { get; set; }
113214
public bool AddonSubscriptionItem { get; set; }
114215
}

src/Core/Billing/Constants/StripeConstants.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ public static class CouponIDs
2222
{
2323
public const string LegacyMSPDiscount = "msp-discount-35";
2424
public const string SecretsManagerStandalone = "sm-standalone";
25-
public const string Milestone2SubscriptionDiscount = "cm3nHfO1";
25+
public const string Milestone2SubscriptionDiscount = "milestone-2c";
2626

2727
public static class MSPDiscounts
2828
{

0 commit comments

Comments
 (0)