Files
OTSSignsOrchestrator/OTSSignsOrchestrator.Server/Webhooks/StripeWebhookHandler.cs

328 lines
13 KiB
C#
Raw Normal View History

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<IResult> HandleWebhook(
HttpContext httpContext,
IConfiguration config,
OrchestratorDbContext db,
AbbreviationService abbreviationService,
EmailService emailService,
IHubContext<FleetHub, IFleetClient> hub,
ILogger<StripeEvent> 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<FleetHub, IFleetClient> hub,
ILogger<StripeEvent> 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<CustomerPlan>(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<FleetHub, IFleetClient> hub,
ILogger<StripeEvent> 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<FleetHub, IFleetClient> hub,
ILogger<StripeEvent> 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<FleetHub, IFleetClient> hub,
ILogger<StripeEvent> 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");
}
}