-
Notifications
You must be signed in to change notification settings - Fork 1.5k
[PM-30271] Create Retrieve Eligible Subscription Discounts Endpoint #7063
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
sbrown-livefront
wants to merge
11
commits into
main
Choose a base branch
from
billing/pm-30271/fetch-discounts-endpoint
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+737
β2
Open
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
bab3400
feat(billing): add subscription discount response model
sbrown-livefront 655fa2b
feat(billing): implement discount audience filters
sbrown-livefront 505fae4
feat(billing): introduce subscription discount service
sbrown-livefront 9f04fc2
feat(billing): add query for applicable discounts
sbrown-livefront 559be3d
feat(api): add endpoint for applicable discounts
sbrown-livefront 1a508ba
chore(billing): register new billing services and queries
sbrown-livefront b776012
refactor(tests): rename discount audience filter test methods for claβ¦
sbrown-livefront cec5d50
refactor(billing): remove unused Id property from SubscriptionDiscounβ¦
sbrown-livefront f0d8df9
feat(billing): add feature flag to GetApplicableDiscountsAsync endpoint
sbrown-livefront 5998308
format(billing): dotnet format and remove pricing import
sbrown-livefront 29c373d
Merge branch 'main' into billing/pm-30271/fetch-discounts-endpoint
sbrown-livefront File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
33 changes: 33 additions & 0 deletions
33
src/Core/Billing/Models/Api/Response/SubscriptionDiscountResponseModel.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
26
src/Core/Billing/Payment/Queries/GetApplicableDiscountsQuery.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
19 changes: 19 additions & 0 deletions
19
src/Core/Billing/Services/DiscountAudienceFilters/DiscountAudienceFilterFactory.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| }; | ||
| } | ||
23 changes: 23 additions & 0 deletions
23
src/Core/Billing/Services/DiscountAudienceFilters/IDiscountAudienceFilter.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } |
18 changes: 18 additions & 0 deletions
18
src/Core/Billing/Services/DiscountAudienceFilters/IDiscountAudienceFilterFactory.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } |
17 changes: 17 additions & 0 deletions
17
src/Core/Billing/Services/DiscountAudienceFilters/UserHasNoPreviousSubscriptionsFilter.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } |
42 changes: 42 additions & 0 deletions
42
src/Core/Billing/Services/Implementations/SubscriptionDiscountService.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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