1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-21 12:05:42 +01: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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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": {

View File

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

View File

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

View File

@ -0,0 +1,31 @@
using System.Text.Json.Serialization;
namespace Bit.Billing.Models.Recovery;
public class EventsResponseBody
{
[JsonPropertyName("events")]
public List<EventResponseBody> 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; }
}

View File

@ -16,6 +16,12 @@ public interface IStripeFacade
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default);
Task<Event> GetEvent(
string eventId,
EventGetOptions eventGetOptions = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default);
Task<Invoice> GetInvoice(
string invoiceId,
InvoiceGetOptions invoiceGetOptions = null,

View File

@ -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 class StripeFacade : IStripeFacade
CancellationToken cancellationToken = default) =>
await _chargeService.GetAsync(chargeId, chargeGetOptions, requestOptions, cancellationToken);
public async Task<Event> GetEvent(
string eventId,
EventGetOptions eventGetOptions = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default) =>
await _eventService.GetAsync(eventId, eventGetOptions, requestOptions, cancellationToken);
public async Task<Customer> GetCustomer(
string customerId,
CustomerGetOptions customerGetOptions = null,

View File

@ -140,6 +140,7 @@ public class GlobalSettings : IGlobalSettings
private string _internalSso;
private string _internalVault;
private string _internalScim;
private string _internalBilling;
public BaseServiceUriSettings(GlobalSettings globalSettings)
{
@ -218,6 +219,12 @@ public class GlobalSettings : IGlobalSettings
get => _globalSettings.BuildInternalUri(_scim, "scim");
set => _internalScim = value;
}
public string InternalBilling
{
get => _globalSettings.BuildInternalUri(_internalBilling, "billing");
set => _internalBilling = value;
}
}
public class SqlSettings

View File

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