Skip to content

Commit

Permalink
Merge branch 'main' into ac/pm-12490/create-organization-enable-command
Browse files Browse the repository at this point in the history
# Conflicts:
#	src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs
  • Loading branch information
r-tome committed Jan 24, 2025
2 parents 688f1d3 + ef32e80 commit e380aec
Show file tree
Hide file tree
Showing 12 changed files with 217 additions and 24 deletions.
2 changes: 2 additions & 0 deletions src/Admin/Views/Users/Edit.cshtml
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
@using Bit.Admin.Enums;
@inject Bit.Admin.Services.IAccessControlService AccessControlService
@inject Bit.Core.Services.IFeatureService FeatureService
@inject Bit.Core.Settings.GlobalSettings GlobalSettings
@inject IWebHostEnvironment HostingEnvironment
@model UserEditModel
@{
ViewData["Title"] = "User: " + Model.User.Email;

var canViewUserInformation = AccessControlService.UserHasPermission(Permission.User_UserInformation_View);
var canViewNewDeviceException = AccessControlService.UserHasPermission(Permission.User_NewDeviceException_Edit) &&
GlobalSettings.EnableNewDeviceVerification &&
FeatureService.IsEnabled(Bit.Core.FeatureFlagKeys.NewDeviceVerification);
var canViewBillingInformation = AccessControlService.UserHasPermission(Permission.User_BillingInformation_View);
var canViewGeneral = AccessControlService.UserHasPermission(Permission.User_GeneralDetails_View);
Expand Down
3 changes: 3 additions & 0 deletions src/Api/Billing/Controllers/OrganizationBillingController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,9 @@ public async Task<IResult> RestartSubscriptionAsync([FromRoute] Guid organizatio
}
var organizationSignup = model.ToOrganizationSignup(user);
var sale = OrganizationSale.From(organization, organizationSignup);
var plan = StaticStore.GetPlan(model.PlanType);
sale.Organization.PlanType = plan.Type;
sale.Organization.Plan = plan.Name;
await organizationBillingService.Finalize(sale);

return TypedResults.Ok();
Expand Down
1 change: 1 addition & 0 deletions src/Billing/Jobs/JobsHostedService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,6 @@ public override async Task StartAsync(CancellationToken cancellationToken)
public static void AddJobsServices(IServiceCollection services)
{
services.AddTransient<AliveJob>();
services.AddTransient<SubscriptionCancellationJob>();
}
}
58 changes: 58 additions & 0 deletions src/Billing/Jobs/SubscriptionCancellationJob.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using Bit.Billing.Services;
using Bit.Core.Repositories;
using Quartz;
using Stripe;

namespace Bit.Billing.Jobs;

public class SubscriptionCancellationJob(
IStripeFacade stripeFacade,
IOrganizationRepository organizationRepository)
: IJob
{
public async Task Execute(IJobExecutionContext context)
{
var subscriptionId = context.MergedJobDataMap.GetString("subscriptionId");
var organizationId = new Guid(context.MergedJobDataMap.GetString("organizationId") ?? string.Empty);

var organization = await organizationRepository.GetByIdAsync(organizationId);
if (organization == null || organization.Enabled)
{
// Organization was deleted or re-enabled by CS, skip cancellation
return;
}

var subscription = await stripeFacade.GetSubscription(subscriptionId);
if (subscription?.Status != "unpaid")
{
// Subscription is no longer unpaid, skip cancellation
return;
}

// Cancel the subscription
await stripeFacade.CancelSubscription(subscriptionId, new SubscriptionCancelOptions());

// Void any open invoices
var options = new InvoiceListOptions
{
Status = "open",
Subscription = subscriptionId,
Limit = 100
};
var invoices = await stripeFacade.ListInvoices(options);
foreach (var invoice in invoices)
{
await stripeFacade.VoidInvoice(invoice.Id);
}

while (invoices.HasMore)
{
options.StartingAfter = invoices.Data.Last().Id;
invoices = await stripeFacade.ListInvoices(options);
foreach (var invoice in invoices)
{
await stripeFacade.VoidInvoice(invoice.Id);
}
}
}
}
54 changes: 36 additions & 18 deletions src/Billing/Services/Implementations/StripeEventUtilityService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ private async Task<bool> AttemptToPayInvoiceWithBraintreeAsync(Invoice invoice,
btObjIdField = "provider_id";
btObjId = providerId.Value;
}
var btInvoiceAmount = invoice.AmountDue / 100M;
var btInvoiceAmount = Math.Round(invoice.AmountDue / 100M, 2);

var existingTransactions = organizationId.HasValue
? await _transactionRepository.GetManyByOrganizationIdAsync(organizationId.Value)
Expand All @@ -318,36 +318,54 @@ private async Task<bool> AttemptToPayInvoiceWithBraintreeAsync(Invoice invoice,
Result<Braintree.Transaction> transactionResult;
try
{
transactionResult = await _btGateway.Transaction.SaleAsync(
new Braintree.TransactionRequest
var transactionRequest = new Braintree.TransactionRequest
{
Amount = btInvoiceAmount,
CustomerId = customer.Metadata["btCustomerId"],
Options = new Braintree.TransactionOptionsRequest
{
Amount = btInvoiceAmount,
CustomerId = customer.Metadata["btCustomerId"],
Options = new Braintree.TransactionOptionsRequest
{
SubmitForSettlement = true,
PayPal = new Braintree.TransactionOptionsPayPalRequest
{
CustomField =
$"{btObjIdField}:{btObjId},region:{_globalSettings.BaseServiceUri.CloudRegion}"
}
},
CustomFields = new Dictionary<string, string>
SubmitForSettlement = true,
PayPal = new Braintree.TransactionOptionsPayPalRequest
{
[btObjIdField] = btObjId.ToString(),
["region"] = _globalSettings.BaseServiceUri.CloudRegion
CustomField =
$"{btObjIdField}:{btObjId},region:{_globalSettings.BaseServiceUri.CloudRegion}"
}
});
},
CustomFields = new Dictionary<string, string>
{
[btObjIdField] = btObjId.ToString(),
["region"] = _globalSettings.BaseServiceUri.CloudRegion
}
};

_logger.LogInformation("Creating Braintree transaction with Amount: {Amount}, CustomerId: {CustomerId}, " +
"CustomField: {CustomField}, CustomFields: {@CustomFields}",
transactionRequest.Amount,
transactionRequest.CustomerId,
transactionRequest.Options.PayPal.CustomField,
transactionRequest.CustomFields);

transactionResult = await _btGateway.Transaction.SaleAsync(transactionRequest);
}
catch (NotFoundException e)
{
_logger.LogError(e,
"Attempted to make a payment with Braintree, but customer did not exist for the given btCustomerId present on the Stripe metadata");
throw;
}
catch (Exception e)
{
_logger.LogError(e, "Exception occurred while trying to pay invoice with Braintree");
throw;
}

if (!transactionResult.IsSuccess())
{
_logger.LogWarning("Braintree transaction failed. Error: {ErrorMessage}, Transaction Status: {Status}, Validation Errors: {ValidationErrors}",
transactionResult.Message,
transactionResult.Target?.Status,
string.Join(", ", transactionResult.Errors.DeepAll().Select(e => $"Code: {e.Code}, Message: {e.Message}, Attribute: {e.Attribute}")));

if (invoice.AttemptCount < 4)
{
await _mailService.SendPaymentFailedAsync(customer.Email, btInvoiceAmount, true);
Expand Down
36 changes: 36 additions & 0 deletions src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
using Bit.Billing.Constants;
using Bit.Billing.Jobs;
using Bit.Core;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Quartz;
using Stripe;
using Event = Stripe.Event;

Expand All @@ -20,6 +23,8 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
private readonly IUserService _userService;
private readonly IPushNotificationService _pushNotificationService;
private readonly IOrganizationRepository _organizationRepository;
private readonly ISchedulerFactory _schedulerFactory;
private readonly IFeatureService _featureService;
private readonly IOrganizationEnableCommand _organizationEnableCommand;

public SubscriptionUpdatedHandler(
Expand All @@ -31,6 +36,8 @@ public SubscriptionUpdatedHandler(
IUserService userService,
IPushNotificationService pushNotificationService,
IOrganizationRepository organizationRepository,
ISchedulerFactory schedulerFactory,
IFeatureService featureService,
IOrganizationEnableCommand organizationEnableCommand)
{
_stripeEventService = stripeEventService;
Expand All @@ -41,6 +48,8 @@ public SubscriptionUpdatedHandler(
_userService = userService;
_pushNotificationService = pushNotificationService;
_organizationRepository = organizationRepository;
_schedulerFactory = schedulerFactory;
_featureService = featureService;
_organizationEnableCommand = organizationEnableCommand;
}

Expand All @@ -59,6 +68,10 @@ public async Task HandleAsync(Event parsedEvent)
when organizationId.HasValue:
{
await _organizationService.DisableAsync(organizationId.Value, subscription.CurrentPeriodEnd);
if (subscription.Status == StripeSubscriptionStatus.Unpaid)
{
await ScheduleCancellationJobAsync(subscription.Id, organizationId.Value);
}
break;
}
case StripeSubscriptionStatus.Unpaid or StripeSubscriptionStatus.IncompleteExpired:
Expand Down Expand Up @@ -187,4 +200,27 @@ p.SecretsManager is not null &&
await _stripeFacade.DeleteSubscriptionDiscount(subscription.Id);
}
}

private async Task ScheduleCancellationJobAsync(string subscriptionId, Guid organizationId)
{
var isResellerManagedOrgAlertEnabled = _featureService.IsEnabled(FeatureFlagKeys.ResellerManagedOrgAlert);

if (isResellerManagedOrgAlertEnabled)
{
var scheduler = await _schedulerFactory.GetScheduler();

var job = JobBuilder.Create<SubscriptionCancellationJob>()
.WithIdentity($"cancel-sub-{subscriptionId}", "subscription-cancellations")
.UsingJobData("subscriptionId", subscriptionId)
.UsingJobData("organizationId", organizationId.ToString())
.Build();

var trigger = TriggerBuilder.Create()
.WithIdentity($"cancel-trigger-{subscriptionId}", "subscription-cancellations")
.StartAt(DateTimeOffset.UtcNow.AddDays(7))
.Build();

await scheduler.ScheduleJob(job, trigger);
}
}
}
8 changes: 8 additions & 0 deletions src/Billing/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using Bit.Core.Utilities;
using Bit.SharedWeb.Utilities;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Quartz;
using Stripe;

namespace Bit.Billing;
Expand Down Expand Up @@ -101,6 +102,13 @@ public void ConfigureServices(IServiceCollection services)
services.AddScoped<IStripeEventService, StripeEventService>();
services.AddScoped<IProviderEventService, ProviderEventService>();

// Add Quartz services first
services.AddQuartz(q =>
{
q.UseMicrosoftDependencyInjectionJobFactory();
});
services.AddQuartzHostedService();

// Jobs service
Jobs.JobsHostedService.AddJobsServices(services);
services.AddHostedService<Jobs.JobsHostedService>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ public class PaymentHistoryService(
var invoices = await stripeAdapter.InvoiceListAsync(new StripeInvoiceListOptions
{
Customer = subscriber.GatewayCustomerId,
Subscription = subscriber.GatewaySubscriptionId,
Limit = pageSize,
Status = status,
StartingAfter = startAfter
Expand Down
3 changes: 3 additions & 0 deletions src/Core/Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@
<PackageReference Include="YubicoDotNetClient" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.10" />
<PackageReference Include="LaunchDarkly.ServerSdk" Version="8.6.0" />
<PackageReference Include="Quartz" Version="3.13.1" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.13.1" />
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.13.1" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,71 @@ public async Task GetManyByUserIdWithDeviceAuth_Works_ReturnsExpectedResults(
Assert.Equal(response.First().AuthRequestId, freshAuthRequest.Id);
}

[DatabaseTheory]
[DatabaseData]
public async Task GetManyByUserIdWithDeviceAuth_WorksWithMultipleUsersOnSameDevice_ReturnsExpectedResults(
IDeviceRepository sutRepository,
IUserRepository userRepository,
IAuthRequestRepository authRequestRepository)
{
// Arrange
var userA = await userRepository.CreateAsync(new User
{
Name = "Test User A",
Email = $"test_user_A+{Guid.NewGuid()}@email.com",
ApiKey = "TEST",
SecurityStamp = "stamp",
});

var userB = await userRepository.CreateAsync(new User
{
Name = "Test User B",
Email = $"test_user_B+{Guid.NewGuid()}@email.com",
ApiKey = "TEST",
SecurityStamp = "stamp",
});

var sharedDeviceIdentifier = Guid.NewGuid().ToString();

var deviceForUserA = await sutRepository.CreateAsync(new Device
{
Active = true,
Name = "chrome-test",
UserId = userA.Id,
Type = DeviceType.ChromeBrowser,
Identifier = sharedDeviceIdentifier,
});

var deviceForUserB = await sutRepository.CreateAsync(new Device
{
Active = true,
Name = "chrome-test",
UserId = userB.Id,
Type = DeviceType.ChromeBrowser,
Identifier = sharedDeviceIdentifier,
});

var userAAuthRequest = await authRequestRepository.CreateAsync(new AuthRequest
{
ResponseDeviceId = null,
Approved = null,
Type = AuthRequestType.AuthenticateAndUnlock,
OrganizationId = null,
UserId = userA.Id,
RequestIpAddress = ":1",
RequestDeviceIdentifier = deviceForUserA.Identifier,
AccessCode = "AccessCode_1234",
PublicKey = "PublicKey_1234"
});

// Act
var response = await sutRepository.GetManyByUserIdWithDeviceAuth(userB.Id);

// Assert
Assert.Null(response.First().AuthRequestId);
Assert.Null(response.First().AuthRequestCreatedAt);
}

[DatabaseTheory]
[DatabaseData]
public async Task GetManyByUserIdWithDeviceAuth_WorksWithNoAuthRequestAndMultipleDevices_ReturnsExpectedResults(
Expand Down Expand Up @@ -117,7 +182,7 @@ await sutRepository.CreateAsync(new Device

[DatabaseTheory]
[DatabaseData]
public async Task GetManyByUserIdWithDeviceAuth_FailsToRespondWithAnyAuthData_ReturnsExpectedResults(
public async Task GetManyByUserIdWithDeviceAuth_FailsToRespondWithAnyAuthData_ReturnsEmptyResults(
IDeviceRepository sutRepository,
IUserRepository userRepository,
IAuthRequestRepository authRequestRepository)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,13 @@ protected override void Up(MigrationBuilder migrationBuilder)

migrationBuilder.Sql(@"
DROP PROCEDURE IF EXISTS GrantSchemaChange;
CREATE PROCEDURE GrantSchemaChange()
BEGIN
IF EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'Grant' AND COLUMN_NAME = 'Id') THEN
IF EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'Grant' AND COLUMN_NAME = 'Id' AND TABLE_SCHEMA=database()) THEN
ALTER TABLE `Grant` DROP COLUMN `Id`;
END IF;
ALTER TABLE `Grant` ADD COLUMN `Id` INT AUTO_INCREMENT UNIQUE;
END;
Expand Down

0 comments on commit e380aec

Please sign in to comment.