From ea8efd21a152916df3cece8f95292315b0d9c020 Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Mon, 4 Aug 2025 17:15:13 -0400 Subject: [PATCH] fix(stripe): correcting webhook --- .../Stripe/Services/StripeTipProcessor.cs | 88 ++++++------- .../Handlers/StripeWebhookEndpoint.cs | 19 ++- .../Tipping/Contracts/ITipPaymentNotifier.cs | 6 +- .../Tipping/Contracts/ITipProcessor.cs | 6 +- backend/Modules/Tipping/Handlers/SendTip.cs | 8 +- .../Tipping/Services/TipPaymentNotifier.cs | 120 ++++++++++-------- 6 files changed, 134 insertions(+), 113 deletions(-) diff --git a/backend/Infrastructure/Payments/Stripe/Services/StripeTipProcessor.cs b/backend/Infrastructure/Payments/Stripe/Services/StripeTipProcessor.cs index 389d8f8..735ea26 100644 --- a/backend/Infrastructure/Payments/Stripe/Services/StripeTipProcessor.cs +++ b/backend/Infrastructure/Payments/Stripe/Services/StripeTipProcessor.cs @@ -7,7 +7,7 @@ using Stripe.Checkout; namespace Hutopy.Infrastructure.Payments.Stripe.Services; -public class StripeTipProcessor( +internal class StripeTipProcessor( IOptions stripeOptions) : ITipProcessor { @@ -17,61 +17,63 @@ public class StripeTipProcessor( decimal amount, string currency, string message, - string successUrl, - string cancelUrl, + Uri successUrl, + Uri cancelUrl, CancellationToken ct = default) { StripeConfiguration.ApiKey = stripeOptions.Value.SecretKey; // Create Stripe customer for the user if not already created CustomerService customerService = new(); - Customer? customer = await customerService.CreateAsync( - new CustomerCreateOptions(), - cancellationToken: ct); + var customer = await customerService + .CreateAsync( + new CustomerCreateOptions(), + cancellationToken: ct) + .ConfigureAwait(false); // Create paymentIntent for the user SessionService sessionService = new(); - Session? session = await sessionService.CreateAsync( - new SessionCreateOptions - { - ClientReferenceId = tipId.ToString(), - Customer = customer.Id, - PaymentMethodTypes = ["card"], - LineItems = - [ - new SessionLineItemOptions - { - PriceData = new SessionLineItemPriceDataOptions - { - Currency = currency, - UnitAmountDecimal = amount, // Amount in cents - ProductData = new SessionLineItemPriceDataProductDataOptions - { - Name = $"Tip for {creator.Name}", // or any descriptive name for the tip - Metadata = new Dictionary { { "creatorId", creator.Id.ToString() } } - } - }, - Quantity = 1 - } - ], - Mode = "payment", - PaymentIntentData = new SessionPaymentIntentDataOptions + var session = await sessionService.CreateAsync( + new SessionCreateOptions { - ApplicationFeeAmount = - Convert.ToInt64(amount * stripeOptions.Value.HutopyRate), // Platform fee - TransferData = new SessionPaymentIntentDataTransferDataOptions + ClientReferenceId = tipId.ToString(), + Customer = customer.Id, + PaymentMethodTypes = ["card"], + LineItems = + [ + new SessionLineItemOptions + { + PriceData = new SessionLineItemPriceDataOptions + { + Currency = currency, + UnitAmountDecimal = amount, // Amount in cents + ProductData = new SessionLineItemPriceDataProductDataOptions + { + Name = $"Tip for {creator.Name}", // or any descriptive name for the tip + Metadata = new Dictionary { { "creatorId", creator.Id.ToString() } } + } + }, + Quantity = 1 + } + ], + Mode = "payment", + PaymentIntentData = new SessionPaymentIntentDataOptions { - Destination = creator.StripeAccountId // Creator's Stripe account ID + ApplicationFeeAmount = Convert.ToInt64(amount * stripeOptions.Value.HutopyRate), // Platform fee + TransferData = new SessionPaymentIntentDataTransferDataOptions + { + Destination = creator.StripeAccountId // Creator's Stripe account ID + } + }, + SuccessUrl = successUrl.ToString(), // Redirect after successful payment + CancelUrl = cancelUrl.ToString(), // Redirect after canceled payment + Metadata = new Dictionary + { + { "creatorId", creator.Id.ToString() }, { "creatorName", creator.Name }, { "message", message } } }, - SuccessUrl = successUrl, // Redirect after successful payment - CancelUrl = cancelUrl, // Redirect after canceled payment - Metadata = new Dictionary - { - { "creatorId", creator.Id.ToString() }, { "creatorName", creator.Name }, { "message", message } - } - }, - cancellationToken: ct); + cancellationToken: ct) + .ConfigureAwait(false); return new TipCheckoutSession(session.Id, session.Url); } diff --git a/backend/Modules/Memberships/Handlers/StripeWebhookEndpoint.cs b/backend/Modules/Memberships/Handlers/StripeWebhookEndpoint.cs index e1e268a..969fce5 100644 --- a/backend/Modules/Memberships/Handlers/StripeWebhookEndpoint.cs +++ b/backend/Modules/Memberships/Handlers/StripeWebhookEndpoint.cs @@ -11,7 +11,7 @@ namespace Hutopy.Modules.Memberships.Handlers; internal class StripeWebhookEndpoint( ITipPaymentNotifier tipPaymentNotifier, IMembershipNotifier membershipNotifier, - IOptions options) + IOptions stripeOptions) : EndpointWithoutRequest { public override void Configure() @@ -28,7 +28,7 @@ internal class StripeWebhookEndpoint( var json = await streamReader.ReadToEndAsync(ct).ConfigureAwait(false); - var stripeEvent = EventUtility.ConstructEvent(json, signatureHeader, options.Value.WebhookSecret); + var stripeEvent = EventUtility.ConstructEvent(json, signatureHeader, stripeOptions.Value.WebhookSecret); var stripeSession = stripeEvent.Data.Object as Session; var stripeSubscription = stripeEvent.Data.Object as Subscription; @@ -46,13 +46,22 @@ internal class StripeWebhookEndpoint( stripeSession.Customer?.Email ?? ""; - // Get the receipt URL, preferring the one directly on the charge if available - var receiptUrl = stripeSession.Invoice?.HostedInvoiceUrl ?? ""; + StripeConfiguration.ApiKey = stripeOptions.Value.SecretKey; + var paymentIntentService = new PaymentIntentService(); + var paymentIntent = await paymentIntentService + .GetAsync( + stripeSession.PaymentIntentId, + new PaymentIntentGetOptions { Expand = ["latest_charge"] }, + cancellationToken: ct) + .ConfigureAwait(false); + var receiptUrl = paymentIntent.LatestCharge.ReceiptUrl; + var receiptUri = new Uri(receiptUrl); + // Get the receipt URL, preferring the one directly on the charge if available await tipPaymentNotifier .NotifyPaymentSucceedAsync( stripeSession.Id, - receiptUrl, + receiptUri, customerEmail, ct) .ConfigureAwait(false); diff --git a/backend/Modules/Tipping/Contracts/ITipPaymentNotifier.cs b/backend/Modules/Tipping/Contracts/ITipPaymentNotifier.cs index 79f1d53..85848ce 100644 --- a/backend/Modules/Tipping/Contracts/ITipPaymentNotifier.cs +++ b/backend/Modules/Tipping/Contracts/ITipPaymentNotifier.cs @@ -1,10 +1,10 @@ namespace Hutopy.Modules.Tipping.Contracts; -public interface ITipPaymentNotifier +internal interface ITipPaymentNotifier { Task NotifyPaymentSucceedAsync( - string stripeId, - string invoiceUrl, + string sessionId, + Uri receiptUrl, string customerEmail, CancellationToken ct); } diff --git a/backend/Modules/Tipping/Contracts/ITipProcessor.cs b/backend/Modules/Tipping/Contracts/ITipProcessor.cs index dd81ec4..a6a563e 100644 --- a/backend/Modules/Tipping/Contracts/ITipProcessor.cs +++ b/backend/Modules/Tipping/Contracts/ITipProcessor.cs @@ -2,7 +2,7 @@ using Hutopy.Modules.Creators.Contracts; namespace Hutopy.Modules.Tipping.Contracts; -public interface ITipProcessor +internal interface ITipProcessor { Task CreateCheckoutSessionAsync( Guid tipId, @@ -10,7 +10,7 @@ public interface ITipProcessor decimal amount, string currency, string message, - string successUrl, - string cancelUrl, + Uri successUrl, + Uri cancelUrl, CancellationToken ct = default); } diff --git a/backend/Modules/Tipping/Handlers/SendTip.cs b/backend/Modules/Tipping/Handlers/SendTip.cs index 219b6d3..280dd84 100644 --- a/backend/Modules/Tipping/Handlers/SendTip.cs +++ b/backend/Modules/Tipping/Handlers/SendTip.cs @@ -13,12 +13,12 @@ internal static class SendTip decimal Amount, string Currency, string Message, - string CheckoutSuccessUrl, - string CheckoutCancelledUrl); + Uri CheckoutSuccessUrl, + Uri CheckoutCancelledUrl); internal record Response( string Id, - string Url); + Uri Url); internal class Validator : Validator { @@ -116,7 +116,7 @@ internal static class SendTip await dbContext.SaveChangesAsync(ct).ConfigureAwait(false); await SendAsync( - new Response(checkout.Id, checkout.Url), + new Response(checkout.Id, new Uri(checkout.Url)), cancellation: ct) .ConfigureAwait(false); } diff --git a/backend/Modules/Tipping/Services/TipPaymentNotifier.cs b/backend/Modules/Tipping/Services/TipPaymentNotifier.cs index 38ee0bd..9ad18c9 100644 --- a/backend/Modules/Tipping/Services/TipPaymentNotifier.cs +++ b/backend/Modules/Tipping/Services/TipPaymentNotifier.cs @@ -5,7 +5,7 @@ using Hutopy.Modules.Tipping.Data; namespace Hutopy.Modules.Tipping.Services; -public class TipPaymentNotifier( +internal class TipPaymentNotifier( TippingDbContext dbContext, IEmailSender emailSender, ICreatorLookup creatorLookup, @@ -14,31 +14,40 @@ public class TipPaymentNotifier( { public async Task NotifyPaymentSucceedAsync( string sessionId, - string receiptUrl, + Uri receiptUrl, string customerEmail, CancellationToken ct) { - Tip? tip = await dbContext.Tips.SingleOrDefaultAsync( - t => t.StripeSessionId == sessionId, - ct); + var tip = await dbContext + .Tips + .SingleOrDefaultAsync( + t => t.StripeSessionId == sessionId, + ct) + .ConfigureAwait(false); if (tip is not null) { tip.Status = TipStatus.Paid; - tip.StripeInvoiceUrl = receiptUrl; // Store the receipt URL - await dbContext.SaveChangesAsync(ct); + tip.StripeInvoiceUrl = receiptUrl.ToString(); // Store the receipt URL + + await dbContext + .SaveChangesAsync(ct) + .ConfigureAwait(false); // Look up creator information - CreatorReference? creator = await creatorLookup.GetCreatorAsync(tip.CreatorId, ct); + var creator = await creatorLookup + .GetCreatorAsync(tip.CreatorId, ct) + .ConfigureAwait(false); if (!string.IsNullOrEmpty(customerEmail)) { await SendTipConfirmationEmailAsync( - customerEmail, - creator?.Name ?? "le créateur", - tip.Amount, - tip.Currency, - receiptUrl); // Pass the receipt URL + customerEmail, + creator?.Name ?? "le créateur", + tip.Amount, + tip.Currency, + receiptUrl) + .ConfigureAwait(false); // Pass the receipt URL } } else @@ -52,52 +61,53 @@ public class TipPaymentNotifier( string creatorUsername, decimal amount, string currency, - string receiptUrl) // Add receipt URL parameter + Uri receiptUrl) // Add receipt URL parameter { - string subject = $"Merci pour votre soutien à {creatorUsername}"; - string message = $""" -
-

{creatorUsername} vous remercie !

- -

- Votre paiement de {amount} {currency} a été traité avec succès. -

- -
-

- Ce reçu confirme votre soutien à {creatorUsername}. Merci de contribuer à son travail ! -

-
- - {(string.IsNullOrEmpty(receiptUrl) ? "" : $""" - - """)} - -

- Cet email sert de reçu pour votre transaction. Nous vous conseillons de le conserver pour vos archives. -

- -

- Merci d'utiliser Hutopy pour soutenir vos créateurs préférés ! -

-
- """; + var subject = $"Merci pour votre soutien à {creatorUsername}"; + var message = $""" +
+

{creatorUsername} vous remercie !

+ +

+ Votre paiement de {amount} {currency} a été traité avec succès. +

+ +
+

+ Ce reçu confirme votre soutien à {creatorUsername}. Merci de contribuer à son travail ! +

+
+ + + +

+ Cet email sert de reçu pour votre transaction. Nous vous conseillons de le conserver pour vos archives. +

+ +

+ Merci d'utiliser Hutopy pour soutenir vos créateurs préférés ! +

+
+ """; try { - await emailSender.SendEmailAsync(email, subject, message); + await emailSender + .SendEmailAsync(email, subject, message) + .ConfigureAwait(false); + logger.LogInformation("Tip confirmation email sent to {Email} for tip to {Creator}", email, creatorUsername); }