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 ;
42using Bit . Core . Billing . Models . Business ;
53using Bit . Core . Entities ;
64using Bit . Core . Models . Api ;
@@ -11,7 +9,17 @@ namespace Bit.Api.Models.Response;
119
1210public 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
58159public 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 }
0 commit comments