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:
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user