feat(client): add payment components

This commit is contained in:
Stevan Freeborn
2026-01-04 21:12:53 -06:00
parent 4e18abfa3d
commit 26f163d878
9 changed files with 377 additions and 111 deletions
@@ -0,0 +1,78 @@
using System.ComponentModel.DataAnnotations;
using GroundsForSupport.Server.Payments.Stripe;
using GroundsForSupport.Server.RateLimiting;
namespace GroundsForSupport.Server.Payments.Endpoints;
internal static class CreatePaymentIntentEndpoint
{
private const string Route = "/payments/create-intent";
public static IEndpointConventionBuilder MapCreatePaymentIntentEndpoint(this WebApplication app)
{
return app.MapPost(Route, CreatePaymentIntentHandler).RequireRateLimiting(FixedRateLimitPolicy.Name);
}
public static async Task<IResult> CreatePaymentIntentHandler(
Request request,
IStripeService stripeService
)
{
var validationErrors = request.Validate(new ValidationContext(request));
if (validationErrors.Any())
{
return Results.ValidationProblem(validationErrors
.GroupBy(static e => e.MemberNames.FirstOrDefault() ?? string.Empty)
.ToDictionary(static g => g.Key, static g => g.Select(static e => e.ErrorMessage ?? string.Empty).ToArray()));
}
var (isSuccess, intent) = await stripeService.CreatePaymentIntentAsync(
request.Name,
request.Amount,
request.Message,
request.Email
);
if (isSuccess is false)
{
return Results.InternalServerError();
}
return Results.Ok(intent);
}
internal sealed record Request(
string Name,
decimal Amount,
string? Message,
string? Email
)
{
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (string.IsNullOrWhiteSpace(Name))
{
yield return new ValidationResult("Name is required", [nameof(Name)]);
}
if (Amount <= 0)
{
yield return new ValidationResult("Amount must be greater than zero", [nameof(Amount)]);
}
if (Message?.Length > 250)
{
yield return new ValidationResult("Message cannot exceed 250 characters", [nameof(Message)]);
}
if (string.IsNullOrWhiteSpace(Email) is false && new EmailAddressAttribute().IsValid(Email) is false)
{
yield return new ValidationResult("Email is not valid", [nameof(Email)]);
}
}
}
internal sealed record Response(string ClientSecret);
}
@@ -0,0 +1,65 @@
using GroundsForSupport.Server.Data;
using GroundsForSupport.Server.Payments.Stripe;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Stripe;
namespace GroundsForSupport.Server.Payments.Endpoints;
internal static class EventsEndpoint
{
private const string Route = "/events";
private const string StripeSignatureHeader = "Stripe-Signature";
internal static IEndpointConventionBuilder MapEventsEndpoint(this WebApplication app)
{
return app.MapPost(Route, EventsHandler);
}
internal static async Task<IResult> EventsHandler(
HttpContext httpContext,
[FromServices] IOptions<StripeOptions> options,
[FromServices] Context dbContext,
[FromServices] TimeProvider timeProvider
)
{
var json = await new StreamReader(httpContext.Request.Body).ReadToEndAsync();
try
{
var stripeEvent = EventUtility.ParseEvent(json);
var signatureHeader = httpContext.Request.Headers[StripeSignatureHeader];
stripeEvent = EventUtility.ConstructEvent(json, signatureHeader, options.Value.EventsWebhookSecret);
if (stripeEvent.Type is not EventTypes.PaymentIntentSucceeded)
{
return Results.Ok();
}
var paymentIntent = (PaymentIntent)stripeEvent.Data.Object;
var name = paymentIntent.Metadata[nameof(Payment.Name)];
var message = paymentIntent.Metadata[nameof(Payment.Message)];
var payment = new Payment()
{
Id = paymentIntent.Id,
Amount = paymentIntent.AmountReceived,
Name = name,
Message = message,
CreatedAtUnix = timeProvider.GetUtcNow().ToUnixTimeMilliseconds(),
};
dbContext.Payments.Add(payment);
await dbContext.SaveChangesAsync();
return Results.Ok();
}
catch (StripeException e)
{
Console.WriteLine($"Stripe exception: {e.Message}");
return Results.BadRequest(new { error = e.Message });
}
}
}
@@ -0,0 +1,107 @@
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using GroundsForSupport.Server.Data;
using Microsoft.AspNetCore.Mvc;
namespace GroundsForSupport.Server.Payments.Endpoints;
internal static class GetPaymentsEndpoint
{
private const string Route = "/payments";
public static IEndpointConventionBuilder MapGetPaymentsEndpoint(this WebApplication app)
{
return app.MapGet(Route, GetPaymentsHandler);
}
public static async Task<IResult> GetPaymentsHandler(
[AsParameters] Request request,
[FromServices] Context context
)
{
var validationErrors = request.Validate(new ValidationContext(request));
if (validationErrors.Any())
{
return Results.ValidationProblem(validationErrors
.GroupBy(static e => e.MemberNames.FirstOrDefault() ?? string.Empty)
.ToDictionary(static g => g.Key, static g => g.Select(static e => e.ErrorMessage ?? string.Empty).ToArray()));
}
var totalNumberOfPayments = context.Payments.Count();
var totalNumberOfPages = (int)Math.Ceiling(totalNumberOfPayments / (double)request.PageSize);
var paymentsQuery = request.SortDirection?.ToLower(CultureInfo.CurrentCulture) is "asc"
? context.Payments
.OrderBy(static p => p.CreatedAtUnix)
: context.Payments
.OrderByDescending(static p => p.CreatedAtUnix);
var payments = paymentsQuery
.Skip((request.PageNumber - 1) * request.PageSize)
.Take(request.PageSize)
.Select(PaymentRecord.From)
.ToList();
var response = new Response(
totalNumberOfPayments,
totalNumberOfPages,
request.PageNumber,
payments
);
return Results.Ok(response);
}
internal sealed record Request(int PageSize = 50, int PageNumber = 1, string? SortDirection = null) : IValidatableObject
{
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (PageSize <= 0)
{
yield return new ValidationResult("PageSize must be greater than zero", [nameof(PageSize)]);
}
if (PageNumber <= 0)
{
yield return new ValidationResult("PageNumber must be greater than zero", [nameof(PageNumber)]);
}
if (
SortDirection is not null &&
SortDirection.ToLower(CultureInfo.CurrentCulture) is not "asc" and not "desc"
)
{
yield return new ValidationResult("SortDirection must be either 'asc' or 'desc'", [nameof(SortDirection)]);
}
}
}
internal sealed record Response(
int TotalNumberOfPayments,
int TotalNumberOfPages,
int CurrentPageNumber,
List<PaymentRecord> Payments
);
internal sealed record PaymentRecord
{
public string Name { get; init; } = string.Empty;
public long Amount { get; init; }
public string? Message { get; init; }
public long CreatedAtUnix { get; init; }
public static PaymentRecord From(Payment payment)
{
return new PaymentRecord
{
Amount = payment.Amount,
Name = payment.Name,
Message = payment.Message,
CreatedAtUnix = payment.CreatedAtUnix,
};
}
}
}
@@ -0,0 +1,3 @@
namespace GroundsForSupport.Server.Payments;
internal sealed record Intent(string ClientSecret);
@@ -0,0 +1,10 @@
namespace GroundsForSupport.Server.Payments;
internal sealed class Payment
{
public required string Id { get; init; }
public required string Name { get; init; }
public required long Amount { get; init; }
public string? Message { get; init; }
public long CreatedAtUnix { get; init; }
}
@@ -0,0 +1,11 @@
namespace GroundsForSupport.Server.Payments.Stripe;
internal interface IStripeService
{
Task<(bool IsSuccess, Intent Intent)> CreatePaymentIntentAsync(
string name,
decimal amount,
string? message,
string? email
);
}
@@ -0,0 +1,25 @@
using Microsoft.Extensions.Options;
namespace GroundsForSupport.Server.Payments.Stripe;
internal sealed record StripeOptions
{
public string ApiKey { get; init; } = string.Empty;
public string EventsWebhookSecret { get; init; } = string.Empty;
}
internal sealed record StripeOptionsSetup : IConfigureOptions<StripeOptions>
{
private const string SectionName = nameof(StripeOptions);
private readonly IConfiguration _configuration;
public StripeOptionsSetup(IConfiguration configuration)
{
_configuration = configuration;
}
public void Configure(StripeOptions options)
{
_configuration.GetSection(SectionName).Bind(options);
}
}
@@ -0,0 +1,50 @@
using GroundsForSupport.Server.Data;
using Microsoft.Extensions.Options;
using Stripe;
namespace GroundsForSupport.Server.Payments.Stripe;
internal sealed class StripeService(
IOptions<StripeOptions> options,
HttpClient httpClient
) : IStripeService
{
private readonly StripeClient _client = new(options.Value.ApiKey, httpClient: new SystemNetHttpClient(httpClient));
public async Task<(bool IsSuccess, Intent Intent)> CreatePaymentIntentAsync(string name, decimal amount, string? message, string? email)
{
try
{
var createOptions = new PaymentIntentCreateOptions
{
Amount = (long)(amount * 100),
Currency = "usd",
AutomaticPaymentMethods = new PaymentIntentAutomaticPaymentMethodsOptions
{
Enabled = true,
},
Metadata = new Dictionary<string, string>
{
{ nameof(Payment.Name), name },
{ nameof(Payment.Message), message ?? string.Empty },
},
};
if (string.IsNullOrWhiteSpace(email) is false)
{
createOptions.ReceiptEmail = email;
}
var intent = await _client.V1.PaymentIntents.CreateAsync(createOptions);
return (true, new Intent(intent.ClientSecret));
}
catch (Exception)
{
// TODO: Log exception
return (false, new Intent(string.Empty));
}
}
}
+27 -110
View File
@@ -1,9 +1,11 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using Microsoft.Extensions.Options;
using GroundsForSupport.Server.Data;
using GroundsForSupport.Server.Payments.Endpoints;
using GroundsForSupport.Server.Payments.Stripe;
using GroundsForSupport.Server.RateLimiting;
using Stripe;
using Microsoft.AspNetCore.HttpOverrides;
var builder = WebApplication.CreateBuilder(args);
@@ -15,6 +17,18 @@ builder.Services.AddProblemDetails();
builder.Services.AddHttpClient();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.Configure<ForwardedHeadersOptions>(
static options => options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
);
builder.Services.AddRateLimingPolicies();
builder.Services.ConfigureOptions<ContextOptionsSetup>();
builder.Services.AddDbContext<Context>();
builder.Services.AddHostedService<MigrationService>();
builder.Services.ConfigureOptions<StripeOptionsSetup>();
builder.Services.AddSingleton<IStripeService, StripeService>();
@@ -29,119 +43,22 @@ if (app.Environment.IsDevelopment())
app.MapOpenApi();
}
if (app.Environment.IsProduction())
{
app.UseForwardedHeaders();
app.UseRateLimiter();
}
app.UseDefaultFiles();
app.UseStaticFiles();
app.UseStatusCodePages();
app.UseHttpsRedirection();
app.UseHsts();
app.MapPost("/create-payment-intent", CreatePaymentIntentHandler);
app.MapCreatePaymentIntentEndpoint();
app.MapGetPaymentsEndpoint();
app.MapEventsEndpoint();
app.Run();
static async Task<IResult> CreatePaymentIntentHandler(
CreatePaymentIntentRequest request,
IStripeService stripeService
)
{
var validationErrors = request.Validate(new ValidationContext(request));
if (validationErrors.Any())
{
return Results.ValidationProblem(validationErrors
.GroupBy(static e => e.MemberNames.FirstOrDefault() ?? string.Empty)
.ToDictionary(static g => g.Key, static g => g.Select(static e => e.ErrorMessage ?? string.Empty).ToArray()));
}
var (isSuccess, intent) = await stripeService.CreatePaymentIntentAsync(request.Amount, request.Email);
if (isSuccess is false)
{
return Results.InternalServerError();
}
return Results.Ok(intent);
}
internal sealed record CreatePaymentIntentRequest(decimal Amount, string? Email)
{
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (Amount <= 0)
{
yield return new ValidationResult("Amount must be greater than zero", [nameof(Amount)]);
}
if (string.IsNullOrWhiteSpace(Email) is false && new EmailAddressAttribute().IsValid(Email) is false)
{
yield return new ValidationResult("Email is not valid", [nameof(Email)]);
}
}
}
internal sealed record StripeOptions
{
public string ApiKey { get; init; } = string.Empty;
}
internal sealed record StripeOptionsSetup : IConfigureOptions<StripeOptions>
{
private const string SectionName = nameof(StripeOptions);
private readonly IConfiguration _configuration;
public StripeOptionsSetup(IConfiguration configuration)
{
_configuration = configuration;
}
public void Configure(StripeOptions options)
{
_configuration.GetSection(SectionName).Bind(options);
}
}
internal sealed record Intent(string ClientSecret);
internal interface IStripeService
{
Task<(bool IsSuccess, Intent Intent)> CreatePaymentIntentAsync(decimal amount, string? email);
}
internal sealed class StripeService(
IOptions<StripeOptions> options,
HttpClient httpClient
) : IStripeService
{
private readonly StripeClient _client = new(options.Value.ApiKey, httpClient: new SystemNetHttpClient(httpClient));
public async Task<(bool IsSuccess, Intent Intent)> CreatePaymentIntentAsync(decimal amount, string? email)
{
try
{
var createOptions = new PaymentIntentCreateOptions
{
Amount = (long)(amount * 100),
Currency = "usd",
AutomaticPaymentMethods = new PaymentIntentAutomaticPaymentMethodsOptions
{
Enabled = true,
},
};
if (string.IsNullOrWhiteSpace(email) is false)
{
createOptions.ReceiptEmail = email;
}
var intent = await _client.V1.PaymentIntents.CreateAsync(createOptions);
return (true, new Intent(intent.ClientSecret));
}
catch (Exception)
{
// TODO: Log exception
return (false, new Intent(string.Empty));
}
}
}