fix(stripe): correcting webhook

This commit is contained in:
2025-08-04 17:15:13 -04:00
parent 18532963a8
commit ea8efd21a1
6 changed files with 134 additions and 113 deletions

View File

@@ -7,7 +7,7 @@ using Stripe.Checkout;
namespace Hutopy.Infrastructure.Payments.Stripe.Services; namespace Hutopy.Infrastructure.Payments.Stripe.Services;
public class StripeTipProcessor( internal class StripeTipProcessor(
IOptions<StripeOptions> stripeOptions) IOptions<StripeOptions> stripeOptions)
: ITipProcessor : ITipProcessor
{ {
@@ -17,61 +17,63 @@ public class StripeTipProcessor(
decimal amount, decimal amount,
string currency, string currency,
string message, string message,
string successUrl, Uri successUrl,
string cancelUrl, Uri cancelUrl,
CancellationToken ct = default) CancellationToken ct = default)
{ {
StripeConfiguration.ApiKey = stripeOptions.Value.SecretKey; StripeConfiguration.ApiKey = stripeOptions.Value.SecretKey;
// Create Stripe customer for the user if not already created // Create Stripe customer for the user if not already created
CustomerService customerService = new(); CustomerService customerService = new();
Customer? customer = await customerService.CreateAsync( var customer = await customerService
new CustomerCreateOptions(), .CreateAsync(
cancellationToken: ct); new CustomerCreateOptions(),
cancellationToken: ct)
.ConfigureAwait(false);
// Create paymentIntent for the user // Create paymentIntent for the user
SessionService sessionService = new(); SessionService sessionService = new();
Session? session = await sessionService.CreateAsync( var session = await sessionService.CreateAsync(
new SessionCreateOptions 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<string, string> { { "creatorId", creator.Id.ToString() } }
}
},
Quantity = 1
}
],
Mode = "payment",
PaymentIntentData = new SessionPaymentIntentDataOptions
{ {
ApplicationFeeAmount = ClientReferenceId = tipId.ToString(),
Convert.ToInt64(amount * stripeOptions.Value.HutopyRate), // Platform fee Customer = customer.Id,
TransferData = new SessionPaymentIntentDataTransferDataOptions 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<string, string> { { "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<string, string>
{
{ "creatorId", creator.Id.ToString() }, { "creatorName", creator.Name }, { "message", message }
} }
}, },
SuccessUrl = successUrl, // Redirect after successful payment cancellationToken: ct)
CancelUrl = cancelUrl, // Redirect after canceled payment .ConfigureAwait(false);
Metadata = new Dictionary<string, string>
{
{ "creatorId", creator.Id.ToString() }, { "creatorName", creator.Name }, { "message", message }
}
},
cancellationToken: ct);
return new TipCheckoutSession(session.Id, session.Url); return new TipCheckoutSession(session.Id, session.Url);
} }

View File

@@ -11,7 +11,7 @@ namespace Hutopy.Modules.Memberships.Handlers;
internal class StripeWebhookEndpoint( internal class StripeWebhookEndpoint(
ITipPaymentNotifier tipPaymentNotifier, ITipPaymentNotifier tipPaymentNotifier,
IMembershipNotifier membershipNotifier, IMembershipNotifier membershipNotifier,
IOptions<StripeOptions> options) IOptions<StripeOptions> stripeOptions)
: EndpointWithoutRequest : EndpointWithoutRequest
{ {
public override void Configure() public override void Configure()
@@ -28,7 +28,7 @@ internal class StripeWebhookEndpoint(
var json = await streamReader.ReadToEndAsync(ct).ConfigureAwait(false); 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 stripeSession = stripeEvent.Data.Object as Session;
var stripeSubscription = stripeEvent.Data.Object as Subscription; var stripeSubscription = stripeEvent.Data.Object as Subscription;
@@ -46,13 +46,22 @@ internal class StripeWebhookEndpoint(
stripeSession.Customer?.Email ?? stripeSession.Customer?.Email ??
""; "";
// Get the receipt URL, preferring the one directly on the charge if available StripeConfiguration.ApiKey = stripeOptions.Value.SecretKey;
var receiptUrl = stripeSession.Invoice?.HostedInvoiceUrl ?? ""; 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 await tipPaymentNotifier
.NotifyPaymentSucceedAsync( .NotifyPaymentSucceedAsync(
stripeSession.Id, stripeSession.Id,
receiptUrl, receiptUri,
customerEmail, customerEmail,
ct) ct)
.ConfigureAwait(false); .ConfigureAwait(false);

View File

@@ -1,10 +1,10 @@
namespace Hutopy.Modules.Tipping.Contracts; namespace Hutopy.Modules.Tipping.Contracts;
public interface ITipPaymentNotifier internal interface ITipPaymentNotifier
{ {
Task NotifyPaymentSucceedAsync( Task NotifyPaymentSucceedAsync(
string stripeId, string sessionId,
string invoiceUrl, Uri receiptUrl,
string customerEmail, string customerEmail,
CancellationToken ct); CancellationToken ct);
} }

View File

@@ -2,7 +2,7 @@ using Hutopy.Modules.Creators.Contracts;
namespace Hutopy.Modules.Tipping.Contracts; namespace Hutopy.Modules.Tipping.Contracts;
public interface ITipProcessor internal interface ITipProcessor
{ {
Task<TipCheckoutSession> CreateCheckoutSessionAsync( Task<TipCheckoutSession> CreateCheckoutSessionAsync(
Guid tipId, Guid tipId,
@@ -10,7 +10,7 @@ public interface ITipProcessor
decimal amount, decimal amount,
string currency, string currency,
string message, string message,
string successUrl, Uri successUrl,
string cancelUrl, Uri cancelUrl,
CancellationToken ct = default); CancellationToken ct = default);
} }

View File

@@ -13,12 +13,12 @@ internal static class SendTip
decimal Amount, decimal Amount,
string Currency, string Currency,
string Message, string Message,
string CheckoutSuccessUrl, Uri CheckoutSuccessUrl,
string CheckoutCancelledUrl); Uri CheckoutCancelledUrl);
internal record Response( internal record Response(
string Id, string Id,
string Url); Uri Url);
internal class Validator : Validator<Request> internal class Validator : Validator<Request>
{ {
@@ -116,7 +116,7 @@ internal static class SendTip
await dbContext.SaveChangesAsync(ct).ConfigureAwait(false); await dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
await SendAsync( await SendAsync(
new Response(checkout.Id, checkout.Url), new Response(checkout.Id, new Uri(checkout.Url)),
cancellation: ct) cancellation: ct)
.ConfigureAwait(false); .ConfigureAwait(false);
} }

View File

@@ -5,7 +5,7 @@ using Hutopy.Modules.Tipping.Data;
namespace Hutopy.Modules.Tipping.Services; namespace Hutopy.Modules.Tipping.Services;
public class TipPaymentNotifier( internal class TipPaymentNotifier(
TippingDbContext dbContext, TippingDbContext dbContext,
IEmailSender emailSender, IEmailSender emailSender,
ICreatorLookup creatorLookup, ICreatorLookup creatorLookup,
@@ -14,31 +14,40 @@ public class TipPaymentNotifier(
{ {
public async Task NotifyPaymentSucceedAsync( public async Task NotifyPaymentSucceedAsync(
string sessionId, string sessionId,
string receiptUrl, Uri receiptUrl,
string customerEmail, string customerEmail,
CancellationToken ct) CancellationToken ct)
{ {
Tip? tip = await dbContext.Tips.SingleOrDefaultAsync( var tip = await dbContext
t => t.StripeSessionId == sessionId, .Tips
ct); .SingleOrDefaultAsync(
t => t.StripeSessionId == sessionId,
ct)
.ConfigureAwait(false);
if (tip is not null) if (tip is not null)
{ {
tip.Status = TipStatus.Paid; tip.Status = TipStatus.Paid;
tip.StripeInvoiceUrl = receiptUrl; // Store the receipt URL tip.StripeInvoiceUrl = receiptUrl.ToString(); // Store the receipt URL
await dbContext.SaveChangesAsync(ct);
await dbContext
.SaveChangesAsync(ct)
.ConfigureAwait(false);
// Look up creator information // 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)) if (!string.IsNullOrEmpty(customerEmail))
{ {
await SendTipConfirmationEmailAsync( await SendTipConfirmationEmailAsync(
customerEmail, customerEmail,
creator?.Name ?? "le créateur", creator?.Name ?? "le créateur",
tip.Amount, tip.Amount,
tip.Currency, tip.Currency,
receiptUrl); // Pass the receipt URL receiptUrl)
.ConfigureAwait(false); // Pass the receipt URL
} }
} }
else else
@@ -52,52 +61,53 @@ public class TipPaymentNotifier(
string creatorUsername, string creatorUsername,
decimal amount, decimal amount,
string currency, string currency,
string receiptUrl) // Add receipt URL parameter Uri receiptUrl) // Add receipt URL parameter
{ {
string subject = $"Merci pour votre soutien à {creatorUsername}"; var subject = $"Merci pour votre soutien à {creatorUsername}";
string message = $""" var message = $"""
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;"> <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;">
<h1 style="color: #2c3e50; margin-bottom: 20px;">{creatorUsername} vous remercie !</h1> <h1 style="color: #2c3e50; margin-bottom: 20px;">{creatorUsername} vous remercie !</h1>
<p style="font-size: 16px; line-height: 1.5; margin-bottom: 15px;"> <p style="font-size: 16px; line-height: 1.5; margin-bottom: 15px;">
Votre paiement de <strong>{amount} {currency}</strong> a é traité avec succès. Votre paiement de <strong>{amount} {currency}</strong> a é traité avec succès.
</p> </p>
<div style="background-color: #f8f9fa; border-radius: 4px; padding: 20px; margin: 30px 0; border-left: 4px solid #3498db;"> <div style="background-color: #f8f9fa; border-radius: 4px; padding: 20px; margin: 30px 0; border-left: 4px solid #3498db;">
<p style="font-size: 16px; margin: 0; line-height: 1.5;"> <p style="font-size: 16px; margin: 0; line-height: 1.5;">
Ce reçu confirme votre soutien à <strong>{creatorUsername}</strong>. Merci de contribuer à son travail ! Ce reçu confirme votre soutien à <strong>{creatorUsername}</strong>. Merci de contribuer à son travail !
</p> </p>
</div> </div>
{(string.IsNullOrEmpty(receiptUrl) ? "" : $""" <div style="text-align: center; margin: 30px 0;">
<div style="text-align: center; margin: 30px 0;"> <a href='{receiptUrl}'
<a href='{receiptUrl}' style="background-color: #3498db;
style="background-color: #3498db; color: white;
color: white; text-decoration: none;
text-decoration: none; padding: 12px 24px;
padding: 12px 24px; border-radius: 4px;
border-radius: 4px; font-weight: bold;
font-weight: bold; display: inline-block;
display: inline-block; box-shadow: 0 2px 5px rgba(0,0,0,0.1);">
box-shadow: 0 2px 5px rgba(0,0,0,0.1);"> Voir le reçu
Voir le reçu </a>
</a> </div>
</div>
""")}
<p style="font-size: 14px; color: #7f8c8d; margin-top: 30px;"> <p style="font-size: 14px; color: #7f8c8d; margin-top: 30px;">
Cet email sert de reçu pour votre transaction. Nous vous conseillons de le conserver pour vos archives. Cet email sert de reçu pour votre transaction. Nous vous conseillons de le conserver pour vos archives.
</p> </p>
<p style="font-size: 14px; color: #7f8c8d; margin-top: 20px; text-align: center; border-top: 1px solid #eee; padding-top: 20px;"> <p style="font-size: 14px; color: #7f8c8d; margin-top: 20px; text-align: center; border-top: 1px solid #eee; padding-top: 20px;">
Merci d'utiliser Hutopy pour soutenir vos créateurs préférés ! Merci d'utiliser Hutopy pour soutenir vos créateurs préférés !
</p> </p>
</div> </div>
"""; """;
try 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, logger.LogInformation("Tip confirmation email sent to {Email} for tip to {Creator}", email,
creatorUsername); creatorUsername);
} }