Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ public class AccountBillingVNextController(
IReinstateSubscriptionCommand reinstateSubscriptionCommand,
IUpdatePaymentMethodCommand updatePaymentMethodCommand,
IUpdatePremiumStorageCommand updatePremiumStorageCommand,
IUpgradePremiumToOrganizationCommand upgradePremiumToOrganizationCommand) : BaseBillingController
IUpgradePremiumToOrganizationCommand upgradePremiumToOrganizationCommand,
IGetApplicableDiscountsQuery getApplicableDiscountsQuery) : BaseBillingController
{
[HttpGet("credit")]
[InjectUser]
Expand Down Expand Up @@ -136,4 +137,15 @@ public async Task<IResult> UpgradePremiumToOrganizationAsync(
var result = await upgradePremiumToOrganizationCommand.Run(user, organizationName, key, planType, billingAddress);
return Handle(result);
}

[HttpGet("discounts")]
[RequireFeature(FeatureFlagKeys.PM29108_EnablePersonalDiscounts)]
[InjectUser]
public async Task<IResult> GetApplicableDiscountsAsync(
[BindNever] User user)
{
var result = await getApplicableDiscountsQuery.Run(user);
return Handle(result);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
ο»Ώ#nullable enable

using Bit.Core.Billing.Subscriptions.Entities;

namespace Bit.Core.Billing.Models.Api.Response;

public class SubscriptionDiscountResponseModel
{
public string StripeCouponId { get; init; } = null!;
public IEnumerable<string>? StripeProductIds { get; init; }
public decimal? PercentOff { get; init; }
public long? AmountOff { get; init; }
public string? Currency { get; init; }
public string Duration { get; init; } = null!;
public int? DurationInMonths { get; init; }
public string? Name { get; init; }
public DateTime StartDate { get; init; }
public DateTime EndDate { get; init; }

public static SubscriptionDiscountResponseModel From(SubscriptionDiscount discount) => new()
{
StripeCouponId = discount.StripeCouponId,
StripeProductIds = discount.StripeProductIds,
PercentOff = discount.PercentOff,
AmountOff = discount.AmountOff,
Currency = discount.Currency,
Duration = discount.Duration,
DurationInMonths = discount.DurationInMonths,
Name = discount.Name,
StartDate = discount.StartDate,
EndDate = discount.EndDate
};
}
26 changes: 26 additions & 0 deletions src/Core/Billing/Payment/Queries/GetApplicableDiscountsQuery.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
ο»Ώ#nullable enable

using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Models.Api.Response;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;

namespace Bit.Core.Billing.Payment.Queries;

public interface IGetApplicableDiscountsQuery
{
/// <summary>
/// Returns all discounts the user is eligible for, mapped to <see cref="SubscriptionDiscountResponseModel"/>.
/// </summary>
Task<BillingCommandResult<SubscriptionDiscountResponseModel[]>> Run(User user);
}

public class GetApplicableDiscountsQuery(
ISubscriptionDiscountService subscriptionDiscountService) : IGetApplicableDiscountsQuery
{
public async Task<BillingCommandResult<SubscriptionDiscountResponseModel[]>> Run(User user)
{
var discounts = await subscriptionDiscountService.GetEligibleDiscountsAsync(user);
return discounts.Select(SubscriptionDiscountResponseModel.From).ToArray();
}
}
8 changes: 8 additions & 0 deletions src/Core/Billing/Payment/Registrations.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
ο»Ώusing Bit.Core.Billing.Payment.Clients;
using Bit.Core.Billing.Payment.Commands;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.DiscountAudienceFilters;
using Bit.Core.Billing.Services.Implementations;
using Microsoft.Extensions.DependencyInjection;

namespace Bit.Core.Billing.Payment;
Expand All @@ -15,7 +18,12 @@ public static void AddPaymentOperations(this IServiceCollection services)
services.AddTransient<IUpdateBillingAddressCommand, UpdateBillingAddressCommand>();
services.AddTransient<IUpdatePaymentMethodCommand, UpdatePaymentMethodCommand>();

// Discount services
services.AddTransient<ISubscriptionDiscountService, SubscriptionDiscountService>();
services.AddTransient<IDiscountAudienceFilterFactory, DiscountAudienceFilterFactory>();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ What do you think about adding the factory class as a singleton? Since it shouldn't ever need instantiated more than once, I don't think... Do you agree?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do! Great callout. I'll update this


// Queries
services.AddTransient<IGetApplicableDiscountsQuery, GetApplicableDiscountsQuery>();
services.AddTransient<IGetBillingAddressQuery, GetBillingAddressQuery>();
services.AddTransient<IGetCreditQuery, GetCreditQuery>();
services.AddTransient<IGetPaymentMethodQuery, GetPaymentMethodQuery>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
ο»Ώ#nullable enable

using Bit.Core.Billing.Enums;

namespace Bit.Core.Billing.Services.DiscountAudienceFilters;

/// <inheritdoc />
/// <remarks>
/// To add support for a new audience type: add an enum value, create a filter class, and add a case here.
/// </remarks>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice. Thanks for adding this comment here!

public class DiscountAudienceFilterFactory : IDiscountAudienceFilterFactory
{
public IDiscountAudienceFilter? GetFilter(DiscountAudienceType audienceType) =>
audienceType switch
{
DiscountAudienceType.UserHasNoPreviousSubscriptions => new UserHasNoPreviousSubscriptionsFilter(),
_ => null
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
ο»Ώ#nullable enable

using Bit.Core.Billing.Subscriptions.Entities;
using Bit.Core.Entities;

namespace Bit.Core.Billing.Services.DiscountAudienceFilters;

/// <summary>
/// Defines an eligibility check for a specific <see cref="Enums.DiscountAudienceType"/>.
/// Implementations are instantiated by <see cref="IDiscountAudienceFilterFactory"/> and
/// represent a single audience targeting rule.
/// </summary>
public interface IDiscountAudienceFilter
{
/// <summary>
/// Determines whether the given <paramref name="user"/> meets the audience criteria
/// required by the <paramref name="discount"/>.
/// </summary>
/// <param name="user">The user to evaluate.</param>
/// <param name="discount">The discount whose audience criteria are being checked.</param>
/// <returns><see langword="true"/> if the user is eligible; otherwise <see langword="false"/>.</returns>
bool IsUserEligible(User user, SubscriptionDiscount discount);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
ο»Ώ#nullable enable

using Bit.Core.Billing.Enums;

namespace Bit.Core.Billing.Services.DiscountAudienceFilters;

/// <summary>
/// Creates <see cref="IDiscountAudienceFilter"/> instances for a given <see cref="DiscountAudienceType"/>.
/// </summary>
public interface IDiscountAudienceFilterFactory
{
/// <summary>
/// Returns the <see cref="IDiscountAudienceFilter"/> for the specified <paramref name="audienceType"/>,
/// or <see langword="null"/> if no filter is registered for that type.
/// </summary>
/// <param name="audienceType">The audience type to retrieve a filter for.</param>
IDiscountAudienceFilter? GetFilter(DiscountAudienceType audienceType);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
ο»Ώ#nullable enable

using Bit.Core.Billing.Subscriptions.Entities;
using Bit.Core.Entities;

namespace Bit.Core.Billing.Services.DiscountAudienceFilters;

/// <summary>
/// Restricts a discount to users who have never held a Bitwarden subscription.
/// A user is considered to have no previous subscriptions when they are not currently
/// a Premium member and have no recorded Stripe subscription ID.
/// </summary>
public class UserHasNoPreviousSubscriptionsFilter : IDiscountAudienceFilter
{
public bool IsUserEligible(User user, SubscriptionDiscount discount) =>
!user.Premium && string.IsNullOrEmpty(user.GatewaySubscriptionId);
}
26 changes: 26 additions & 0 deletions src/Core/Billing/Services/ISubscriptionDiscountService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
ο»Ώusing Bit.Core.Billing.Subscriptions.Entities;
using Bit.Core.Entities;

namespace Bit.Core.Billing.Services;

/// <summary>
/// Manages eligibility evaluation for subscription discounts.
/// </summary>
public interface ISubscriptionDiscountService
{
/// <summary>
/// Retrieves all active discounts the user is eligible for
/// </summary>
/// <param name="user">The user to evaluate discount eligibility for.</param>
/// <returns>The collection of eligible <see cref="SubscriptionDiscount"/> records.</returns>
Task<IEnumerable<SubscriptionDiscount>> GetEligibleDiscountsAsync(User user);

/// <summary>
/// Performs a server-side eligibility recheck for a specific coupon before subscription creation,
/// confirming the coupon exists, is active, and the user still qualifies for it.
/// </summary>
/// <param name="user">The user to validate eligibility for.</param>
/// <param name="coupon">The Stripe coupon ID to validate.</param>
/// <returns><see langword="true"/> if the discount exists and the user is eligible; otherwise <see langword="false"/>.</returns>
Task<bool> ValidateDiscountEligibilityForUserAsync(User user, string coupon);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
ο»Ώusing Bit.Core.Billing.Enums;
using Bit.Core.Billing.Services.DiscountAudienceFilters;
using Bit.Core.Billing.Subscriptions.Entities;
using Bit.Core.Billing.Subscriptions.Repositories;
using Bit.Core.Entities;

namespace Bit.Core.Billing.Services.Implementations;

/// <inheritdoc />
public class SubscriptionDiscountService(
ISubscriptionDiscountRepository subscriptionDiscountRepository,
IDiscountAudienceFilterFactory discountAudienceFilterFactory) : ISubscriptionDiscountService
{
/// <inheritdoc />
public async Task<IEnumerable<SubscriptionDiscount>> GetEligibleDiscountsAsync(User user)
{
var activeDiscounts = await subscriptionDiscountRepository.GetActiveDiscountsAsync();
return activeDiscounts.Where(discount => IsEligible(user, discount)).ToList();
}

/// <inheritdoc />
public async Task<bool> ValidateDiscountEligibilityForUserAsync(User user, string coupon)
{
var discount = await subscriptionDiscountRepository.GetByStripeCouponIdAsync(coupon);
return discount != null && IsEligible(user, discount);
}

/// <summary>
/// Checks whether the <paramref name="user"/> meets the audience criteria for the given <paramref name="discount"/>
/// by delegating to the appropriate <see cref="IDiscountAudienceFilter"/> via the factory.
/// </summary>
private bool IsEligible(User user, SubscriptionDiscount discount)
{
if (discount.AudienceType == DiscountAudienceType.AllUsers)
{
return true;
}

var filter = discountAudienceFilterFactory.GetFilter(discount.AudienceType);
return filter?.IsUserEligible(user, discount) ?? false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@
using Bit.Api.Billing.Models.Requests.Storage;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Licenses.Queries;
using Bit.Core.Billing.Models.Api.Response;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Premium.Commands;
using Bit.Core.Billing.Subscriptions.Commands;
using Bit.Core.Billing.Subscriptions.Queries;
using Bit.Core.Entities;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using NSubstitute;
using OneOf.Types;
using Xunit;
Expand All @@ -20,13 +23,15 @@ public class AccountBillingVNextControllerTests
private readonly IUpdatePremiumStorageCommand _updatePremiumStorageCommand;
private readonly IGetUserLicenseQuery _getUserLicenseQuery;
private readonly IUpgradePremiumToOrganizationCommand _upgradePremiumToOrganizationCommand;
private readonly IGetApplicableDiscountsQuery _getApplicableDiscountsQuery;
private readonly AccountBillingVNextController _sut;

public AccountBillingVNextControllerTests()
{
_updatePremiumStorageCommand = Substitute.For<IUpdatePremiumStorageCommand>();
_getUserLicenseQuery = Substitute.For<IGetUserLicenseQuery>();
_upgradePremiumToOrganizationCommand = Substitute.For<IUpgradePremiumToOrganizationCommand>();
_getApplicableDiscountsQuery = Substitute.For<IGetApplicableDiscountsQuery>();

_sut = new AccountBillingVNextController(
Substitute.For<Core.Billing.Payment.Commands.ICreateBitPayInvoiceForCreditCommand>(),
Expand All @@ -38,7 +43,8 @@ public AccountBillingVNextControllerTests()
Substitute.For<IReinstateSubscriptionCommand>(),
Substitute.For<Core.Billing.Payment.Commands.IUpdatePaymentMethodCommand>(),
_updatePremiumStorageCommand,
_upgradePremiumToOrganizationCommand);
_upgradePremiumToOrganizationCommand,
_getApplicableDiscountsQuery);
}

[Theory, BitAutoData]
Expand Down Expand Up @@ -249,4 +255,39 @@ public async Task UpdateStorageAsync_NullPaymentSecret_Success(User user)
var okResult = Assert.IsAssignableFrom<IResult>(result);
await _updatePremiumStorageCommand.Received(1).Run(user, 5);
}

[Theory, BitAutoData]
public async Task GetApplicableDiscountsAsync_NoEligibleDiscounts_ReturnsOkWithEmptyArray(User user)
{
// Arrange
_getApplicableDiscountsQuery.Run(user)
.Returns(Array.Empty<SubscriptionDiscountResponseModel>());

// Act
var result = await _sut.GetApplicableDiscountsAsync(user);

// Assert
var okResult = Assert.IsType<Ok<SubscriptionDiscountResponseModel[]>>(result);
Assert.Empty(okResult.Value!);
await _getApplicableDiscountsQuery.Received(1).Run(user);
}

[Theory, BitAutoData]
public async Task GetApplicableDiscountsAsync_EligibleDiscounts_ReturnsOkWithDiscounts(
User user,
SubscriptionDiscountResponseModel firstModel,
SubscriptionDiscountResponseModel secondModel)
{
// Arrange
var models = new[] { firstModel, secondModel };
_getApplicableDiscountsQuery.Run(user).Returns(models);

// Act
var result = await _sut.GetApplicableDiscountsAsync(user);

// Assert
var okResult = Assert.IsType<Ok<SubscriptionDiscountResponseModel[]>>(result);
Assert.Equal(models, okResult.Value);
await _getApplicableDiscountsQuery.Received(1).Run(user);
}
}
Loading
Loading