From 18532963a87641fab6436ce25dcfd8d1e670a6b6 Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Mon, 4 Aug 2025 16:31:54 -0400 Subject: [PATCH] fix(stripe): correcting webhook --- .../Handlers/StripeWebhookEndpoint.cs | 80 +++---- backend/Modules/Tipping/Handlers/SendTip.cs | 206 +++++++++--------- 2 files changed, 149 insertions(+), 137 deletions(-) diff --git a/backend/Modules/Memberships/Handlers/StripeWebhookEndpoint.cs b/backend/Modules/Memberships/Handlers/StripeWebhookEndpoint.cs index 533bebc..e1e268a 100644 --- a/backend/Modules/Memberships/Handlers/StripeWebhookEndpoint.cs +++ b/backend/Modules/Memberships/Handlers/StripeWebhookEndpoint.cs @@ -8,11 +8,10 @@ using Stripe.Checkout; namespace Hutopy.Modules.Memberships.Handlers; -public class StripeWebhookEndpoint( +internal class StripeWebhookEndpoint( ITipPaymentNotifier tipPaymentNotifier, IMembershipNotifier membershipNotifier, - IOptions options, - ILogger logger) + IOptions options) : EndpointWithoutRequest { public override void Configure() @@ -24,10 +23,11 @@ public class StripeWebhookEndpoint( public override async Task HandleAsync(CancellationToken ct) { - using StreamReader streamReader = new(HttpContext.Request.Body); - var json = await streamReader.ReadToEndAsync(ct); - var signatureHeader = HttpContext.Request.Headers["Stripe-Signature"]; + using StreamReader streamReader = new(HttpContext.Request.Body); + + var json = await streamReader.ReadToEndAsync(ct).ConfigureAwait(false); + var stripeEvent = EventUtility.ConstructEvent(json, signatureHeader, options.Value.WebhookSecret); var stripeSession = stripeEvent.Data.Object as Session; @@ -37,16 +37,10 @@ public class StripeWebhookEndpoint( { case "checkout.session.completed": Debug.Assert(stripeSession != null); - logger.LogWarning(stripeSession.ToJson()); - logger.LogWarning("stripeSession.PaymentIntentId: {PaymentIntentId}", stripeSession.PaymentIntentId); - logger.LogWarning("stripeSession.PaymentIntent.Status: {PaymentIntentStatus}", stripeSession.PaymentIntent.Status); - logger.LogWarning(stripeSession.ToJson()); - logger.LogWarning(stripeSession.ToJson()); switch (stripeSession.Mode) { // Check if this is a one-time tip - case "payment" when stripeSession.PaymentIntentId != null - && stripeSession.PaymentIntent.Status == "paid": + case "payment" when stripeSession is { PaymentIntentId: not null, PaymentStatus: "paid" }: // Get the customer email from the appropriate place var customerEmail = stripeSession.CustomerDetails?.Email ?? stripeSession.Customer?.Email ?? @@ -55,21 +49,25 @@ public class StripeWebhookEndpoint( // Get the receipt URL, preferring the one directly on the charge if available var receiptUrl = stripeSession.Invoice?.HostedInvoiceUrl ?? ""; - await tipPaymentNotifier.NotifyPaymentSucceedAsync( - stripeSession.Id, - receiptUrl, - customerEmail, - ct); + await tipPaymentNotifier + .NotifyPaymentSucceedAsync( + stripeSession.Id, + receiptUrl, + customerEmail, + ct) + .ConfigureAwait(false); break; // Check if this is a subscription case "subscription" when stripeSession.SubscriptionId != null: - await membershipNotifier.NotifyPaymentSucceedAsync( - stripeSession.SubscriptionId, - stripeSession.Invoice.HostedInvoiceUrl, - stripeSession.Invoice.Total, - stripeSession.Invoice.Currency, - ct); + await membershipNotifier + .NotifyPaymentSucceedAsync( + stripeSession.SubscriptionId, + stripeSession.Invoice.HostedInvoiceUrl, + stripeSession.Invoice.Total, + stripeSession.Invoice.Currency, + ct) + .ConfigureAwait(false); break; } @@ -78,29 +76,35 @@ public class StripeWebhookEndpoint( var invoice = stripeEvent.Data.Object as Invoice; Debug.Assert(invoice != null); Debug.Assert(invoice.Subscription != null); - await membershipNotifier.NotifyPaymentSucceedAsync( - invoice.SubscriptionId, - invoice.HostedInvoiceUrl, - invoice.Total, - invoice.Currency, - ct); + await membershipNotifier + .NotifyPaymentSucceedAsync( + invoice.SubscriptionId, + invoice.HostedInvoiceUrl, + invoice.Total, + invoice.Currency, + ct) + .ConfigureAwait(false); break; case "customer.subscription.updated": Debug.Assert(stripeSubscription != null); - await membershipNotifier.NotifySubscriptionUpdatedAsync( - stripeSubscription.Id, - stripeSubscription.CancelAt ?? stripeSubscription.CanceledAt, - ct); + await membershipNotifier + .NotifySubscriptionUpdatedAsync( + stripeSubscription.Id, + stripeSubscription.CancelAt ?? stripeSubscription.CanceledAt, + ct) + .ConfigureAwait(false); break; case "customer.subscription.deleted": Debug.Assert(stripeSubscription != null); - await membershipNotifier.NotifySubscriptionDeletedAsync( - stripeSubscription.Id, - ct); + await membershipNotifier + .NotifySubscriptionDeletedAsync( + stripeSubscription.Id, + ct) + .ConfigureAwait(false); break; } - await SendOkAsync(ct); + await SendOkAsync(ct).ConfigureAwait(false); } } diff --git a/backend/Modules/Tipping/Handlers/SendTip.cs b/backend/Modules/Tipping/Handlers/SendTip.cs index 7f18c72..219b6d3 100644 --- a/backend/Modules/Tipping/Handlers/SendTip.cs +++ b/backend/Modules/Tipping/Handlers/SendTip.cs @@ -6,111 +6,119 @@ using Hutopy.Modules.Tipping.Data; namespace Hutopy.Modules.Tipping.Handlers; [PublicAPI] -public record SendTipRequest( - Guid CreatorId, - decimal Amount, - string Currency, - string Message, - string CheckoutSuccessUrl, - string CheckoutCancelledUrl); - -[PublicAPI] -public record SendTipResponse( - string Id, - string Url); - -[PublicAPI] -public class SendTipRequestValidator : Validator +internal static class SendTip { - public SendTipRequestValidator() + internal record Request( + Guid CreatorId, + decimal Amount, + string Currency, + string Message, + string CheckoutSuccessUrl, + string CheckoutCancelledUrl); + + internal record Response( + string Id, + string Url); + + internal class Validator : Validator { - RuleFor(x => x.Amount) - .GreaterThan(0) - .WithMessage("Tip amount must be greater than 0"); - - RuleFor(x => x.CreatorId) - .NotEmpty() - .WithMessage("Creator ID is required"); - - RuleFor(x => x.CheckoutSuccessUrl) - .NotEmpty() - .WithMessage("CheckoutSuccessUrl is required"); - - RuleFor(x => x.CheckoutCancelledUrl) - .NotEmpty() - .WithMessage("CheckoutCancelledUrl is required"); - } -} - -[PublicAPI] -public class SendTipHandler( - TippingDbContext dbContext, - ITipProcessor tipProcessor, - ICreatorLookup creatorLookup) - : Endpoint -{ - private static readonly Guid AnonymousUserId = Guid.Parse("AAAAAAAA-0000-0000-0000-000000000000"); - - public override void Configure() - { - Post("/api/tips"); - Options(o => o.WithTags("Memberships")); - - AllowAnonymous(); - } - - public override async Task HandleAsync( - SendTipRequest req, - CancellationToken ct) - { - CreatorReference? creator = await creatorLookup.GetCreatorAsync(req.CreatorId, ct); - if (creator == null) + public Validator() { - await SendNotFoundAsync(ct); - return; + RuleFor(x => x.Amount) + .GreaterThan(0) + .WithMessage("Tip amount must be greater than 0"); + + RuleFor(x => x.CreatorId) + .NotEmpty() + .WithMessage("Creator ID is required"); + + RuleFor(x => x.CheckoutSuccessUrl) + .NotEmpty() + .WithMessage("CheckoutSuccessUrl is required"); + + RuleFor(x => x.CheckoutCancelledUrl) + .NotEmpty() + .WithMessage("CheckoutCancelledUrl is required"); + } + } + + internal class Handler( + TippingDbContext dbContext, + ITipProcessor tipProcessor, + ICreatorLookup creatorLookup) + : Endpoint + { + private static readonly Guid AnonymousUserId = Guid.Parse("AAAAAAAA-0000-0000-0000-000000000000"); + + public override void Configure() + { + Post("/api/tips"); + Options(o => o.WithTags("Memberships")); + + AllowAnonymous(); } - if (!creator.AcceptCharges) + public override async Task HandleAsync( + Request req, + CancellationToken ct) { - await SendErrorsAsync(StatusCodes.Status400BadRequest, ct); - return; + ArgumentNullException.ThrowIfNull(req); + + var userId = User.Identity?.IsAuthenticated == true + ? User.GetUserId() + : AnonymousUserId; + + var creator = await creatorLookup + .GetCreatorAsync(req.CreatorId, ct) + .ConfigureAwait(false); + + if (creator == null) + { + await SendNotFoundAsync(ct).ConfigureAwait(false); + return; + } + + if (!creator.AcceptCharges) + { + await SendErrorsAsync(StatusCodes.Status400BadRequest, ct).ConfigureAwait(false); + return; + } + + var tipId = Guid.CreateVersion7(); + + var checkout = await tipProcessor + .CreateCheckoutSessionAsync( + tipId, + creator, + req.Amount, + req.Currency, + req.Message, + req.CheckoutSuccessUrl, + req.CheckoutCancelledUrl, + ct) + .ConfigureAwait(false); + + Tip tip = new() + { + Id = tipId, + CreatedAt = DateTimeOffset.UtcNow, + StripeSessionId = checkout.Id, + CreatedBy = userId, + CreatorId = req.CreatorId, + Status = TipStatus.Pending, + Amount = req.Amount, + Currency = req.Currency, + Message = req.Message + }; + + dbContext.Tips.Add(tip); + + await dbContext.SaveChangesAsync(ct).ConfigureAwait(false); + + await SendAsync( + new Response(checkout.Id, checkout.Url), + cancellation: ct) + .ConfigureAwait(false); } - - Guid tipId = Guid.CreateVersion7(); - - TipCheckoutSession checkout = await tipProcessor.CreateCheckoutSessionAsync( - tipId, - creator, - req.Amount, - req.Currency, - req.Message, - req.CheckoutSuccessUrl, - req.CheckoutCancelledUrl, - ct); - - Guid userId = User.Identity?.IsAuthenticated == true - ? User.GetUserId() - : AnonymousUserId; - - Tip tip = new() - { - Id = tipId, - CreatedAt = DateTimeOffset.UtcNow, - StripeSessionId = checkout.Id, - CreatedBy = userId, - CreatorId = req.CreatorId, - Status = TipStatus.Pending, - Amount = req.Amount, - Currency = req.Currency, - Message = req.Message - }; - - dbContext.Tips.Add(tip); - - await dbContext.SaveChangesAsync(ct); - - await SendAsync( - new SendTipResponse(checkout.Id, checkout.Url), - cancellation: ct); } }