Skip to content

Commit 2c6c2fe

Browse files
committed
resolve pr comments
1 parent 64fa791 commit 2c6c2fe

File tree

3 files changed

+149
-44
lines changed

3 files changed

+149
-44
lines changed
Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,32 @@
11
using System.ComponentModel.DataAnnotations;
2+
using System.Text.Json.Serialization;
23
using Bit.Core.Billing.Enums;
34

45
namespace Bit.Api.Billing.Models.Requests.Premium;
56

67
public class UpgradePremiumToOrganizationRequest
78
{
89
[Required]
9-
public required PlanType PlanType { get; set; }
10+
[JsonConverter(typeof(JsonStringEnumConverter))]
11+
public ProductTierType Tier { get; set; }
1012

11-
[Range(1, int.MaxValue)]
12-
public int Seats { get; set; }
13-
14-
public bool PremiumAccess { get; set; } = false;
15-
16-
[Range(0, 99)]
17-
public int Storage { get; set; } = 0;
13+
[Required]
14+
[JsonConverter(typeof(JsonStringEnumConverter))]
15+
public PlanCadenceType Cadence { get; set; }
1816

19-
public DateTime? TrialEndDate { get; set; }
17+
private PlanType PlanType =>
18+
Tier switch
19+
{
20+
ProductTierType.Families => PlanType.FamiliesAnnually,
21+
ProductTierType.Teams => Cadence == PlanCadenceType.Monthly
22+
? PlanType.TeamsMonthly
23+
: PlanType.TeamsAnnually,
24+
ProductTierType.Enterprise => Cadence == PlanCadenceType.Monthly
25+
? PlanType.EnterpriseMonthly
26+
: PlanType.EnterpriseAnnually,
27+
_ => throw new InvalidOperationException("Cannot upgrade to an Organization subscription that isn't Families, Teams or Enterprise.")
28+
};
2029

2130
public (PlanType, int, bool, int?, DateTime?) ToDomain() =>
22-
(PlanType, Seats, PremiumAccess, Storage > 0 ? Storage : null, TrialEndDate);
31+
(PlanType, 1, false, null, null);
2332
}

src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs

Lines changed: 86 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
1-
using Bit.Core.Billing.Commands;
1+
using Bit.Core.AdminConsole.Entities;
2+
using Bit.Core.Billing.Commands;
23
using Bit.Core.Billing.Constants;
34
using Bit.Core.Billing.Enums;
4-
using Bit.Core.Billing.Extensions;
55
using Bit.Core.Billing.Pricing;
66
using Bit.Core.Billing.Services;
77
using Bit.Core.Entities;
8+
using Bit.Core.Enums;
9+
using Bit.Core.Repositories;
810
using Bit.Core.Services;
11+
using Bit.Core.Utilities;
912
using Microsoft.Extensions.Logging;
1013
using OneOf.Types;
1114
using Stripe;
1215

1316
namespace Bit.Core.Billing.Premium.Commands;
1417
/// <summary>
15-
/// Upgrades a user's Premium subscription to an Organization plan by modifying the existing Stripe subscription.
18+
/// Upgrades a user's Premium subscription to an Organization plan by creating a new Organization
19+
/// and transferring the subscription from the User to the Organization.
1620
/// </summary>
1721
public interface IUpgradePremiumToOrganizationCommand
1822
{
@@ -39,8 +43,11 @@ public class UpgradePremiumToOrganizationCommand(
3943
ILogger<UpgradePremiumToOrganizationCommand> logger,
4044
IPricingClient pricingClient,
4145
IStripeAdapter stripeAdapter,
42-
ISubscriberService subscriberService,
43-
IUserService userService)
46+
IUserService userService,
47+
IOrganizationRepository organizationRepository,
48+
IOrganizationUserRepository organizationUserRepository,
49+
IOrganizationApiKeyRepository organizationApiKeyRepository,
50+
IApplicationCacheService applicationCacheService)
4451
: BaseBillingCommand<UpgradePremiumToOrganizationCommand>(logger), IUpgradePremiumToOrganizationCommand
4552
{
4653
public Task<BillingCommandResult<None>> Run(
@@ -52,16 +59,11 @@ public Task<BillingCommandResult<None>> Run(
5259
DateTime? trialEndDate) => HandleAsync<None>(async () =>
5360
{
5461
// Validate that the user has an active Premium subscription
55-
if (!user.Premium)
62+
if (user is not { Premium: true, GatewaySubscriptionId: not null and not "" })
5663
{
5764
return new BadRequest("User does not have an active Premium subscription.");
5865
}
5966

60-
if (string.IsNullOrEmpty(user.GatewaySubscriptionId))
61-
{
62-
return new BadRequest("User does not have a Stripe subscription.");
63-
}
64-
6567
if (seats < 1)
6668
{
6769
return new BadRequest("Seats must be at least 1.");
@@ -73,10 +75,7 @@ public Task<BillingCommandResult<None>> Run(
7375
}
7476

7577
// Fetch the current Premium subscription from Stripe
76-
var currentSubscription = await subscriberService.GetSubscriptionOrThrow(user, new SubscriptionGetOptions
77-
{
78-
Expand = ["items.data.price"]
79-
});
78+
var currentSubscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId);
8079

8180
// Get the target organization plan
8281
var targetPlan = await pricingClient.GetPlanOrThrow(targetPlanType);
@@ -160,11 +159,81 @@ public Task<BillingCommandResult<None>> Run(
160159
subscriptionUpdateOptions.TrialEnd = trialEndDate.Value;
161160
}
162161

162+
// Create the Organization entity
163+
var organization = new Organization
164+
{
165+
Id = CoreHelpers.GenerateComb(),
166+
Name = $"{user.Email}'s Organization",
167+
BillingEmail = user.Email,
168+
PlanType = targetPlan.Type,
169+
Seats = (short)seats,
170+
MaxCollections = targetPlan.PasswordManager.MaxCollections,
171+
MaxStorageGb = (short)(targetPlan.PasswordManager.BaseStorageGb + (storage ?? 0)),
172+
UsePolicies = targetPlan.HasPolicies,
173+
UseSso = targetPlan.HasSso,
174+
UseGroups = targetPlan.HasGroups,
175+
UseEvents = targetPlan.HasEvents,
176+
UseDirectory = targetPlan.HasDirectory,
177+
UseTotp = targetPlan.HasTotp,
178+
Use2fa = targetPlan.Has2fa,
179+
UseApi = targetPlan.HasApi,
180+
UseResetPassword = targetPlan.HasResetPassword,
181+
SelfHost = targetPlan.HasSelfHost,
182+
UsersGetPremium = targetPlan.UsersGetPremium || premiumAccess,
183+
UseCustomPermissions = targetPlan.HasCustomPermissions,
184+
UseScim = targetPlan.HasScim,
185+
Plan = targetPlan.Name,
186+
Gateway = null,
187+
Enabled = true,
188+
LicenseKey = CoreHelpers.SecureRandomString(20),
189+
CreationDate = DateTime.UtcNow,
190+
RevisionDate = DateTime.UtcNow,
191+
Status = OrganizationStatusType.Created,
192+
UsePasswordManager = true,
193+
UseSecretsManager = false,
194+
UseOrganizationDomains = targetPlan.HasOrganizationDomains,
195+
GatewayCustomerId = user.GatewayCustomerId,
196+
GatewaySubscriptionId = currentSubscription.Id
197+
};
198+
163199
// Update the subscription in Stripe
164200
await stripeAdapter.UpdateSubscriptionAsync(currentSubscription.Id, subscriptionUpdateOptions);
165201

166-
// Update user record
167-
user.PremiumExpirationDate = trialEndDate ?? currentSubscription.GetCurrentPeriodEnd();
202+
// Save the organization
203+
await organizationRepository.CreateAsync(organization);
204+
205+
// Create organization API key
206+
await organizationApiKeyRepository.CreateAsync(new OrganizationApiKey
207+
{
208+
OrganizationId = organization.Id,
209+
ApiKey = CoreHelpers.SecureRandomString(30),
210+
Type = OrganizationApiKeyType.Default,
211+
RevisionDate = DateTime.UtcNow,
212+
});
213+
214+
// Update cache
215+
await applicationCacheService.UpsertOrganizationAbilityAsync(organization);
216+
217+
// Create OrganizationUser for the upgrading user as owner
218+
var organizationUser = new OrganizationUser
219+
{
220+
OrganizationId = organization.Id,
221+
UserId = user.Id,
222+
Key = null, // Will need to be set by client
223+
AccessSecretsManager = false,
224+
Type = OrganizationUserType.Owner,
225+
Status = OrganizationUserStatusType.Confirmed,
226+
CreationDate = organization.CreationDate,
227+
RevisionDate = organization.CreationDate
228+
};
229+
organizationUser.SetNewId();
230+
await organizationUserRepository.CreateAsync(organizationUser);
231+
232+
// Remove subscription from user
233+
user.Premium = false;
234+
user.PremiumExpirationDate = null;
235+
user.GatewaySubscriptionId = null;
236+
user.GatewayCustomerId = null;
168237
user.RevisionDate = DateTime.UtcNow;
169238
await userService.SaveUserAsync(user);
170239

test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs

Lines changed: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
using Bit.Core.Billing.Enums;
1+
using Bit.Core.AdminConsole.Entities;
2+
using Bit.Core.Billing.Enums;
23
using Bit.Core.Billing.Premium.Commands;
34
using Bit.Core.Billing.Pricing;
45
using Bit.Core.Billing.Services;
56
using Bit.Core.Entities;
7+
using Bit.Core.Repositories;
68
using Bit.Core.Services;
79
using Bit.Test.Common.AutoFixture.Attributes;
810
using Microsoft.Extensions.Logging;
@@ -88,8 +90,11 @@ private static Core.Models.StaticStore.Plan CreateTestPlan(
8890

8991
private readonly IPricingClient _pricingClient = Substitute.For<IPricingClient>();
9092
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
91-
private readonly ISubscriberService _subscriberService = Substitute.For<ISubscriberService>();
9293
private readonly IUserService _userService = Substitute.For<IUserService>();
94+
private readonly IOrganizationRepository _organizationRepository = Substitute.For<IOrganizationRepository>();
95+
private readonly IOrganizationUserRepository _organizationUserRepository = Substitute.For<IOrganizationUserRepository>();
96+
private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository = Substitute.For<IOrganizationApiKeyRepository>();
97+
private readonly IApplicationCacheService _applicationCacheService = Substitute.For<IApplicationCacheService>();
9398
private readonly ILogger<UpgradePremiumToOrganizationCommand> _logger = Substitute.For<ILogger<UpgradePremiumToOrganizationCommand>>();
9499
private readonly UpgradePremiumToOrganizationCommand _command;
95100

@@ -99,8 +104,11 @@ public UpgradePremiumToOrganizationCommandTests()
99104
_logger,
100105
_pricingClient,
101106
_stripeAdapter,
102-
_subscriberService,
103-
_userService);
107+
_userService,
108+
_organizationRepository,
109+
_organizationUserRepository,
110+
_organizationApiKeyRepository,
111+
_applicationCacheService);
104112
}
105113

106114
[Theory, BitAutoData]
@@ -131,7 +139,7 @@ public async Task Run_UserNoGatewaySubscriptionId_ReturnsBadRequest(User user)
131139
// Assert
132140
Assert.True(result.IsT1);
133141
var badRequest = result.AsT1;
134-
Assert.Equal("User does not have a Stripe subscription.", badRequest.Response);
142+
Assert.Equal("User does not have an active Premium subscription.", badRequest.Response);
135143
}
136144

137145
[Theory, BitAutoData]
@@ -147,7 +155,7 @@ public async Task Run_UserEmptyGatewaySubscriptionId_ReturnsBadRequest(User user
147155
// Assert
148156
Assert.True(result.IsT1);
149157
var badRequest = result.AsT1;
150-
Assert.Equal("User does not have a Stripe subscription.", badRequest.Response);
158+
Assert.Equal("User does not have an active Premium subscription.", badRequest.Response);
151159
}
152160

153161
[Theory, BitAutoData]
@@ -212,7 +220,7 @@ public async Task Run_PlanDoesNotSupportPremiumAccess_ReturnsBadRequest(User use
212220
stripePremiumAccessPlanId: null // No premium access support
213221
);
214222

215-
_subscriberService.GetSubscriptionOrThrow(user, Arg.Any<SubscriptionGetOptions>())
223+
_stripeAdapter.GetSubscriptionAsync("sub_123")
216224
.Returns(mockSubscription);
217225
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan);
218226

@@ -254,7 +262,7 @@ public async Task Run_PlanDoesNotSupportStorage_ReturnsBadRequest(User user)
254262
stripeStoragePlanId: null // No storage support
255263
);
256264

257-
_subscriberService.GetSubscriptionOrThrow(user, Arg.Any<SubscriptionGetOptions>())
265+
_stripeAdapter.GetSubscriptionAsync("sub_123")
258266
.Returns(mockSubscription);
259267
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan);
260268

@@ -273,6 +281,7 @@ public async Task Run_SuccessfulUpgrade_SeatBasedPlan_ReturnsSuccess(User user)
273281
// Arrange
274282
user.Premium = true;
275283
user.GatewaySubscriptionId = "sub_123";
284+
user.GatewayCustomerId = "cus_123";
276285
user.Id = Guid.NewGuid();
277286

278287
var currentPeriodEnd = DateTime.UtcNow.AddMonths(1);
@@ -301,11 +310,15 @@ public async Task Run_SuccessfulUpgrade_SeatBasedPlan_ReturnsSuccess(User user)
301310
stripeStoragePlanId: "storage-plan-teams"
302311
);
303312

304-
_subscriberService.GetSubscriptionOrThrow(user, Arg.Any<SubscriptionGetOptions>())
313+
_stripeAdapter.GetSubscriptionAsync("sub_123")
305314
.Returns(mockSubscription);
306315
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan);
307316
_stripeAdapter.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
308317
.Returns(Task.FromResult(mockSubscription));
318+
_organizationRepository.CreateAsync(Arg.Any<Organization>()).Returns(callInfo => Task.FromResult(callInfo.Arg<Organization>()));
319+
_organizationApiKeyRepository.CreateAsync(Arg.Any<OrganizationApiKey>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationApiKey>()));
320+
_organizationUserRepository.CreateAsync(Arg.Any<OrganizationUser>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationUser>()));
321+
_applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any<Organization>()).Returns(Task.CompletedTask);
309322
_userService.SaveUserAsync(user).Returns(Task.CompletedTask);
310323

311324
// Act
@@ -323,8 +336,16 @@ await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
323336
opts.Items.Any(i => i.Price == "teams-premium-access-annually" && i.Quantity == 1) &&
324337
opts.Items.Any(i => i.Price == "storage-plan-teams" && i.Quantity == 10)));
325338

326-
await _userService.Received(1).SaveUserAsync(user);
327-
Assert.Equal(currentPeriodEnd, user.PremiumExpirationDate);
339+
await _organizationRepository.Received(1).CreateAsync(Arg.Is<Organization>(o =>
340+
o.GatewaySubscriptionId == "sub_123" &&
341+
o.GatewayCustomerId == "cus_123"));
342+
await _organizationUserRepository.Received(1).CreateAsync(Arg.Any<OrganizationUser>());
343+
await _organizationApiKeyRepository.Received(1).CreateAsync(Arg.Any<OrganizationApiKey>());
344+
345+
await _userService.Received(1).SaveUserAsync(Arg.Is<User>(u =>
346+
u.Premium == false &&
347+
u.GatewaySubscriptionId == null &&
348+
u.GatewayCustomerId == null));
328349
}
329350

330351
[Theory, BitAutoData]
@@ -333,6 +354,7 @@ public async Task Run_SuccessfulUpgrade_NonSeatBasedPlan_ReturnsSuccess(User use
333354
// Arrange
334355
user.Premium = true;
335356
user.GatewaySubscriptionId = "sub_123";
357+
user.GatewayCustomerId = "cus_123";
336358

337359
var currentPeriodEnd = DateTime.UtcNow.AddMonths(1);
338360
var mockSubscription = new Subscription
@@ -359,11 +381,15 @@ public async Task Run_SuccessfulUpgrade_NonSeatBasedPlan_ReturnsSuccess(User use
359381
stripeSeatPlanId: null // Non-seat-based
360382
);
361383

362-
_subscriberService.GetSubscriptionOrThrow(user, Arg.Any<SubscriptionGetOptions>())
384+
_stripeAdapter.GetSubscriptionAsync("sub_123")
363385
.Returns(mockSubscription);
364386
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(mockPlan);
365387
_stripeAdapter.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
366388
.Returns(Task.FromResult(mockSubscription));
389+
_organizationRepository.CreateAsync(Arg.Any<Organization>()).Returns(callInfo => Task.FromResult(callInfo.Arg<Organization>()));
390+
_organizationApiKeyRepository.CreateAsync(Arg.Any<OrganizationApiKey>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationApiKey>()));
391+
_organizationUserRepository.CreateAsync(Arg.Any<OrganizationUser>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationUser>()));
392+
_applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any<Organization>()).Returns(Task.CompletedTask);
367393
_userService.SaveUserAsync(user).Returns(Task.CompletedTask);
368394

369395
// Act
@@ -379,7 +405,10 @@ await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
379405
opts.Items.Any(i => i.Deleted == true) &&
380406
opts.Items.Any(i => i.Price == "families-plan-annually" && i.Quantity == 1)));
381407

382-
await _userService.Received(1).SaveUserAsync(user);
408+
await _organizationRepository.Received(1).CreateAsync(Arg.Any<Organization>());
409+
await _userService.Received(1).SaveUserAsync(Arg.Is<User>(u =>
410+
u.Premium == false &&
411+
u.GatewaySubscriptionId == null));
383412
}
384413

385414
[Theory, BitAutoData]
@@ -412,7 +441,7 @@ public async Task Run_WithTrialEndDate_SetsTrialEndOnSubscription(User user)
412441
stripeSeatPlanId: "teams-seat-annually"
413442
);
414443

415-
_subscriberService.GetSubscriptionOrThrow(user, Arg.Any<SubscriptionGetOptions>())
444+
_stripeAdapter.GetSubscriptionAsync("sub_123")
416445
.Returns(mockSubscription);
417446
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan);
418447
_stripeAdapter.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
@@ -429,8 +458,6 @@ await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
429458
"sub_123",
430459
Arg.Is<SubscriptionUpdateOptions>(opts =>
431460
opts.TrialEnd == trialEndDate));
432-
433-
Assert.Equal(trialEndDate, user.PremiumExpirationDate);
434461
}
435462

436463
[Theory, BitAutoData]
@@ -466,7 +493,7 @@ public async Task Run_AddsMetadataWithOriginalPremiumPriceId(User user)
466493
stripeSeatPlanId: "teams-seat-annually"
467494
);
468495

469-
_subscriberService.GetSubscriptionOrThrow(user, Arg.Any<SubscriptionGetOptions>())
496+
_stripeAdapter.GetSubscriptionAsync("sub_123")
470497
.Returns(mockSubscription);
471498
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan);
472499
_stripeAdapter.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())

0 commit comments

Comments
 (0)