Browse Source
* Billing: Add event recovery endpoints * Core: Add InternalBilling to BaseServiceUriSettings * Admin: Scaffold billing section * Admin: Scaffold ProcessStripeEvents section * Admin: Implement event processing * Run dotnet formatv2024.9.2
21 changed files with 379 additions and 3 deletions
@ -0,0 +1,71 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 |
||||
} |
||||
@ -0,0 +1,25 @@
@@ -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> |
||||
@ -0,0 +1,49 @@
@@ -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> |
||||
@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
|
||||
@using Microsoft.AspNetCore.Identity |
||||
@using Bit.Admin.AdminConsole |
||||
@using Bit.Admin.AdminConsole.Models |
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers |
||||
@addTagHelper "*, Admin" |
||||
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
@{ |
||||
Layout = "_Layout"; |
||||
} |
||||
@ -0,0 +1,68 @@
@@ -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 |
||||
}; |
||||
} |
||||
@ -0,0 +1,9 @@
@@ -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; } |
||||
} |
||||
@ -0,0 +1,31 @@
@@ -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; } |
||||
} |
||||
Loading…
Reference in new issue