diff --git a/src/Admin/Admin.csproj b/src/Admin/Admin.csproj index cd30e841b451..5493e65afd8f 100644 --- a/src/Admin/Admin.csproj +++ b/src/Admin/Admin.csproj @@ -14,6 +14,10 @@ + + + + diff --git a/src/Admin/Billing/Controllers/ProcessStripeEventsController.cs b/src/Admin/Billing/Controllers/ProcessStripeEventsController.cs new file mode 100644 index 000000000000..1a3f56a1835c --- /dev/null +++ b/src/Admin/Billing/Controllers/ProcessStripeEventsController.cs @@ -0,0 +1,71 @@ +using System.Text.Json; +using Bit.Admin.Billing.Models.ProcessStripeEvents; +using Bit.Core.Settings; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Admin.Billing.Controllers; + +[Authorize] +[Route("process-stripe-events")] +[SelfHosted(NotSelfHostedOnly = true)] +public class ProcessStripeEventsController( + IHttpClientFactory httpClientFactory, + IGlobalSettings globalSettings) : Controller +{ + [HttpGet] + public ActionResult Index() + { + return View(new EventsFormModel()); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task ProcessAsync([FromForm] EventsFormModel model) + { + var eventIds = model.GetEventIds(); + + const string baseEndpoint = "stripe/recovery/events"; + + var endpoint = model.Inspect ? $"{baseEndpoint}/inspect" : $"{baseEndpoint}/process"; + + var (response, failedResponseMessage) = await PostAsync(endpoint, new EventsRequestBody + { + EventIds = eventIds + }); + + if (response == null) + { + return StatusCode((int)failedResponseMessage.StatusCode, "An error occurred during your request."); + } + + response.ActionType = model.Inspect ? EventActionType.Inspect : EventActionType.Process; + + return View("Results", response); + } + + private async Task<(EventsResponseBody, HttpResponseMessage)> PostAsync( + string endpoint, + EventsRequestBody requestModel) + { + var client = httpClientFactory.CreateClient("InternalBilling"); + client.BaseAddress = new Uri(globalSettings.BaseServiceUri.InternalBilling); + + var json = JsonSerializer.Serialize(requestModel); + var requestBody = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); + + var responseMessage = await client.PostAsync(endpoint, requestBody); + + if (!responseMessage.IsSuccessStatusCode) + { + return (null, responseMessage); + } + + var responseContent = await responseMessage.Content.ReadAsStringAsync(); + + var response = JsonSerializer.Deserialize(responseContent); + + return (response, null); + } +} diff --git a/src/Admin/Billing/Models/ProcessStripeEvents/EventsFormModel.cs b/src/Admin/Billing/Models/ProcessStripeEvents/EventsFormModel.cs new file mode 100644 index 000000000000..5ead00e26350 --- /dev/null +++ b/src/Admin/Billing/Models/ProcessStripeEvents/EventsFormModel.cs @@ -0,0 +1,29 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace Bit.Admin.Billing.Models.ProcessStripeEvents; + +public class EventsFormModel : IValidatableObject +{ + [Required] + public string EventIds { get; set; } + + [Required] + [DisplayName("Inspect Only")] + public bool Inspect { get; set; } + + public List GetEventIds() => + EventIds?.Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries) + .Select(eventId => eventId.Trim()) + .ToList() ?? []; + + public IEnumerable Validate(ValidationContext validationContext) + { + var eventIds = GetEventIds(); + + if (eventIds.Any(eventId => !eventId.StartsWith("evt_"))) + { + yield return new ValidationResult("Event Ids must start with 'evt_'."); + } + } +} diff --git a/src/Admin/Billing/Models/ProcessStripeEvents/EventsRequestBody.cs b/src/Admin/Billing/Models/ProcessStripeEvents/EventsRequestBody.cs new file mode 100644 index 000000000000..05a2444605ca --- /dev/null +++ b/src/Admin/Billing/Models/ProcessStripeEvents/EventsRequestBody.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Bit.Admin.Billing.Models.ProcessStripeEvents; + +public class EventsRequestBody +{ + [JsonPropertyName("eventIds")] + public List EventIds { get; set; } +} diff --git a/src/Admin/Billing/Models/ProcessStripeEvents/EventsResponseBody.cs b/src/Admin/Billing/Models/ProcessStripeEvents/EventsResponseBody.cs new file mode 100644 index 000000000000..84eeb35d29d6 --- /dev/null +++ b/src/Admin/Billing/Models/ProcessStripeEvents/EventsResponseBody.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Serialization; + +namespace Bit.Admin.Billing.Models.ProcessStripeEvents; + +public class EventsResponseBody +{ + [JsonPropertyName("events")] + public List Events { get; set; } + + [JsonIgnore] + public EventActionType ActionType { get; set; } +} + +public class EventResponseBody +{ + [JsonPropertyName("id")] + public string Id { get; set; } + + [JsonPropertyName("url")] + public string URL { get; set; } + + [JsonPropertyName("apiVersion")] + public string APIVersion { get; set; } + + [JsonPropertyName("type")] + public string Type { get; set; } + + [JsonPropertyName("createdUTC")] + public DateTime CreatedUTC { get; set; } + + [JsonPropertyName("processingError")] + public string ProcessingError { get; set; } +} + +public enum EventActionType +{ + Inspect, + Process +} diff --git a/src/Admin/Billing/Views/ProcessStripeEvents/Index.cshtml b/src/Admin/Billing/Views/ProcessStripeEvents/Index.cshtml new file mode 100644 index 000000000000..a8f5454d8e8c --- /dev/null +++ b/src/Admin/Billing/Views/ProcessStripeEvents/Index.cshtml @@ -0,0 +1,25 @@ +@model Bit.Admin.Billing.Models.ProcessStripeEvents.EventsFormModel + +@{ + ViewData["Title"] = "Process Stripe Events"; +} + +

Process Stripe Events

+
+
+
+
+ +
+
+
+
+ + +
+
+
+
+ +
+
diff --git a/src/Admin/Billing/Views/ProcessStripeEvents/Results.cshtml b/src/Admin/Billing/Views/ProcessStripeEvents/Results.cshtml new file mode 100644 index 000000000000..2293f4833f0f --- /dev/null +++ b/src/Admin/Billing/Views/ProcessStripeEvents/Results.cshtml @@ -0,0 +1,49 @@ +@using Bit.Admin.Billing.Models.ProcessStripeEvents +@model Bit.Admin.Billing.Models.ProcessStripeEvents.EventsResponseBody + +@{ + var title = Model.ActionType == EventActionType.Inspect ? "Inspect Stripe Events" : "Process Stripe Events"; + ViewData["Title"] = title; +} + +

@title

+

Results

+ +
+ @if (!Model.Events.Any()) + { +

No data found.

+ } + else + { + + + + + + + + @if (Model.ActionType == EventActionType.Process) + { + + } + + + + @foreach (var eventResponseBody in Model.Events) + { + + + + + + @if (Model.ActionType == EventActionType.Process) + { + + } + + } + +
IDTypeAPI VersionCreatedProcessing Error
@eventResponseBody.Id@eventResponseBody.Type@eventResponseBody.APIVersion@eventResponseBody.CreatedUTC@eventResponseBody.ProcessingError
+ } +
diff --git a/src/Admin/Billing/Views/_ViewImports.cshtml b/src/Admin/Billing/Views/_ViewImports.cshtml new file mode 100644 index 000000000000..02423ba0e704 --- /dev/null +++ b/src/Admin/Billing/Views/_ViewImports.cshtml @@ -0,0 +1,5 @@ +@using Microsoft.AspNetCore.Identity +@using Bit.Admin.AdminConsole +@using Bit.Admin.AdminConsole.Models +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper "*, Admin" diff --git a/src/Admin/Billing/Views/_ViewStart.cshtml b/src/Admin/Billing/Views/_ViewStart.cshtml new file mode 100644 index 000000000000..820a2f6e02f1 --- /dev/null +++ b/src/Admin/Billing/Views/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "_Layout"; +} diff --git a/src/Admin/Enums/Permissions.cs b/src/Admin/Enums/Permissions.cs index 6b73ba4205aa..a8168b9e1ddd 100644 --- a/src/Admin/Enums/Permissions.cs +++ b/src/Admin/Enums/Permissions.cs @@ -47,5 +47,6 @@ public enum Permission Tools_GenerateLicenseFile, Tools_ManageTaxRates, Tools_ManageStripeSubscriptions, - Tools_CreateEditTransaction + Tools_CreateEditTransaction, + Tools_ProcessStripeEvents } diff --git a/src/Admin/Startup.cs b/src/Admin/Startup.cs index 788908d42a19..f25e5072db9b 100644 --- a/src/Admin/Startup.cs +++ b/src/Admin/Startup.cs @@ -89,6 +89,7 @@ public void ConfigureServices(IServiceCollection services) services.AddDefaultServices(globalSettings); services.AddScoped(); services.AddBillingOperations(); + services.AddHttpClient(); #if OSS services.AddOosServices(); @@ -108,6 +109,7 @@ public void ConfigureServices(IServiceCollection services) { o.ViewLocationFormats.Add("/Auth/Views/{1}/{0}.cshtml"); o.ViewLocationFormats.Add("/AdminConsole/Views/{1}/{0}.cshtml"); + o.ViewLocationFormats.Add("/Billing/Views/{1}/{0}.cshtml"); }); // Jobs service diff --git a/src/Admin/Utilities/RolePermissionMapping.cs b/src/Admin/Utilities/RolePermissionMapping.cs index 0ca37f713963..cb4a0fe47953 100644 --- a/src/Admin/Utilities/RolePermissionMapping.cs +++ b/src/Admin/Utilities/RolePermissionMapping.cs @@ -161,7 +161,8 @@ public static class RolePermissionMapping Permission.Tools_GenerateLicenseFile, Permission.Tools_ManageTaxRates, Permission.Tools_ManageStripeSubscriptions, - Permission.Tools_CreateEditTransaction + Permission.Tools_CreateEditTransaction, + Permission.Tools_ProcessStripeEvents, } }, { "sales", new List diff --git a/src/Admin/Views/Shared/_Layout.cshtml b/src/Admin/Views/Shared/_Layout.cshtml index 7b204f48f8df..d3bfc6313b8f 100644 --- a/src/Admin/Views/Shared/_Layout.cshtml +++ b/src/Admin/Views/Shared/_Layout.cshtml @@ -14,6 +14,7 @@ var canGenerateLicense = AccessControlService.UserHasPermission(Permission.Tools_GenerateLicenseFile); var canManageTaxRates = AccessControlService.UserHasPermission(Permission.Tools_ManageTaxRates); var canManageStripeSubscriptions = AccessControlService.UserHasPermission(Permission.Tools_ManageStripeSubscriptions); + var canProcessStripeEvents = AccessControlService.UserHasPermission(Permission.Tools_ProcessStripeEvents); var canViewTools = canChargeBraintree || canCreateTransaction || canPromoteAdmin || canGenerateLicense || canManageTaxRates || canManageStripeSubscriptions; @@ -107,6 +108,12 @@ Manage Stripe Subscriptions } + @if (canProcessStripeEvents) + { + + Process Stripe Events + + } } diff --git a/src/Admin/appsettings.Development.json b/src/Admin/appsettings.Development.json index 645b9020dfda..861f9be98dd6 100644 --- a/src/Admin/appsettings.Development.json +++ b/src/Admin/appsettings.Development.json @@ -13,7 +13,8 @@ "internalApi": "http://localhost:4000", "internalVault": "https://localhost:8080", "internalSso": "http://localhost:51822", - "internalScim": "http://localhost:44559" + "internalScim": "http://localhost:44559", + "internalBilling": "http://localhost:44519" }, "mail": { "smtp": { diff --git a/src/Billing/Controllers/RecoveryController.cs b/src/Billing/Controllers/RecoveryController.cs new file mode 100644 index 000000000000..bada1e826d4e --- /dev/null +++ b/src/Billing/Controllers/RecoveryController.cs @@ -0,0 +1,68 @@ +using Bit.Billing.Models.Recovery; +using Bit.Billing.Services; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Stripe; + +namespace Bit.Billing.Controllers; + +[Route("stripe/recovery")] +[SelfHosted(NotSelfHostedOnly = true)] +public class RecoveryController( + IStripeEventProcessor stripeEventProcessor, + IStripeFacade stripeFacade, + IWebHostEnvironment webHostEnvironment) : Controller +{ + private readonly string _stripeURL = webHostEnvironment.IsDevelopment() || webHostEnvironment.IsEnvironment("QA") + ? "https://dashboard.stripe.com/test" + : "https://dashboard.stripe.com"; + + // ReSharper disable once RouteTemplates.ActionRoutePrefixCanBeExtractedToControllerRoute + [HttpPost("events/inspect")] + public async Task> InspectEventsAsync([FromBody] EventsRequestBody requestBody) + { + var inspected = await Task.WhenAll(requestBody.EventIds.Select(async eventId => + { + var @event = await stripeFacade.GetEvent(eventId); + return Map(@event); + })); + + var response = new EventsResponseBody { Events = inspected.ToList() }; + + return TypedResults.Ok(response); + } + + // ReSharper disable once RouteTemplates.ActionRoutePrefixCanBeExtractedToControllerRoute + [HttpPost("events/process")] + public async Task> ProcessEventsAsync([FromBody] EventsRequestBody requestBody) + { + var processed = await Task.WhenAll(requestBody.EventIds.Select(async eventId => + { + var @event = await stripeFacade.GetEvent(eventId); + try + { + await stripeEventProcessor.ProcessEventAsync(@event); + return Map(@event); + } + catch (Exception exception) + { + return Map(@event, exception.Message); + } + })); + + var response = new EventsResponseBody { Events = processed.ToList() }; + + return TypedResults.Ok(response); + } + + private EventResponseBody Map(Event @event, string processingError = null) => new() + { + Id = @event.Id, + URL = $"{_stripeURL}/workbench/events/{@event.Id}", + APIVersion = @event.ApiVersion, + Type = @event.Type, + CreatedUTC = @event.Created, + ProcessingError = processingError + }; +} diff --git a/src/Billing/Models/Recovery/EventsRequestBody.cs b/src/Billing/Models/Recovery/EventsRequestBody.cs new file mode 100644 index 000000000000..a40f8c9655dd --- /dev/null +++ b/src/Billing/Models/Recovery/EventsRequestBody.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Bit.Billing.Models.Recovery; + +public class EventsRequestBody +{ + [JsonPropertyName("eventIds")] + public List EventIds { get; set; } +} diff --git a/src/Billing/Models/Recovery/EventsResponseBody.cs b/src/Billing/Models/Recovery/EventsResponseBody.cs new file mode 100644 index 000000000000..a0c7f087b741 --- /dev/null +++ b/src/Billing/Models/Recovery/EventsResponseBody.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; + +namespace Bit.Billing.Models.Recovery; + +public class EventsResponseBody +{ + [JsonPropertyName("events")] + public List Events { get; set; } +} + +public class EventResponseBody +{ + [JsonPropertyName("id")] + public string Id { get; set; } + + [JsonPropertyName("url")] + public string URL { get; set; } + + [JsonPropertyName("apiVersion")] + public string APIVersion { get; set; } + + [JsonPropertyName("type")] + public string Type { get; set; } + + [JsonPropertyName("createdUTC")] + public DateTime CreatedUTC { get; set; } + + [JsonPropertyName("processingError")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string ProcessingError { get; set; } +} diff --git a/src/Billing/Services/IStripeFacade.cs b/src/Billing/Services/IStripeFacade.cs index 0791e507fd71..f793846a53e5 100644 --- a/src/Billing/Services/IStripeFacade.cs +++ b/src/Billing/Services/IStripeFacade.cs @@ -16,6 +16,12 @@ Task GetCustomer( RequestOptions requestOptions = null, CancellationToken cancellationToken = default); + Task GetEvent( + string eventId, + EventGetOptions eventGetOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default); + Task GetInvoice( string invoiceId, InvoiceGetOptions invoiceGetOptions = null, diff --git a/src/Billing/Services/Implementations/StripeFacade.cs b/src/Billing/Services/Implementations/StripeFacade.cs index 05ad9e0f4ca7..42049467813b 100644 --- a/src/Billing/Services/Implementations/StripeFacade.cs +++ b/src/Billing/Services/Implementations/StripeFacade.cs @@ -6,6 +6,7 @@ public class StripeFacade : IStripeFacade { private readonly ChargeService _chargeService = new(); private readonly CustomerService _customerService = new(); + private readonly EventService _eventService = new(); private readonly InvoiceService _invoiceService = new(); private readonly PaymentMethodService _paymentMethodService = new(); private readonly SubscriptionService _subscriptionService = new(); @@ -19,6 +20,13 @@ public async Task GetCharge( CancellationToken cancellationToken = default) => await _chargeService.GetAsync(chargeId, chargeGetOptions, requestOptions, cancellationToken); + public async Task GetEvent( + string eventId, + EventGetOptions eventGetOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) => + await _eventService.GetAsync(eventId, eventGetOptions, requestOptions, cancellationToken); + public async Task GetCustomer( string customerId, CustomerGetOptions customerGetOptions = null, diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index 42e3f2bdc9c9..f99fb3b57d19 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -140,6 +140,7 @@ public class BaseServiceUriSettings : IBaseServiceUriSettings private string _internalSso; private string _internalVault; private string _internalScim; + private string _internalBilling; public BaseServiceUriSettings(GlobalSettings globalSettings) { @@ -218,6 +219,12 @@ public string InternalScim get => _globalSettings.BuildInternalUri(_scim, "scim"); set => _internalScim = value; } + + public string InternalBilling + { + get => _globalSettings.BuildInternalUri(_internalBilling, "billing"); + set => _internalBilling = value; + } } public class SqlSettings diff --git a/src/Core/Settings/IBaseServiceUriSettings.cs b/src/Core/Settings/IBaseServiceUriSettings.cs index 0c2ed15f6647..2a1d165ac1e3 100644 --- a/src/Core/Settings/IBaseServiceUriSettings.cs +++ b/src/Core/Settings/IBaseServiceUriSettings.cs @@ -20,4 +20,5 @@ public interface IBaseServiceUriSettings public string InternalVault { get; set; } public string InternalSso { get; set; } public string InternalScim { get; set; } + public string InternalBilling { get; set; } }