feat(client): add payment components
This commit is contained in:
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.Text.Json;
|
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);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@@ -15,6 +17,18 @@ builder.Services.AddProblemDetails();
|
|||||||
|
|
||||||
builder.Services.AddHttpClient();
|
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.ConfigureOptions<StripeOptionsSetup>();
|
||||||
builder.Services.AddSingleton<IStripeService, StripeService>();
|
builder.Services.AddSingleton<IStripeService, StripeService>();
|
||||||
|
|
||||||
@@ -29,119 +43,22 @@ if (app.Environment.IsDevelopment())
|
|||||||
app.MapOpenApi();
|
app.MapOpenApi();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (app.Environment.IsProduction())
|
||||||
|
{
|
||||||
|
app.UseForwardedHeaders();
|
||||||
|
app.UseRateLimiter();
|
||||||
|
}
|
||||||
|
|
||||||
app.UseDefaultFiles();
|
app.UseDefaultFiles();
|
||||||
app.UseStaticFiles();
|
app.UseStaticFiles();
|
||||||
|
|
||||||
app.UseStatusCodePages();
|
app.UseStatusCodePages();
|
||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
app.UseHsts();
|
||||||
|
|
||||||
app.MapPost("/create-payment-intent", CreatePaymentIntentHandler);
|
app.MapCreatePaymentIntentEndpoint();
|
||||||
|
app.MapGetPaymentsEndpoint();
|
||||||
|
app.MapEventsEndpoint();
|
||||||
|
|
||||||
app.Run();
|
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user