1
0
mirror of https://github.com/bitwarden/server.git synced 2025-07-04 17:42:49 -05:00

[PM-12420] Stripe events recovery (#4793)

* 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
This commit is contained in:
Alex Morask
2024-09-26 09:18:21 -04:00
committed by GitHub
parent 3f629e0a5a
commit 05247d2525
21 changed files with 379 additions and 3 deletions

View File

@ -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'))">

View File

@ -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);
}
}

View File

@ -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_'.");
}
}
}

View File

@ -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; }
}

View File

@ -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
}

View File

@ -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>

View File

@ -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>

View File

@ -0,0 +1,5 @@
@using Microsoft.AspNetCore.Identity
@using Bit.Admin.AdminConsole
@using Bit.Admin.AdminConsole.Models
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper "*, Admin"

View File

@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}

View File

@ -47,5 +47,6 @@ public enum Permission
Tools_GenerateLicenseFile,
Tools_ManageTaxRates,
Tools_ManageStripeSubscriptions,
Tools_CreateEditTransaction
Tools_CreateEditTransaction,
Tools_ProcessStripeEvents
}

View File

@ -89,6 +89,7 @@ public class Startup
services.AddDefaultServices(globalSettings);
services.AddScoped<IAccessControlService, AccessControlService>();
services.AddBillingOperations();
services.AddHttpClient();
#if OSS
services.AddOosServices();
@ -108,6 +109,7 @@ public class Startup
{
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

View File

@ -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>

View File

@ -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
</a>
}
@if (canProcessStripeEvents)
{
<a class="dropdown-item" asp-controller="ProcessStripeEvents" asp-action="Index">
Process Stripe Events
</a>
}
</div>
</li>
}

View File

@ -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": {