using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using OTSSignsOrchestrator.Server.Data; using OTSSignsOrchestrator.Server.Data.Entities; using OTSSignsOrchestrator.Server.Hubs; using OTSSignsOrchestrator.Server.Services; using Stripe; namespace OTSSignsOrchestrator.Server.Webhooks; public static class StripeWebhookHandler { public static void MapStripeWebhook(this WebApplication app) { app.MapPost("/api/webhooks/stripe", HandleWebhook) .DisableRateLimiting(); } private static async Task HandleWebhook( HttpContext httpContext, IConfiguration config, OrchestratorDbContext db, AbbreviationService abbreviationService, EmailService emailService, IHubContext hub, ILogger logger) { // ── Verify signature ──────────────────────────────────────────────── var json = await new StreamReader(httpContext.Request.Body).ReadToEndAsync(); var webhookSecret = config["Stripe:WebhookSecret"] ?? throw new InvalidOperationException("Stripe:WebhookSecret not configured."); Event stripeEvent; try { stripeEvent = EventUtility.ConstructEvent( json, httpContext.Request.Headers["Stripe-Signature"], webhookSecret); } catch (StripeException ex) { logger.LogWarning("Stripe signature verification failed: {Error}", ex.Message); return Results.BadRequest("Invalid signature."); } // ── Idempotency check ─────────────────────────────────────────────── if (await db.StripeEvents.AnyAsync(e => e.StripeEventId == stripeEvent.Id)) { logger.LogInformation("Stripe event {EventId} already processed — skipping", stripeEvent.Id); return Results.Ok(); } // ── Record event before processing ────────────────────────────────── db.StripeEvents.Add(new Data.Entities.StripeEvent { StripeEventId = stripeEvent.Id, EventType = stripeEvent.Type, ProcessedAt = DateTime.UtcNow, Payload = json, }); await db.SaveChangesAsync(); // ── Dispatch ──────────────────────────────────────────────────────── logger.LogInformation("Processing Stripe event {EventId} type={EventType}", stripeEvent.Id, stripeEvent.Type); switch (stripeEvent.Type) { case EventTypes.CheckoutSessionCompleted: await HandleCheckoutSessionCompleted(stripeEvent, db, abbreviationService, hub, logger); break; case EventTypes.InvoicePaid: logger.LogInformation("Invoice paid: {EventId}", stripeEvent.Id); break; case EventTypes.InvoicePaymentFailed: await HandleInvoicePaymentFailed(stripeEvent, db, emailService, hub, logger); break; case EventTypes.CustomerSubscriptionUpdated: await HandleSubscriptionUpdated(stripeEvent, db, hub, logger); break; case EventTypes.CustomerSubscriptionDeleted: await HandleSubscriptionDeleted(stripeEvent, db, hub, logger); break; case "customer.subscription.trial_will_end": logger.LogInformation("Trial ending soon for event {EventId}", stripeEvent.Id); break; default: logger.LogInformation("Unhandled Stripe event type: {EventType}", stripeEvent.Type); break; } return Results.Ok(); } // ── checkout.session.completed ────────────────────────────────────────── private static async Task HandleCheckoutSessionCompleted( Event stripeEvent, OrchestratorDbContext db, AbbreviationService abbreviationService, IHubContext hub, ILogger logger) { var session = stripeEvent.Data.Object as Stripe.Checkout.Session; if (session is null) return; var meta = session.Metadata; if (!meta.TryGetValue("ots_customer_id", out var customerIdStr) || !Guid.TryParse(customerIdStr, out var customerId)) { logger.LogWarning("checkout.session.completed missing ots_customer_id metadata"); return; } var customer = await db.Customers.FindAsync(customerId); if (customer is null) { logger.LogWarning("Customer {CustomerId} not found for checkout session", customerId); return; } // Update customer from Stripe metadata customer.StripeCustomerId = session.CustomerId; customer.StripeSubscriptionId = session.SubscriptionId; customer.Status = CustomerStatus.Provisioning; if (meta.TryGetValue("company_name", out var cn)) customer.CompanyName = cn; if (meta.TryGetValue("admin_email", out var ae)) customer.AdminEmail = ae; if (meta.TryGetValue("admin_first_name", out var fn)) customer.AdminFirstName = fn; if (meta.TryGetValue("admin_last_name", out var ln)) customer.AdminLastName = ln; if (meta.TryGetValue("plan", out var planStr) && Enum.TryParse(planStr, true, out var plan)) customer.Plan = plan; if (meta.TryGetValue("screen_count", out var scStr) && int.TryParse(scStr, out var sc)) customer.ScreenCount = sc; // Generate abbreviation var abbrev = await abbreviationService.GenerateAsync(customer.CompanyName); customer.Abbreviation = abbrev; // Create provisioning job var job = new Job { Id = Guid.NewGuid(), CustomerId = customer.Id, JobType = "provision", Status = JobStatus.Queued, TriggeredBy = "stripe-webhook", CreatedAt = DateTime.UtcNow, }; db.Jobs.Add(job); await db.SaveChangesAsync(); logger.LogInformation( "Checkout completed: customer={CustomerId}, abbrev={Abbrev}, job={JobId}", customer.Id, abbrev, job.Id); await hub.Clients.All.SendJobCreated(job.Id.ToString(), abbrev, "provision"); } // ── invoice.payment_failed ────────────────────────────────────────────── private static async Task HandleInvoicePaymentFailed( Event stripeEvent, OrchestratorDbContext db, EmailService emailService, IHubContext hub, ILogger logger) { var invoice = stripeEvent.Data.Object as Stripe.Invoice; if (invoice is null) return; var customer = await db.Customers.FirstOrDefaultAsync( c => c.StripeCustomerId == invoice.CustomerId); if (customer is null) { logger.LogWarning("No customer found for Stripe customer {StripeCustomerId}", invoice.CustomerId); return; } customer.FailedPaymentCount++; customer.FirstPaymentFailedAt ??= DateTime.UtcNow; await db.SaveChangesAsync(); logger.LogInformation( "Payment failed for customer {CustomerId} ({Company}) — count={Count}", customer.Id, customer.CompanyName, customer.FailedPaymentCount); await emailService.SendPaymentFailedEmailAsync( customer.AdminEmail, customer.CompanyName, customer.FailedPaymentCount); // At day 14 with 3+ failures → suspend var daysSinceFirst = (DateTime.UtcNow - customer.FirstPaymentFailedAt.Value).TotalDays; if (customer.FailedPaymentCount >= 3 && daysSinceFirst >= 14) { logger.LogWarning( "Customer {CustomerId} ({Company}) has {Count} failed payments over {Days} days — creating suspend job", customer.Id, customer.CompanyName, customer.FailedPaymentCount, (int)daysSinceFirst); var job = new Job { Id = Guid.NewGuid(), CustomerId = customer.Id, JobType = "suspend", Status = JobStatus.Queued, TriggeredBy = "stripe-webhook", CreatedAt = DateTime.UtcNow, }; db.Jobs.Add(job); await db.SaveChangesAsync(); await hub.Clients.All.SendJobCreated(job.Id.ToString(), customer.Abbreviation, "suspend"); } } // ── customer.subscription.updated ─────────────────────────────────────── private static async Task HandleSubscriptionUpdated( Event stripeEvent, OrchestratorDbContext db, IHubContext hub, ILogger logger) { var subscription = stripeEvent.Data.Object as Stripe.Subscription; if (subscription is null) return; var customer = await db.Customers.FirstOrDefaultAsync( c => c.StripeSubscriptionId == subscription.Id); if (customer is null) { logger.LogWarning("No customer found for subscription {SubscriptionId}", subscription.Id); return; } // Sync screen count from subscription quantity var quantity = subscription.Items?.Data?.FirstOrDefault()?.Quantity; if (quantity.HasValue && (int)quantity.Value != customer.ScreenCount) { var oldCount = customer.ScreenCount; customer.ScreenCount = (int)quantity.Value; logger.LogInformation( "Screen count changed for customer {CustomerId}: {Old} → {New}", customer.Id, oldCount, customer.ScreenCount); var job = new Job { Id = Guid.NewGuid(), CustomerId = customer.Id, JobType = "update-screen-limit", Status = JobStatus.Queued, TriggeredBy = "stripe-webhook", Parameters = $"{{\"oldCount\":{oldCount},\"newCount\":{customer.ScreenCount}}}", CreatedAt = DateTime.UtcNow, }; db.Jobs.Add(job); await db.SaveChangesAsync(); await hub.Clients.All.SendJobCreated(job.Id.ToString(), customer.Abbreviation, "update-screen-limit"); } // Reactivate if subscription is active and customer was suspended if (subscription.Status == "active" && customer.Status == CustomerStatus.Suspended) { logger.LogInformation("Reactivating customer {CustomerId} — subscription now active", customer.Id); customer.FailedPaymentCount = 0; customer.FirstPaymentFailedAt = null; var job = new Job { Id = Guid.NewGuid(), CustomerId = customer.Id, JobType = "reactivate", Status = JobStatus.Queued, TriggeredBy = "stripe-webhook", CreatedAt = DateTime.UtcNow, }; db.Jobs.Add(job); await db.SaveChangesAsync(); await hub.Clients.All.SendJobCreated(job.Id.ToString(), customer.Abbreviation, "reactivate"); } await db.SaveChangesAsync(); await hub.Clients.All.SendInstanceStatusChanged(customer.Id.ToString(), customer.Status.ToString()); } // ── customer.subscription.deleted ─────────────────────────────────────── private static async Task HandleSubscriptionDeleted( Event stripeEvent, OrchestratorDbContext db, IHubContext hub, ILogger logger) { var subscription = stripeEvent.Data.Object as Stripe.Subscription; if (subscription is null) return; var customer = await db.Customers.FirstOrDefaultAsync( c => c.StripeSubscriptionId == subscription.Id); if (customer is null) { logger.LogWarning("No customer found for deleted subscription {SubscriptionId}", subscription.Id); return; } logger.LogInformation( "Subscription deleted for customer {CustomerId} ({Company}) — creating decommission job", customer.Id, customer.CompanyName); var job = new Job { Id = Guid.NewGuid(), CustomerId = customer.Id, JobType = "decommission", Status = JobStatus.Queued, TriggeredBy = "stripe-webhook", CreatedAt = DateTime.UtcNow, }; db.Jobs.Add(job); await db.SaveChangesAsync(); await hub.Clients.All.SendJobCreated(job.Id.ToString(), customer.Abbreviation, "decommission"); } }