using FastEndpoints; using GB5Shared.DTO.Framework.Login; using GoodBooks.PAY.PAYBLL.Webhook; using Microsoft.Extensions.Logging; namespace GoodBooks.PAY.PAYSL.Endpoints.Webhook { // ───────────────────────────────────────────────────────────────────────── // ReceiveWebhook // // IMPORTANT: Inherits Endpoint — NOT BaseEndpoint<>. // BaseEndpoint<> reads a Login header to construct LoginDTO. // External gateways (Razorpay, Stripe, Cashfree) never send that header. // Using BaseEndpoint<> would cause a 500 error on every real webhook. // // Route includes TenantId so the tenant is deterministically identified // from the URL before parsing the untrusted payload body. // // ALWAYS returns HTTP 200 — gateways retry on any other status, // causing duplicate processing storms. // ───────────────────────────────────────────────────────────────────────── public class ReceiveWebhook : Endpoint { private readonly IPayWebhookBLL _bll; private readonly ILogger _logger; public ReceiveWebhook(IPayWebhookBLL bll, ILogger logger) { _bll = bll; _logger = logger; } public record WebhookParameters( [property: RouteParam] string GatewayCode, [property: RouteParam] int TenantId ); public override void Configure() { Post("/Pay/Webhook/{GatewayCode}/{TenantId}"); AllowAnonymous(); // Raw endpoint — gateways do not send the Login header that BaseEndpoint<> expects } public override async Task HandleAsync(WebhookParameters req, CancellationToken ct) { // Step 1: Buffer request body so it can be read as a string HttpContext.Request.EnableBuffering(); string rawPayload; using (var reader = new System.IO.StreamReader( HttpContext.Request.Body, leaveOpen: true)) { rawPayload = await reader.ReadToEndAsync(ct).ConfigureAwait(false); } HttpContext.Request.Body.Position = 0; // Step 2: Extract gateway signature header (check all known header names) // Cashfree's real webhook headers are `x-webhook-signature` + a separate // `x-webhook-timestamp` (its HMAC is computed over timestamp+body, not the body // alone) — there is no single "X-Cashfree-Signature" header in Cashfree's real API. // Combined into the same "t=...,v1=..." compound-string convention Stripe's own // native header already uses, so CashfreeGateway can parse it the same way // StripeGateway parses Stripe-Signature — no interface change needed. string? cashfreeSig = HttpContext.Request.Headers["x-webhook-signature"].FirstOrDefault(); string sigHeader = HttpContext.Request.Headers["X-Razorpay-Signature"].FirstOrDefault() ?? HttpContext.Request.Headers["Stripe-Signature"].FirstOrDefault() ?? (cashfreeSig is not null ? $"t={HttpContext.Request.Headers["x-webhook-timestamp"].FirstOrDefault()},v1={cashfreeSig}" : null) ?? string.Empty; // Step 3: Build system-level LoginDTO (no user session for gateway callbacks) LoginDTO systemLogin; try { systemLogin = await _bll.BuildSystemLoginAsync(req.TenantId, req.GatewayCode, ct) .ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "BuildSystemLogin failed {GatewayCode}/{TenantId}", req.GatewayCode, req.TenantId); // Fall back to minimal login — BLL will handle gracefully systemLogin = new LoginDTO { ClientId = req.TenantId, DatabaseName = "GoodBooks_Main", UserId = -1 }; } // Step 4: Process webhook — BLL never throws; catch here is belt-and-suspenders try { await _bll.ProcessWebhookAsync( req.GatewayCode, req.TenantId, rawPayload, sigHeader, systemLogin, ct) .ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "Unexpected exception from PayWebhookBLL {GatewayCode}/{TenantId}", req.GatewayCode, req.TenantId); } // Step 5: ALWAYS return 200 — any other status triggers gateway retry storms await Send.OkAsync(ct); } } }