328 lines
13 KiB
C#
328 lines
13 KiB
C#
|
|
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");
|
||
|
|
}
|
||
|
|
}
|