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:
parent
3f629e0a5a
commit
05247d2525
@ -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'))">
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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_'.");
|
||||
}
|
||||
}
|
||||
}
|
@ -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; }
|
||||
}
|
@ -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
src/Admin/Billing/Views/ProcessStripeEvents/Index.cshtml
Normal file
25
src/Admin/Billing/Views/ProcessStripeEvents/Index.cshtml
Normal 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>
|
49
src/Admin/Billing/Views/ProcessStripeEvents/Results.cshtml
Normal file
49
src/Admin/Billing/Views/ProcessStripeEvents/Results.cshtml
Normal 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>
|
5
src/Admin/Billing/Views/_ViewImports.cshtml
Normal file
5
src/Admin/Billing/Views/_ViewImports.cshtml
Normal 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"
|
3
src/Admin/Billing/Views/_ViewStart.cshtml
Normal file
3
src/Admin/Billing/Views/_ViewStart.cshtml
Normal file
@ -0,0 +1,3 @@
|
||||
@{
|
||||
Layout = "_Layout";
|
||||
}
|
@ -47,5 +47,6 @@ public enum Permission
|
||||
Tools_GenerateLicenseFile,
|
||||
Tools_ManageTaxRates,
|
||||
Tools_ManageStripeSubscriptions,
|
||||
Tools_CreateEditTransaction
|
||||
Tools_CreateEditTransaction,
|
||||
Tools_ProcessStripeEvents
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
}
|
||||
|
@ -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": {
|
||||
|
68
src/Billing/Controllers/RecoveryController.cs
Normal file
68
src/Billing/Controllers/RecoveryController.cs
Normal 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
|
||||
};
|
||||
}
|
9
src/Billing/Models/Recovery/EventsRequestBody.cs
Normal file
9
src/Billing/Models/Recovery/EventsRequestBody.cs
Normal 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; }
|
||||
}
|
31
src/Billing/Models/Recovery/EventsResponseBody.cs
Normal file
31
src/Billing/Models/Recovery/EventsResponseBody.cs
Normal 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; }
|
||||
}
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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; }
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user