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;
public class StripeTipProcessor(
internal class StripeTipProcessor(
IOptions<StripeOptions> 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<string, string> { { "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<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
CancelUrl = cancelUrl, // Redirect after canceled payment
Metadata = new Dictionary<string, string>
{
{ "creatorId", creator.Id.ToString() }, { "creatorName", creator.Name }, { "message", message }
}
},
cancellationToken: ct);
cancellationToken: ct)
.ConfigureAwait(false);
return new TipCheckoutSession(session.Id, session.Url);
}

View File

@@ -11,7 +11,7 @@ namespace Hutopy.Modules.Memberships.Handlers;
internal class StripeWebhookEndpoint(
ITipPaymentNotifier tipPaymentNotifier,
IMembershipNotifier membershipNotifier,
IOptions<StripeOptions> options)
IOptions<StripeOptions> 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);

View File

@@ -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);
}

View File

@@ -2,7 +2,7 @@ using Hutopy.Modules.Creators.Contracts;
namespace Hutopy.Modules.Tipping.Contracts;
public interface ITipProcessor
internal interface ITipProcessor
{
Task<TipCheckoutSession> 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);
}

View File

@@ -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<Request>
{
@@ -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);
}

View File

@@ -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 = $"""
<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>
<p style="font-size: 16px; line-height: 1.5; margin-bottom: 15px;">
Votre paiement de <strong>{amount} {currency}</strong> a é traité avec succès.
</p>
<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;">
Ce reçu confirme votre soutien à <strong>{creatorUsername}</strong>. Merci de contribuer à son travail !
</p>
</div>
{(string.IsNullOrEmpty(receiptUrl) ? "" : $"""
<div style="text-align: center; margin: 30px 0;">
<a href='{receiptUrl}'
style="background-color: #3498db;
color: white;
text-decoration: none;
padding: 12px 24px;
border-radius: 4px;
font-weight: bold;
display: inline-block;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);">
Voir le reçu
</a>
</div>
""")}
<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.
</p>
<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 !
</p>
</div>
""";
var subject = $"Merci pour votre soutien à {creatorUsername}";
var message = $"""
<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>
<p style="font-size: 16px; line-height: 1.5; margin-bottom: 15px;">
Votre paiement de <strong>{amount} {currency}</strong> a é traité avec succès.
</p>
<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;">
Ce reçu confirme votre soutien à <strong>{creatorUsername}</strong>. Merci de contribuer à son travail !
</p>
</div>
<div style="text-align: center; margin: 30px 0;">
<a href='{receiptUrl}'
style="background-color: #3498db;
color: white;
text-decoration: none;
padding: 12px 24px;
border-radius: 4px;
font-weight: bold;
display: inline-block;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);">
Voir le reçu
</a>
</div>
<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.
</p>
<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 !
</p>
</div>
""";
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);
}