-
Notifications
You must be signed in to change notification settings - Fork 1.5k
[PM-29598] Create Subscription Upgrade Endpoint #6787
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
Changes from 5 commits
35d8812
8276423
ffdef93
dc93149
bca4274
64fa791
2c6c2fe
383530d
79a8be0
d8ebcc9
00f4ad6
025e3e0
9ba9ef1
0687f31
a64e0a0
9b54c2e
244ca57
cbdec08
823db8c
5299129
f728816
9b3a68f
d2f197a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| ๏ปฟusing System.ComponentModel.DataAnnotations; | ||
| using Bit.Core.Billing.Enums; | ||
|
|
||
| namespace Bit.Api.Billing.Models.Requests.Premium; | ||
|
|
||
| public class UpgradePremiumToOrganizationRequest | ||
|
Collaborator
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. โ Would we not include seats in the request(even though we assume it will be 1 for now)?
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. @sbrown-livefront I recommended not doing so - since the business requirements are for a 1-seat upgrade, I feel like that would primarily just give the client extra, unnecessary responsibility. |
||
| { | ||
| [Required] | ||
| public required PlanType PlanType { get; set; } | ||
|
||
|
|
||
| [Range(1, int.MaxValue)] | ||
| public int Seats { get; set; } | ||
|
||
|
|
||
| public bool PremiumAccess { get; set; } = false; | ||
|
||
|
|
||
| [Range(0, 99)] | ||
| public int Storage { get; set; } = 0; | ||
|
||
|
|
||
| public DateTime? TrialEndDate { get; set; } | ||
|
||
|
|
||
| public (PlanType, int, bool, int?, DateTime?) ToDomain() => | ||
| (PlanType, Seats, PremiumAccess, Storage > 0 ? Storage : null, TrialEndDate); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,173 @@ | ||
| ๏ปฟusing Bit.Core.Billing.Commands; | ||
| using Bit.Core.Billing.Constants; | ||
| using Bit.Core.Billing.Enums; | ||
| using Bit.Core.Billing.Extensions; | ||
| using Bit.Core.Billing.Pricing; | ||
| using Bit.Core.Billing.Services; | ||
| using Bit.Core.Entities; | ||
| using Bit.Core.Services; | ||
| using Microsoft.Extensions.Logging; | ||
| using OneOf.Types; | ||
| using Stripe; | ||
|
|
||
| namespace Bit.Core.Billing.Premium.Commands; | ||
| /// <summary> | ||
| /// Upgrades a user's Premium subscription to an Organization plan by modifying the existing Stripe subscription. | ||
| /// </summary> | ||
| public interface IUpgradePremiumToOrganizationCommand | ||
| { | ||
| /// <summary> | ||
| /// Upgrades a Premium subscription to an Organization subscription. | ||
| /// </summary> | ||
| /// <param name="user">The user with an active Premium subscription to upgrade.</param> | ||
| /// <param name="targetPlanType">The target organization plan type to upgrade to.</param> | ||
| /// <param name="seats">The number of seats for the organization plan.</param> | ||
| /// <param name="premiumAccess">Whether to include premium access for the organization.</param> | ||
| /// <param name="storage">Additional storage in GB for the organization.</param> | ||
| /// <param name="trialEndDate">The trial end date to apply to the upgraded subscription.</param> | ||
| /// <returns>A billing command result indicating success or failure with appropriate error details.</returns> | ||
| Task<BillingCommandResult<None>> Run( | ||
| User user, | ||
| PlanType targetPlanType, | ||
| int seats, | ||
| bool premiumAccess, | ||
| int? storage, | ||
| DateTime? trialEndDate); | ||
| } | ||
|
|
||
| public class UpgradePremiumToOrganizationCommand( | ||
| ILogger<UpgradePremiumToOrganizationCommand> logger, | ||
| IPricingClient pricingClient, | ||
| IStripeAdapter stripeAdapter, | ||
| ISubscriberService subscriberService, | ||
| IUserService userService) | ||
| : BaseBillingCommand<UpgradePremiumToOrganizationCommand>(logger), IUpgradePremiumToOrganizationCommand | ||
| { | ||
| public Task<BillingCommandResult<None>> Run( | ||
| User user, | ||
| PlanType targetPlanType, | ||
| int seats, | ||
| bool premiumAccess, | ||
| int? storage, | ||
| DateTime? trialEndDate) => HandleAsync<None>(async () => | ||
| { | ||
| // Validate that the user has an active Premium subscription | ||
| if (!user.Premium) | ||
| { | ||
| return new BadRequest("User does not have an active Premium subscription."); | ||
| } | ||
|
|
||
| if (string.IsNullOrEmpty(user.GatewaySubscriptionId)) | ||
| { | ||
| return new BadRequest("User does not have a Stripe subscription."); | ||
| } | ||
|
||
|
|
||
| if (seats < 1) | ||
amorask-bitwarden marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| { | ||
| return new BadRequest("Seats must be at least 1."); | ||
| } | ||
|
|
||
| if (trialEndDate.HasValue && trialEndDate.Value < DateTime.UtcNow) | ||
amorask-bitwarden marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| { | ||
| return new BadRequest("Trial end date cannot be in the past."); | ||
| } | ||
|
|
||
| // Fetch the current Premium subscription from Stripe | ||
| var currentSubscription = await subscriberService.GetSubscriptionOrThrow(user, new SubscriptionGetOptions | ||
|
||
| { | ||
| Expand = ["items.data.price"] | ||
|
||
| }); | ||
|
|
||
| // Get the target organization plan | ||
| var targetPlan = await pricingClient.GetPlanOrThrow(targetPlanType); | ||
|
|
||
| // Validate plan supports requested features | ||
| if (premiumAccess && string.IsNullOrEmpty(targetPlan.PasswordManager.StripePremiumAccessPlanId)) | ||
amorask-bitwarden marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| { | ||
| return new BadRequest("The selected plan does not support premium access."); | ||
| } | ||
|
|
||
| if (storage is > 0 && string.IsNullOrEmpty(targetPlan.PasswordManager.StripeStoragePlanId)) | ||
amorask-bitwarden marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| { | ||
| return new BadRequest("The selected plan does not support additional storage."); | ||
| } | ||
|
|
||
| // Build the list of subscription item updates | ||
| var subscriptionItemOptions = new List<SubscriptionItemOptions>(); | ||
|
|
||
| // Mark existing Premium subscription items for deletion | ||
| foreach (var item in currentSubscription.Items.Data) | ||
|
||
| { | ||
| subscriptionItemOptions.Add(new SubscriptionItemOptions | ||
| { | ||
| Id = item.Id, | ||
| Deleted = true | ||
| }); | ||
| } | ||
|
|
||
| // Add new organization subscription items | ||
| if (targetPlan.HasNonSeatBasedPasswordManagerPlan()) | ||
| { | ||
| subscriptionItemOptions.Add(new SubscriptionItemOptions | ||
| { | ||
| Price = targetPlan.PasswordManager.StripePlanId, | ||
| Quantity = 1 | ||
| }); | ||
| } | ||
| else | ||
| { | ||
| subscriptionItemOptions.Add(new SubscriptionItemOptions | ||
| { | ||
| Price = targetPlan.PasswordManager.StripeSeatPlanId, | ||
| Quantity = seats | ||
| }); | ||
| } | ||
|
|
||
| if (premiumAccess) | ||
| { | ||
| subscriptionItemOptions.Add(new SubscriptionItemOptions | ||
| { | ||
| Price = targetPlan.PasswordManager.StripePremiumAccessPlanId, | ||
| Quantity = 1 | ||
| }); | ||
| } | ||
|
|
||
| if (storage is > 0) | ||
amorask-bitwarden marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| { | ||
| subscriptionItemOptions.Add(new SubscriptionItemOptions | ||
| { | ||
| Price = targetPlan.PasswordManager.StripeStoragePlanId, | ||
| Quantity = storage | ||
| }); | ||
| } | ||
|
|
||
| // Build the subscription update options | ||
| var subscriptionUpdateOptions = new SubscriptionUpdateOptions | ||
| { | ||
| Items = subscriptionItemOptions, | ||
| ProrationBehavior = StripeConstants.ProrationBehavior.None, | ||
| Metadata = new Dictionary<string, string>(currentSubscription.Metadata ?? new Dictionary<string, string>()) | ||
amorask-bitwarden marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| { | ||
| ["premium_upgrade_metadata"] = currentSubscription.Items.Data | ||
amorask-bitwarden marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| .Select(item => item.Price.Id) | ||
| .FirstOrDefault() ?? "premium" | ||
| } | ||
sbrown-livefront marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }; | ||
|
|
||
| // Apply trial period if specified | ||
| if (trialEndDate.HasValue) | ||
|
||
| { | ||
| subscriptionUpdateOptions.TrialEnd = trialEndDate.Value; | ||
| } | ||
|
|
||
| // Update the subscription in Stripe | ||
| await stripeAdapter.UpdateSubscriptionAsync(currentSubscription.Id, subscriptionUpdateOptions); | ||
|
|
||
| // Update user record | ||
| user.PremiumExpirationDate = trialEndDate ?? currentSubscription.GetCurrentPeriodEnd(); | ||
| user.RevisionDate = DateTime.UtcNow; | ||
| await userService.SaveUserAsync(user); | ||
|
||
|
|
||
| return new None(); | ||
| }); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.