Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/StripeWebhookController.cs
T
2026-04-23 21:38:24 -04:00

129 lines
5.6 KiB
C#

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Receives platform-level Stripe webhook events (subscription lifecycle, checkout completion, etc.)
/// from the Stripe platform account. This is distinct from <c>PaymentController.ConnectWebhook</c>
/// which handles Stripe Connect account-level events (payments, refunds, disputes on tenant
/// accounts). Raw body reading is required — Stripe's signature verification computes an HMAC over
/// the exact bytes received, so any middleware that re-encodes the body (e.g. JSON middleware)
/// would break signature validation. The request body size limit is 5 MB; Stripe events are always
/// much smaller, but this cap prevents memory exhaustion from malformed oversized POST requests.
/// </summary>
[AllowAnonymous]
[IgnoreAntiforgeryToken]
[RequestSizeLimit(5 * 1024 * 1024)] // 5 MB — Stripe events are never larger than this
[EnableRateLimiting(AppConstants.RateLimitPolicies.Webhook)]
public class StripeWebhookController : Controller
{
private readonly IStripeService _stripeService;
private readonly ApplicationDbContext _db;
private readonly ILogger<StripeWebhookController> _logger;
public StripeWebhookController(
IStripeService stripeService,
ApplicationDbContext db,
ILogger<StripeWebhookController> logger)
{
_stripeService = stripeService;
_db = db;
_logger = logger;
}
/// <summary>
/// Receives, verifies, and processes a Stripe webhook event at <c>POST /stripe/webhook</c>.
/// The body is read as raw text rather than via model binding so the HMAC signature computed
/// by Stripe over the exact byte sequence remains valid. The event ID and type are parsed
/// structurally (before signature check) purely for logging — this pre-parse does not trust the
/// payload; the authoritative verification happens inside
/// <c>IStripeService.HandleWebhookAsync</c>. The <c>StripeWebhookEvent</c> audit record is
/// written AFTER signature verification to prevent unauthenticated callers from flooding the
/// events table. On a processing error after verification, the event is still recorded with
/// <c>Status = Failed</c> so SuperAdmins can investigate in <c>StripeEventsController</c>.
/// Returns HTTP 500 on processing failure — Stripe will retry the event up to its retry policy
/// limit, which is the desired behaviour for transient DB or service errors.
/// </summary>
[HttpPost("/stripe/webhook")]
public async Task<IActionResult> Webhook()
{
// Read raw body
using var reader = new StreamReader(Request.Body);
var json = await reader.ReadToEndAsync();
var stripeSignature = Request.Headers["Stripe-Signature"].ToString();
if (string.IsNullOrEmpty(stripeSignature))
{
_logger.LogWarning("Stripe webhook received without signature header");
return BadRequest("Missing Stripe-Signature header");
}
// Parse event id/type for logging (no signature check yet — just structural parse)
string eventId = string.Empty;
string eventType = string.Empty;
try
{
var evt = Stripe.EventUtility.ParseEvent(json);
eventId = evt.Id ?? string.Empty;
eventType = evt.Type ?? string.Empty;
}
catch { /* invalid JSON — HandleWebhookAsync will reject it below */ }
// Process first (signature verification happens inside HandleWebhookAsync).
// Only persist a log record after we know the request is authentic — this prevents
// unauthenticated callers from flooding the StripeWebhookEvents table.
try
{
await _stripeService.HandleWebhookAsync(json, stripeSignature);
// Signature verified and event processed — now record it
var logEntry = new StripeWebhookEvent
{
EventId = eventId,
EventType = eventType,
RawJson = json,
Status = StripeWebhookEventStatus.Processed,
ReceivedAt = DateTime.UtcNow,
ProcessedAt = DateTime.UtcNow
};
_db.StripeWebhookEvents.Add(logEntry);
await _db.SaveChangesAsync();
return Ok();
}
catch (Stripe.StripeException ex)
{
// Signature invalid or event malformed — log warning, do NOT save to DB
_logger.LogWarning("Stripe webhook rejected (invalid signature or payload): {Message}", ex.Message);
return BadRequest("Invalid Stripe signature");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing Stripe webhook {EventType} {EventId}", eventType, eventId);
// Processing failed after signature was verified — record it for debugging
var logEntry = new StripeWebhookEvent
{
EventId = eventId,
EventType = eventType,
RawJson = json,
Status = StripeWebhookEventStatus.Failed,
ReceivedAt = DateTime.UtcNow,
ProcessedAt = DateTime.UtcNow,
ErrorMessage = ex.Message
};
_db.StripeWebhookEvents.Add(logEntry);
await _db.SaveChangesAsync();
return StatusCode(500, "Webhook processing error");
}
}
}