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 @@ |
|||||||
|
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 |
||||||
|
} |
||||||
@ -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 @@ |
|||||||
|
@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 @@ |
|||||||
|
@using Microsoft.AspNetCore.Identity |
||||||
|
@using Bit.Admin.AdminConsole |
||||||
|
@using Bit.Admin.AdminConsole.Models |
||||||
|
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers |
||||||
|
@addTagHelper "*, Admin" |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
@{ |
||||||
|
Layout = "_Layout"; |
||||||
|
} |
||||||
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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