Skip to content

Commit

Permalink
[PM-12420] Stripe events recovery (#4793)
Browse files Browse the repository at this point in the history
* Billing: Add event recovery endpoints

* Core: Add InternalBilling to BaseServiceUriSettings

* Admin: Scaffold billing section

* Admin: Scaffold ProcessStripeEvents section

* Admin: Implement event processing

* Run dotnet format
  • Loading branch information
amorask-bitwarden committed Sep 26, 2024
1 parent 150c780 commit cd43535
Show file tree
Hide file tree
Showing 21 changed files with 379 additions and 3 deletions.
4 changes: 4 additions & 0 deletions src/Admin/Admin.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
<ProjectReference Include="..\Core\Core.csproj" />
<ProjectReference Include="..\..\util\SqliteMigrations\SqliteMigrations.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Billing\Controllers\" />
<Folder Include="Billing\Models\" />
</ItemGroup>

<Choose>
<When Condition="!$(DefineConstants.Contains('OSS'))">
Expand Down
71 changes: 71 additions & 0 deletions src/Admin/Billing/Controllers/ProcessStripeEventsController.cs
Original file line number Diff line number Diff line change
@@ -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<IActionResult> 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<EventsResponseBody>(responseContent);

return (response, null);
}
}
29 changes: 29 additions & 0 deletions src/Admin/Billing/Models/ProcessStripeEvents/EventsFormModel.cs
Original file line number Diff line number Diff line change
@@ -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<string> GetEventIds() =>
EventIds?.Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries)
.Select(eventId => eventId.Trim())
.ToList() ?? [];

public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
var eventIds = GetEventIds();

if (eventIds.Any(eventId => !eventId.StartsWith("evt_")))
{
yield return new ValidationResult("Event Ids must start with 'evt_'.");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using System.Text.Json.Serialization;

namespace Bit.Admin.Billing.Models.ProcessStripeEvents;

public class EventsRequestBody
{
[JsonPropertyName("eventIds")]
public List<string> EventIds { get; set; }
}
39 changes: 39 additions & 0 deletions src/Admin/Billing/Models/ProcessStripeEvents/EventsResponseBody.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using System.Text.Json.Serialization;

namespace Bit.Admin.Billing.Models.ProcessStripeEvents;

public class EventsResponseBody
{
[JsonPropertyName("events")]
public List<EventResponseBody> 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
}
25 changes: 25 additions & 0 deletions src/Admin/Billing/Views/ProcessStripeEvents/Index.cshtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
@model Bit.Admin.Billing.Models.ProcessStripeEvents.EventsFormModel

@{
ViewData["Title"] = "Process Stripe Events";
}

<h1>Process Stripe Events</h1>
<form method="post" asp-controller="ProcessStripeEvents" asp-action="Process">
<div class="row">
<div class="col-1">
<div class="form-group">
<input type="submit" value="Process" class="btn btn-primary mb-2"/>
</div>
</div>
<div class="col-2">
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" asp-for="Inspect">
<label class="form-check-label" asp-for="Inspect"></label>
</div>
</div>
</div>
<div class="form-group">
<textarea id="event-ids" type="text" class="form-control" rows="100" asp-for="EventIds"></textarea>
</div>
</form>
49 changes: 49 additions & 0 deletions src/Admin/Billing/Views/ProcessStripeEvents/Results.cshtml
Original file line number Diff line number Diff line change
@@ -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;
}

<h1>@title</h1>
<h2>Results</h2>

<div class="table-responsive">
@if (!Model.Events.Any())
{
<p>No data found.</p>
}
else
{
<table class="table table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Type</th>
<th>API Version</th>
<th>Created</th>
@if (Model.ActionType == EventActionType.Process)
{
<th>Processing Error</th>
}
</tr>
</thead>
<tbody>
@foreach (var eventResponseBody in Model.Events)
{
<tr>
<td><a href="@eventResponseBody.URL">@eventResponseBody.Id</a></td>
<td>@eventResponseBody.Type</td>
<td>@eventResponseBody.APIVersion</td>
<td>@eventResponseBody.CreatedUTC</td>
@if (Model.ActionType == EventActionType.Process)
{
<td>@eventResponseBody.ProcessingError</td>
}
</tr>
}
</tbody>
</table>
}
</div>
5 changes: 5 additions & 0 deletions src/Admin/Billing/Views/_ViewImports.cshtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@using Microsoft.AspNetCore.Identity
@using Bit.Admin.AdminConsole
@using Bit.Admin.AdminConsole.Models
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper "*, Admin"
3 changes: 3 additions & 0 deletions src/Admin/Billing/Views/_ViewStart.cshtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}
3 changes: 2 additions & 1 deletion src/Admin/Enums/Permissions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,5 +47,6 @@ public enum Permission
Tools_GenerateLicenseFile,
Tools_ManageTaxRates,
Tools_ManageStripeSubscriptions,
Tools_CreateEditTransaction
Tools_CreateEditTransaction,
Tools_ProcessStripeEvents
}
2 changes: 2 additions & 0 deletions src/Admin/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ public void ConfigureServices(IServiceCollection services)
services.AddDefaultServices(globalSettings);
services.AddScoped<IAccessControlService, AccessControlService>();
services.AddBillingOperations();
services.AddHttpClient();

#if OSS
services.AddOosServices();
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/Admin/Utilities/RolePermissionMapping.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Permission>
Expand Down
7 changes: 7 additions & 0 deletions src/Admin/Views/Shared/_Layout.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -107,6 +108,12 @@
Manage Stripe Subscriptions
</a>
}
@if (canProcessStripeEvents)
{
<a class="dropdown-item" asp-controller="ProcessStripeEvents" asp-action="Index">
Process Stripe Events
</a>
}
</div>
</li>
}
Expand Down
3 changes: 2 additions & 1 deletion src/Admin/appsettings.Development.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
68 changes: 68 additions & 0 deletions src/Billing/Controllers/RecoveryController.cs
Original file line number Diff line number Diff line change
@@ -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<Ok<EventsResponseBody>> 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<Ok<EventsResponseBody>> 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
};
}
9 changes: 9 additions & 0 deletions src/Billing/Models/Recovery/EventsRequestBody.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using System.Text.Json.Serialization;

namespace Bit.Billing.Models.Recovery;

public class EventsRequestBody
{
[JsonPropertyName("eventIds")]
public List<string> EventIds { get; set; }
}
Loading

0 comments on commit cd43535

Please sign in to comment.