1
0
mirror of https://github.com/bitwarden/server synced 2025-12-31 07:33:43 +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

@@ -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)
{