fix(stripe): correcting webhook

This commit is contained in:
2025-08-04 16:31:54 -04:00
parent c0a1eabecb
commit 18532963a8
2 changed files with 149 additions and 137 deletions

View File

@@ -8,11 +8,10 @@ using Stripe.Checkout;
namespace Hutopy.Modules.Memberships.Handlers; namespace Hutopy.Modules.Memberships.Handlers;
public class StripeWebhookEndpoint( internal class StripeWebhookEndpoint(
ITipPaymentNotifier tipPaymentNotifier, ITipPaymentNotifier tipPaymentNotifier,
IMembershipNotifier membershipNotifier, IMembershipNotifier membershipNotifier,
IOptions<StripeOptions> options, IOptions<StripeOptions> options)
ILogger<StripeWebhookEndpoint> logger)
: EndpointWithoutRequest : EndpointWithoutRequest
{ {
public override void Configure() public override void Configure()
@@ -24,10 +23,11 @@ public class StripeWebhookEndpoint(
public override async Task HandleAsync(CancellationToken ct) 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"]; 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 stripeEvent = EventUtility.ConstructEvent(json, signatureHeader, options.Value.WebhookSecret);
var stripeSession = stripeEvent.Data.Object as Session; var stripeSession = stripeEvent.Data.Object as Session;
@@ -37,16 +37,10 @@ public class StripeWebhookEndpoint(
{ {
case "checkout.session.completed": case "checkout.session.completed":
Debug.Assert(stripeSession != null); 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) switch (stripeSession.Mode)
{ {
// Check if this is a one-time tip // Check if this is a one-time tip
case "payment" when stripeSession.PaymentIntentId != null case "payment" when stripeSession is { PaymentIntentId: not null, PaymentStatus: "paid" }:
&& stripeSession.PaymentIntent.Status == "paid":
// Get the customer email from the appropriate place // Get the customer email from the appropriate place
var customerEmail = stripeSession.CustomerDetails?.Email ?? var customerEmail = stripeSession.CustomerDetails?.Email ??
stripeSession.Customer?.Email ?? stripeSession.Customer?.Email ??
@@ -55,21 +49,25 @@ public class StripeWebhookEndpoint(
// Get the receipt URL, preferring the one directly on the charge if available // Get the receipt URL, preferring the one directly on the charge if available
var receiptUrl = stripeSession.Invoice?.HostedInvoiceUrl ?? ""; var receiptUrl = stripeSession.Invoice?.HostedInvoiceUrl ?? "";
await tipPaymentNotifier.NotifyPaymentSucceedAsync( await tipPaymentNotifier
stripeSession.Id, .NotifyPaymentSucceedAsync(
receiptUrl, stripeSession.Id,
customerEmail, receiptUrl,
ct); customerEmail,
ct)
.ConfigureAwait(false);
break; break;
// Check if this is a subscription // Check if this is a subscription
case "subscription" when stripeSession.SubscriptionId != null: case "subscription" when stripeSession.SubscriptionId != null:
await membershipNotifier.NotifyPaymentSucceedAsync( await membershipNotifier
stripeSession.SubscriptionId, .NotifyPaymentSucceedAsync(
stripeSession.Invoice.HostedInvoiceUrl, stripeSession.SubscriptionId,
stripeSession.Invoice.Total, stripeSession.Invoice.HostedInvoiceUrl,
stripeSession.Invoice.Currency, stripeSession.Invoice.Total,
ct); stripeSession.Invoice.Currency,
ct)
.ConfigureAwait(false);
break; break;
} }
@@ -78,29 +76,35 @@ public class StripeWebhookEndpoint(
var invoice = stripeEvent.Data.Object as Invoice; var invoice = stripeEvent.Data.Object as Invoice;
Debug.Assert(invoice != null); Debug.Assert(invoice != null);
Debug.Assert(invoice.Subscription != null); Debug.Assert(invoice.Subscription != null);
await membershipNotifier.NotifyPaymentSucceedAsync( await membershipNotifier
invoice.SubscriptionId, .NotifyPaymentSucceedAsync(
invoice.HostedInvoiceUrl, invoice.SubscriptionId,
invoice.Total, invoice.HostedInvoiceUrl,
invoice.Currency, invoice.Total,
ct); invoice.Currency,
ct)
.ConfigureAwait(false);
break; break;
case "customer.subscription.updated": case "customer.subscription.updated":
Debug.Assert(stripeSubscription != null); Debug.Assert(stripeSubscription != null);
await membershipNotifier.NotifySubscriptionUpdatedAsync( await membershipNotifier
stripeSubscription.Id, .NotifySubscriptionUpdatedAsync(
stripeSubscription.CancelAt ?? stripeSubscription.CanceledAt, stripeSubscription.Id,
ct); stripeSubscription.CancelAt ?? stripeSubscription.CanceledAt,
ct)
.ConfigureAwait(false);
break; break;
case "customer.subscription.deleted": case "customer.subscription.deleted":
Debug.Assert(stripeSubscription != null); Debug.Assert(stripeSubscription != null);
await membershipNotifier.NotifySubscriptionDeletedAsync( await membershipNotifier
stripeSubscription.Id, .NotifySubscriptionDeletedAsync(
ct); stripeSubscription.Id,
ct)
.ConfigureAwait(false);
break; break;
} }
await SendOkAsync(ct); await SendOkAsync(ct).ConfigureAwait(false);
} }
} }

View File

@@ -6,111 +6,119 @@ using Hutopy.Modules.Tipping.Data;
namespace Hutopy.Modules.Tipping.Handlers; namespace Hutopy.Modules.Tipping.Handlers;
[PublicAPI] [PublicAPI]
public record SendTipRequest( internal static class SendTip
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<SendTipRequest>
{ {
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<Request>
{ {
RuleFor(x => x.Amount) public Validator()
.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<SendTipRequest, SendTipResponse>
{
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)
{ {
await SendNotFoundAsync(ct); RuleFor(x => x.Amount)
return; .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<Request, Response>
{
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); ArgumentNullException.ThrowIfNull(req);
return;
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);
} }
} }