using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using PowderCoating.Application.Interfaces; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; using Stripe; using Stripe.Checkout; namespace PowderCoating.Infrastructure.Services; /// /// Manages Stripe subscription billing for the platform: creates checkout sessions for plan /// upgrades, fulfills completed checkouts, syncs subscription state, opens the customer billing /// portal, and handles inbound Stripe webhook events. All webhook events are verified with /// HMAC signature validation before processing to ensure idempotent, tamper-proof delivery. /// public class StripeService : IStripeService { private readonly IUnitOfWork _unitOfWork; private readonly IConfiguration _configuration; private readonly IInAppNotificationService _inApp; private readonly ILogger _logger; private readonly string _webhookSecret; /// /// Constructs the service and sets the Stripe global API key from configuration. /// The webhook secret is cached at construction time rather than read per-request /// because is called on every inbound webhook and /// configuration reads are synchronous/cheap but the intent is to make it obvious /// where the secret comes from. /// public StripeService( IUnitOfWork unitOfWork, IConfiguration configuration, IInAppNotificationService inApp, ILogger logger) { _unitOfWork = unitOfWork; _configuration = configuration; _inApp = inApp; _logger = logger; StripeConfiguration.ApiKey = configuration["Stripe:SecretKey"] ?? string.Empty; _webhookSecret = configuration["Stripe:WebhookSecret"] ?? string.Empty; } /// /// Creates a Stripe Checkout session for an existing tenant company that is upgrading /// or changing its subscription plan. Persists the chosen billing period preference /// () before creating the session so renewals default to the same /// cadence. Creates a Stripe Customer record on first checkout and stores the resulting /// StripeCustomerId on the company. The price ID is resolved from /// SubscriptionPlanConfig in the database; the method validates that the stored ID /// starts with price_ (not prod_) and provides a human-readable error message /// if misconfigured. Returns the Stripe-hosted checkout URL to which the caller should redirect. /// public async Task CreateCheckoutSessionAsync( int companyId, int newPlan, bool isAnnual, string successUrl, string cancelUrl) { var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true) ?? throw new InvalidOperationException($"Company {companyId} not found"); // Persist the billing period preference so future renewals default to the same company.IsAnnualBilling = isAnnual; await _unitOfWork.Companies.UpdateAsync(company); await _unitOfWork.CompleteAsync(); // Get plan config for Stripe price ID var planConfig = await _unitOfWork.SubscriptionPlanConfigs.FirstOrDefaultAsync( c => c.Plan == newPlan && c.IsActive, ignoreQueryFilters: true); // Use annual price ID when requested, fall back to monthly var planName = planConfig?.DisplayName ?? newPlan.ToString(); var priceId = isAnnual ? (!string.IsNullOrWhiteSpace(planConfig?.StripePriceIdAnnual) ? planConfig!.StripePriceIdAnnual : null) : (!string.IsNullOrWhiteSpace(planConfig?.StripePriceIdMonthly) ? planConfig!.StripePriceIdMonthly : _configuration[$"Stripe:Prices:{planName}"]); if (string.IsNullOrWhiteSpace(priceId)) throw new InvalidOperationException( $"No Stripe price ID configured for the {planName} plan. " + "Go to Platform Management → Subscription Plans → Edit and enter the price ID."); // Product IDs (prod_...) are a common mistake — price IDs must start with price_ if (!priceId!.StartsWith("price_")) throw new InvalidOperationException( $"The value '{priceId}' saved for the {planName} plan is a Stripe product ID, not a price ID. " + "In your Stripe dashboard, open the product, find the specific price under the Pricing section, " + "and copy the ID that starts with 'price_'. " + "Then update it in Platform Management → Subscription Plans → Edit."); // Create Stripe customer if needed var stripeCustomerId = company.StripeCustomerId; if (string.IsNullOrEmpty(stripeCustomerId)) { var customerService = new CustomerService(); var customer = await customerService.CreateAsync(new CustomerCreateOptions { Email = company.PrimaryContactEmail, Name = company.CompanyName, Metadata = new Dictionary { ["companyId"] = companyId.ToString() } }); stripeCustomerId = customer.Id; // Persist customer ID company.StripeCustomerId = stripeCustomerId; await _unitOfWork.Companies.UpdateAsync(company); await _unitOfWork.CompleteAsync(); } var sessionService = new SessionService(); var session = await sessionService.CreateAsync(new SessionCreateOptions { Customer = stripeCustomerId, Mode = "subscription", PaymentMethodTypes = new List { "card" }, LineItems = new List { new SessionLineItemOptions { Price = priceId, Quantity = 1 } }, // Stripe replaces {CHECKOUT_SESSION_ID} with the real session ID on redirect SuccessUrl = (successUrl.Contains('?') ? successUrl + "&" : successUrl + "?") + "session_id={CHECKOUT_SESSION_ID}", CancelUrl = cancelUrl, Metadata = new Dictionary { ["companyId"] = companyId.ToString(), ["plan"] = newPlan.ToString() // stored as int string } }); return session.Url; } /// /// Creates a Stripe Checkout session for a new registrant who has not yet been issued a /// company record in the database. Unlike , this /// session is keyed by (via CustomerEmail) rather than a /// stored StripeCustomerId, and carries a registration=true metadata flag so /// the subsequent call can distinguish /// registration sessions from plan-upgrade sessions in webhook handlers. /// Returns the Stripe-hosted checkout URL. /// public async Task CreateRegistrationCheckoutSessionAsync( int plan, bool isAnnual, string email, string companyName, string successUrl, string cancelUrl) { var planConfig = await _unitOfWork.SubscriptionPlanConfigs.FirstOrDefaultAsync( c => c.Plan == plan && c.IsActive, ignoreQueryFilters: true); var planName = planConfig?.DisplayName ?? plan.ToString(); var priceId = isAnnual ? (!string.IsNullOrWhiteSpace(planConfig?.StripePriceIdAnnual) ? planConfig!.StripePriceIdAnnual : null) : (!string.IsNullOrWhiteSpace(planConfig?.StripePriceIdMonthly) ? planConfig!.StripePriceIdMonthly : _configuration[$"Stripe:Prices:{planName}"]); if (string.IsNullOrWhiteSpace(priceId)) throw new InvalidOperationException( $"No Stripe price ID configured for the {planName} plan. " + "Go to Platform Management → Subscription Plans → Edit and enter the price ID."); if (!priceId!.StartsWith("price_")) throw new InvalidOperationException( $"The value '{priceId}' for the {planName} plan is not a valid Stripe price ID (must start with 'price_')."); var sessionService = new SessionService(); var session = await sessionService.CreateAsync(new SessionCreateOptions { CustomerEmail = email, Mode = "subscription", PaymentMethodTypes = new List { "card" }, LineItems = new List { new() { Price = priceId, Quantity = 1 } }, SuccessUrl = (successUrl.Contains('?') ? successUrl + "&" : successUrl + "?") + "session_id={CHECKOUT_SESSION_ID}", CancelUrl = cancelUrl, Metadata = new Dictionary { ["registration"] = "true", ["plan"] = plan.ToString() } }); return session.Url; } /// /// Verifies that the supplied Stripe Checkout session belongs to the registration flow and has /// reached the paid/complete state. Returns false for any missing/invalid/unpaid session /// so the caller can safely stop before creating any local company or user records. /// public async Task IsRegistrationCheckoutPaidAsync(string sessionId) { try { var sessionService = new SessionService(); var session = await sessionService.GetAsync(sessionId); if (!session.Metadata.TryGetValue("registration", out var isRegistration) || !string.Equals(isRegistration, "true", StringComparison.OrdinalIgnoreCase)) { _logger.LogWarning( "Registration checkout validation failed for session {SessionId}: missing registration metadata", sessionId); return false; } var isPaidAndComplete = session.PaymentStatus == "paid" && session.Status == "complete"; if (!isPaidAndComplete) { _logger.LogWarning( "Registration checkout validation failed for session {SessionId}: paymentStatus={PaymentStatus}, status={Status}", sessionId, session.PaymentStatus, session.Status); } return isPaidAndComplete; } catch (StripeException ex) { _logger.LogWarning(ex, "Stripe rejected registration checkout validation for session {SessionId}", sessionId); return false; } catch (Exception ex) { _logger.LogError(ex, "Unexpected error validating registration checkout session {SessionId}", sessionId); return false; } } /// /// Finalizes a registration checkout after payment is confirmed. Called from the /// registration success redirect (not the webhook path). Retrieves the session from Stripe /// with the subscription expanded to access the real CurrentPeriodEnd, then updates /// the newly created company record with the Stripe customer ID, subscription ID, plan, and /// end date. Silently returns if the session is not yet paid or the company record is missing. /// public async Task FulfillRegistrationCheckoutAsync(string sessionId, int companyId, int plan) { var sessionService = new SessionService(); var session = await sessionService.GetAsync(sessionId, new SessionGetOptions { Expand = new List { "subscription" } }); if (session.PaymentStatus != "paid" && session.Status != "complete") { _logger.LogWarning("FulfillRegistrationCheckoutAsync: session {SessionId} not paid/complete", sessionId); return; } var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true); if (company == null) return; var subscription = session.Subscription; var periodEnd = subscription?.Items?.Data?.FirstOrDefault()?.CurrentPeriodEnd ?? DateTime.UtcNow.AddMonths(1); company.SubscriptionPlan = plan; company.SubscriptionStatus = SubscriptionStatus.Active; company.StripeCustomerId = session.CustomerId; company.StripeSubscriptionId = session.SubscriptionId; company.SubscriptionStartDate = DateTime.UtcNow; company.SubscriptionEndDate = periodEnd; await _unitOfWork.Companies.UpdateAsync(company); await _unitOfWork.CompleteAsync(); _logger.LogInformation( "Registration checkout fulfilled for company {CompanyId}: plan={Plan}, ends={EndDate}", companyId, plan, periodEnd); } /// /// Fulfills an existing-tenant plan-upgrade checkout after Stripe confirms payment. /// This method is the shared fulfillment path used by both the success-redirect controller /// action and the checkout.session.completed webhook handler — keeping both paths /// identical prevents divergence if only one fires (e.g. user closes browser before redirect). /// The subscription is expanded in the Stripe API call so the real CurrentPeriodEnd /// is available without a second round-trip. The company ID and target plan are read from /// session metadata set by . /// public async Task FulfillCheckoutAsync(string sessionId) { // Retrieve the session and expand the subscription so we get the real period end date var sessionService = new SessionService(); var session = await sessionService.GetAsync(sessionId, new SessionGetOptions { Expand = new List { "subscription" } }); if (session.PaymentStatus != "paid" && session.Status != "complete") { _logger.LogWarning("FulfillCheckoutAsync called for unpaid/incomplete session {SessionId}", sessionId); return; } if (!session.Metadata.TryGetValue("companyId", out var companyIdStr) || !int.TryParse(companyIdStr, out var companyId)) { _logger.LogWarning("FulfillCheckoutAsync: missing companyId metadata on session {SessionId}", sessionId); return; } var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true); if (company == null) return; // Parse the plan from metadata var plan = company.SubscriptionPlan; if (session.Metadata.TryGetValue("plan", out var planStr) && int.TryParse(planStr, out var parsedPlan)) plan = parsedPlan; // Get the real subscription period end from the expanded subscription var subscription = session.Subscription; var periodEnd = subscription?.Items?.Data?.FirstOrDefault()?.CurrentPeriodEnd ?? DateTime.UtcNow.AddMonths(1); company.SubscriptionPlan = plan; company.SubscriptionStatus = SubscriptionStatus.Active; company.StripeCustomerId = session.CustomerId; company.StripeSubscriptionId = session.SubscriptionId; company.SubscriptionStartDate = DateTime.UtcNow; company.SubscriptionEndDate = periodEnd; await _unitOfWork.Companies.UpdateAsync(company); await _unitOfWork.CompleteAsync(); _logger.LogInformation( "Company {CompanyId} subscription fulfilled: plan={Plan}, ends={EndDate}", companyId, plan, periodEnd); } /// /// Pulls the current subscription state from Stripe and overwrites the local company record /// with the authoritative Stripe values (status, plan, period end). Used by SuperAdmins via /// the Platform Management UI when the local record is believed to be stale. Matches the /// Stripe price ID back to a SubscriptionPlanConfig row so the SubscriptionPlan /// int is kept in sync even if the price ID was rotated. Logs a warning (but does not throw) /// when no plan config matches the active price ID. /// public async Task SyncSubscriptionAsync(int companyId) { var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true) ?? throw new InvalidOperationException($"Company {companyId} not found"); if (string.IsNullOrEmpty(company.StripeSubscriptionId)) throw new InvalidOperationException("No Stripe subscription ID on record. Complete a checkout first."); var subscriptionService = new Stripe.SubscriptionService(); var subscription = await subscriptionService.GetAsync(company.StripeSubscriptionId, new Stripe.SubscriptionGetOptions { Expand = new List { "items.data.price" } }); // Map Stripe status to our enum company.SubscriptionStatus = subscription.Status switch { "active" => SubscriptionStatus.Active, "past_due" => SubscriptionStatus.GracePeriod, "canceled" => SubscriptionStatus.Canceled, "unpaid" => SubscriptionStatus.GracePeriod, _ => SubscriptionStatus.Active }; company.SubscriptionEndDate = subscription.Items?.Data?.FirstOrDefault()?.CurrentPeriodEnd ?? DateTime.UtcNow.AddMonths(1); // Match the Stripe price ID back to one of our plan configs var stripePriceId = subscription.Items?.Data?.FirstOrDefault()?.Price?.Id; if (!string.IsNullOrEmpty(stripePriceId)) { var planConfigs = await _unitOfWork.SubscriptionPlanConfigs.FindAsync( c => c.IsActive, ignoreQueryFilters: true); var matchedConfig = planConfigs.FirstOrDefault(c => c.StripePriceIdMonthly == stripePriceId || c.StripePriceIdAnnual == stripePriceId); if (matchedConfig != null) company.SubscriptionPlan = matchedConfig.Plan; // int else _logger.LogWarning( "SyncSubscriptionAsync: price {PriceId} did not match any plan config for company {CompanyId}", stripePriceId, companyId); } await _unitOfWork.Companies.UpdateAsync(company); await _unitOfWork.CompleteAsync(); _logger.LogInformation( "Company {CompanyId} synced from Stripe: plan={Plan}, status={Status}, ends={EndDate}", companyId, company.SubscriptionPlan, company.SubscriptionStatus, company.SubscriptionEndDate); } /// /// Creates a Stripe Billing Portal session that lets a tenant self-manage their subscription /// (update payment method, view invoices, cancel) without surfacing Stripe credentials to the /// tenant. Returns the portal URL to which the controller should redirect the user. The portal /// session is short-lived (minutes) and bound to the Stripe customer ID; the /// is where Stripe sends the user when they close the portal. /// public async Task CreateCustomerPortalSessionAsync(string stripeCustomerId, string returnUrl) { var service = new Stripe.BillingPortal.SessionService(); var session = await service.CreateAsync(new Stripe.BillingPortal.SessionCreateOptions { Customer = stripeCustomerId, ReturnUrl = returnUrl }); return session.Url; } /// /// Verifies and dispatches an inbound Stripe webhook event. The HMAC signature in /// is validated against _webhookSecret before any /// processing; a is re-thrown so the caller (StripeController) /// can return HTTP 400, which tells Stripe the event was rejected and it should retry. /// Four event types are handled: checkout.session.completed, /// customer.subscription.updated, customer.subscription.deleted, and /// invoice.payment_failed. Unknown event types are logged and ignored, as Stripe /// delivers many event types that are irrelevant to this platform. /// public async Task HandleWebhookAsync(string json, string stripeSignature) { Event stripeEvent; try { stripeEvent = EventUtility.ConstructEvent(json, stripeSignature, _webhookSecret); } catch (StripeException ex) { _logger.LogError(ex, "Stripe webhook signature verification failed"); throw; } _logger.LogInformation("Processing Stripe webhook event: {EventType}", stripeEvent.Type); switch (stripeEvent.Type) { case EventTypes.CheckoutSessionCompleted: await HandleCheckoutSessionCompletedAsync(stripeEvent); break; case EventTypes.CustomerSubscriptionUpdated: await HandleSubscriptionUpdatedAsync(stripeEvent); break; case EventTypes.CustomerSubscriptionDeleted: await HandleSubscriptionDeletedAsync(stripeEvent); break; case EventTypes.InvoicePaymentFailed: await HandleInvoicePaymentFailedAsync(stripeEvent); break; default: _logger.LogInformation("Unhandled Stripe event type: {EventType}", stripeEvent.Type); break; } } /// /// Handles the checkout.session.completed webhook by delegating to /// . Using the same fulfillment method as the /// success-redirect path ensures webhook delivery and browser redirect are idempotent — /// the second call is a no-op because Stripe subscription IDs do not change between calls. /// private async Task HandleCheckoutSessionCompletedAsync(Event stripeEvent) { var session = stripeEvent.Data.Object as Session; if (session == null) return; // Reuse FulfillCheckoutAsync so the webhook path has identical logic to the success redirect await FulfillCheckoutAsync(session.Id); } /// /// Handles the customer.subscription.updated webhook, which fires on any subscription /// change (plan switch, renewal, trial ending, status change). Looks up the company first by /// subscription ID, then falls back to customer ID in case the subscription ID was not yet /// persisted (e.g. during initial provisioning race conditions). Updates SubscriptionStatus /// and SubscriptionEndDate from the live Stripe subscription object. /// /// When a customer cancels via the billing portal, Stripe keeps the subscription in /// "active" status but sets cancel_at_period_end = true. Without this check the /// handler would leave the company as Active. We treat cancel_at_period_end as /// Canceled immediately so the status reflects the customer's intent, while /// IsActive and SubscriptionEndDate are left unchanged so they retain access /// until the period actually ends (at which point customer.subscription.deleted fires /// and deactivates the company). /// private async Task HandleSubscriptionUpdatedAsync(Event stripeEvent) { var subscription = stripeEvent.Data.Object as Subscription; if (subscription == null) return; var company = await _unitOfWork.Companies.FirstOrDefaultAsync( c => c.StripeSubscriptionId == subscription.Id, ignoreQueryFilters: true); if (company == null) { company = await _unitOfWork.Companies.FirstOrDefaultAsync( c => c.StripeCustomerId == subscription.CustomerId, ignoreQueryFilters: true); } if (company == null) return; company.StripeSubscriptionId = subscription.Id; company.SubscriptionEndDate = subscription.Items?.Data?.FirstOrDefault()?.CurrentPeriodEnd ?? DateTime.UtcNow.AddMonths(1); // cancel_at_period_end means the customer cancelled via the portal — reflect that // immediately even though Stripe still reports status "active" until the period ends. if (subscription.CancelAtPeriodEnd) { company.SubscriptionStatus = SubscriptionStatus.Canceled; } else { company.SubscriptionStatus = subscription.Status switch { "active" => SubscriptionStatus.Active, "past_due" => SubscriptionStatus.GracePeriod, "canceled" => SubscriptionStatus.Canceled, "unpaid" => SubscriptionStatus.GracePeriod, _ => SubscriptionStatus.Active }; } await _unitOfWork.Companies.UpdateAsync(company); await _unitOfWork.CompleteAsync(); _logger.LogInformation("Company {CompanyId} subscription updated: Stripe status={StripeStatus}, CancelAtPeriodEnd={CancelAtPeriodEnd}, LocalStatus={LocalStatus}", company.Id, subscription.Status, subscription.CancelAtPeriodEnd, company.SubscriptionStatus); } /// /// Handles the customer.subscription.deleted webhook, which fires when a subscription /// is fully ended in Stripe — either because the cancel-at-period-end date passed or because /// it was immediately cancelled. Sets SubscriptionStatus to /// and IsActive = false to block access, then /// fires a SuperAdmin in-app notification so the team can follow up with the churned tenant. /// The notification is fire-and-forget (discarded task) because a failure there should not roll /// back the deactivation. /// private async Task HandleSubscriptionDeletedAsync(Event stripeEvent) { var subscription = stripeEvent.Data.Object as Subscription; if (subscription == null) return; var company = await _unitOfWork.Companies.FirstOrDefaultAsync( c => c.StripeSubscriptionId == subscription.Id, ignoreQueryFilters: true); if (company == null) return; company.SubscriptionStatus = SubscriptionStatus.Canceled; company.IsActive = false; await _unitOfWork.Companies.UpdateAsync(company); await _unitOfWork.CompleteAsync(); _logger.LogInformation("Company {CompanyId} subscription deleted — deactivated", company.Id); _ = _inApp.CreateForSuperAdminsAsync( "Subscription Canceled", $"{company.CompanyName} subscription was canceled via Stripe.", "SubscriptionCanceled", $"/Companies/Details/{company.Id}"); } /// /// Handles the invoice.payment_failed webhook, which fires when Stripe cannot charge /// the tenant's saved payment method during renewal. Moves the company to /// rather than immediately canceling, giving the /// tenant time to update their payment method via the Stripe Billing Portal. A SuperAdmin /// in-app notification is created as a fire-and-forget task so the team can proactively /// reach out to the at-risk tenant. /// private async Task HandleInvoicePaymentFailedAsync(Event stripeEvent) { var invoice = stripeEvent.Data.Object as Invoice; if (invoice == null || string.IsNullOrEmpty(invoice.CustomerId)) return; var company = await _unitOfWork.Companies.FirstOrDefaultAsync( c => c.StripeCustomerId == invoice.CustomerId, ignoreQueryFilters: true); if (company == null) return; company.SubscriptionStatus = SubscriptionStatus.GracePeriod; await _unitOfWork.Companies.UpdateAsync(company); await _unitOfWork.CompleteAsync(); _logger.LogWarning("Company {CompanyId} payment failed — set to grace period", company.Id); _ = _inApp.CreateForSuperAdminsAsync( "Subscription Payment Failed", $"{company.CompanyName} subscription payment failed — moved to grace period.", "PaymentFailed", $"/Companies/Details/{company.Id}"); } }