diff --git a/src/GroundsForSupport.Server/Payments/Endpoints/CreatePaymentIntentEndpoint.cs b/src/GroundsForSupport.Server/Payments/Endpoints/CreatePaymentIntentEndpoint.cs new file mode 100644 index 0000000..0ac607b --- /dev/null +++ b/src/GroundsForSupport.Server/Payments/Endpoints/CreatePaymentIntentEndpoint.cs @@ -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 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 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); +} \ No newline at end of file diff --git a/src/GroundsForSupport.Server/Payments/Endpoints/EventsEndpoint.cs b/src/GroundsForSupport.Server/Payments/Endpoints/EventsEndpoint.cs new file mode 100644 index 0000000..50945f1 --- /dev/null +++ b/src/GroundsForSupport.Server/Payments/Endpoints/EventsEndpoint.cs @@ -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 EventsHandler( + HttpContext httpContext, + [FromServices] IOptions 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 }); + } + } +} \ No newline at end of file diff --git a/src/GroundsForSupport.Server/Payments/Endpoints/GetPaymentsEndpoint.cs b/src/GroundsForSupport.Server/Payments/Endpoints/GetPaymentsEndpoint.cs new file mode 100644 index 0000000..413108f --- /dev/null +++ b/src/GroundsForSupport.Server/Payments/Endpoints/GetPaymentsEndpoint.cs @@ -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 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 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 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, + }; + } + } +} \ No newline at end of file diff --git a/src/GroundsForSupport.Server/Payments/Intent.cs b/src/GroundsForSupport.Server/Payments/Intent.cs new file mode 100644 index 0000000..62aa1d2 --- /dev/null +++ b/src/GroundsForSupport.Server/Payments/Intent.cs @@ -0,0 +1,3 @@ +namespace GroundsForSupport.Server.Payments; + +internal sealed record Intent(string ClientSecret); \ No newline at end of file diff --git a/src/GroundsForSupport.Server/Payments/Payment.cs b/src/GroundsForSupport.Server/Payments/Payment.cs new file mode 100644 index 0000000..7652f2e --- /dev/null +++ b/src/GroundsForSupport.Server/Payments/Payment.cs @@ -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; } +} \ No newline at end of file diff --git a/src/GroundsForSupport.Server/Payments/Stripe/IStripeService.cs b/src/GroundsForSupport.Server/Payments/Stripe/IStripeService.cs new file mode 100644 index 0000000..3481c94 --- /dev/null +++ b/src/GroundsForSupport.Server/Payments/Stripe/IStripeService.cs @@ -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 + ); +} \ No newline at end of file diff --git a/src/GroundsForSupport.Server/Payments/Stripe/StripeOptions.cs b/src/GroundsForSupport.Server/Payments/Stripe/StripeOptions.cs new file mode 100644 index 0000000..73f0fcc --- /dev/null +++ b/src/GroundsForSupport.Server/Payments/Stripe/StripeOptions.cs @@ -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 +{ + 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); + } +} \ No newline at end of file diff --git a/src/GroundsForSupport.Server/Payments/Stripe/StripeService.cs b/src/GroundsForSupport.Server/Payments/Stripe/StripeService.cs new file mode 100644 index 0000000..d0a401a --- /dev/null +++ b/src/GroundsForSupport.Server/Payments/Stripe/StripeService.cs @@ -0,0 +1,50 @@ +using GroundsForSupport.Server.Data; + +using Microsoft.Extensions.Options; + +using Stripe; + +namespace GroundsForSupport.Server.Payments.Stripe; + +internal sealed class StripeService( + IOptions 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 + { + { 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)); + } + } +} \ No newline at end of file diff --git a/src/GroundsForSupport.Server/Program.cs b/src/GroundsForSupport.Server/Program.cs index 5daa99e..d76b978 100644 --- a/src/GroundsForSupport.Server/Program.cs +++ b/src/GroundsForSupport.Server/Program.cs @@ -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( + static options => options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto +); + +builder.Services.AddRateLimingPolicies(); + +builder.Services.ConfigureOptions(); +builder.Services.AddDbContext(); +builder.Services.AddHostedService(); + builder.Services.ConfigureOptions(); builder.Services.AddSingleton(); @@ -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 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 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 -{ - 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 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)); - } - } -} \ No newline at end of file +app.Run(); \ No newline at end of file