From 2df6bff9a7428f585e7e494f0e2059cb7299842a Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sat, 27 Dec 2025 08:38:31 -0600 Subject: [PATCH] feat: use stripe sdk to create intent --- .editorconfig | 5 + src/GroundsForSupport.Server/Program.cs | 149 +++++++++++++++--- .../appsettings.Example.json | 5 + tests/.editorconfig | 2 + .../Integration/PaymentTests.cs | 36 ++++- 5 files changed, 178 insertions(+), 19 deletions(-) create mode 100644 src/GroundsForSupport.Server/appsettings.Example.json create mode 100644 tests/.editorconfig diff --git a/.editorconfig b/.editorconfig index 937b121..bc9a7ae 100644 --- a/.editorconfig +++ b/.editorconfig @@ -23,6 +23,11 @@ insert_final_newline = false #### .NET Coding Conventions #### [*.{cs,vb}] +# Analyzer severity levels +dotnet_diagnostic.IDE0058.severity = none +dotnet_diagnostic.IDE0100.severity = none +dotnet_diagnostic.IDE3000.severity = none + # Organize usings dotnet_separate_import_directive_groups = true dotnet_sort_system_directives_first = true diff --git a/src/GroundsForSupport.Server/Program.cs b/src/GroundsForSupport.Server/Program.cs index 45b5c9e..5daa99e 100644 --- a/src/GroundsForSupport.Server/Program.cs +++ b/src/GroundsForSupport.Server/Program.cs @@ -1,7 +1,27 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; + +using Microsoft.Extensions.Options; + +using Stripe; + var builder = WebApplication.CreateBuilder(args); builder.Services.AddOpenApi(); +builder.Services.AddValidation(); + +builder.Services.AddProblemDetails(); + +builder.Services.AddHttpClient(); + +builder.Services.ConfigureOptions(); +builder.Services.AddSingleton(); + +builder.Services.ConfigureHttpJsonOptions( + static options => options.SerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.CamelCase +); + var app = builder.Build(); if (app.Environment.IsDevelopment()) @@ -9,26 +29,119 @@ if (app.Environment.IsDevelopment()) app.MapOpenApi(); } -// We need a /create-payment-intent endpoint - -app.MapPost("/create-payment-intent", static () => -{ - // we need to get the amount and email - // from the frontend - - // we need to validate the amount and email - - // we are going to use the Stripe API to create - // a payment intent - - // return the secret for that intent - // to the frontend - return Results.Ok("Hello"); -}); - app.UseDefaultFiles(); app.UseStaticFiles(); +app.UseStatusCodePages(); + app.UseHttpsRedirection(); -app.Run(); \ No newline at end of file +app.MapPost("/create-payment-intent", CreatePaymentIntentHandler); + +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 diff --git a/src/GroundsForSupport.Server/appsettings.Example.json b/src/GroundsForSupport.Server/appsettings.Example.json new file mode 100644 index 0000000..743e09a --- /dev/null +++ b/src/GroundsForSupport.Server/appsettings.Example.json @@ -0,0 +1,5 @@ +{ + "StripeOptions": { + "ApiKey": "ApiKey" + } +} diff --git a/tests/.editorconfig b/tests/.editorconfig new file mode 100644 index 0000000..79bfd7f --- /dev/null +++ b/tests/.editorconfig @@ -0,0 +1,2 @@ +[*.cs] +dotnet_diagnostic.CA1707.severity = none diff --git a/tests/GroundsForSupport.Server.Tests/Integration/PaymentTests.cs b/tests/GroundsForSupport.Server.Tests/Integration/PaymentTests.cs index 5651db7..86b1d85 100644 --- a/tests/GroundsForSupport.Server.Tests/Integration/PaymentTests.cs +++ b/tests/GroundsForSupport.Server.Tests/Integration/PaymentTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Http.Json; + using GroundsForSupport.API.Tests.Integration.Infra; namespace GroundsForSupport.API.Tests.Integration; @@ -13,7 +14,8 @@ public sealed class PaymentTests(TestApi api) : IClassFixture { var client = _api.CreateClient(); - var request = new { + var request = new + { amount = 0, email = string.Empty, }; @@ -22,4 +24,36 @@ public sealed class PaymentTests(TestApi api) : IClassFixture response.StatusCode.Should().Be(HttpStatusCode.BadRequest); } + + [Fact] + public async Task CreatePaymentIntent_WhenGivenInvalidEmail_ItShouldReturnBadRequest() + { + var client = _api.CreateClient(); + + var request = new + { + amount = 5000, + email = "invalid-email", + }; + + var response = await client.PostAsJsonAsync("/create-payment-intent", request, TestContext.Current.CancellationToken); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task CreatePaymentIntent_WhenGivenValidRequest_ItShouldReturnCreated() + { + var client = _api.CreateClient(); + + var request = new + { + amount = 5000, + email = "test@test.com", + }; + + var response = await client.PostAsJsonAsync("/create-payment-intent", request, TestContext.Current.CancellationToken); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } } \ No newline at end of file