Initial commit

This commit is contained in:
2026-04-23 21:38:24 -04:00
commit 63e12a9636
1762 changed files with 1672620 additions and 0 deletions
@@ -0,0 +1,568 @@
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;
/// <summary>
/// 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.
/// </summary>
public class StripeService : IStripeService
{
private readonly IUnitOfWork _unitOfWork;
private readonly IConfiguration _configuration;
private readonly IInAppNotificationService _inApp;
private readonly ILogger<StripeService> _logger;
private readonly string _webhookSecret;
/// <summary>
/// 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 <see cref="HandleWebhookAsync"/> 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.
/// </summary>
public StripeService(
IUnitOfWork unitOfWork,
IConfiguration configuration,
IInAppNotificationService inApp,
ILogger<StripeService> logger)
{
_unitOfWork = unitOfWork;
_configuration = configuration;
_inApp = inApp;
_logger = logger;
StripeConfiguration.ApiKey = configuration["Stripe:SecretKey"] ?? string.Empty;
_webhookSecret = configuration["Stripe:WebhookSecret"] ?? string.Empty;
}
/// <summary>
/// Creates a Stripe Checkout session for an existing tenant company that is upgrading
/// or changing its subscription plan. Persists the chosen billing period preference
/// (<paramref name="isAnnual"/>) before creating the session so renewals default to the same
/// cadence. Creates a Stripe Customer record on first checkout and stores the resulting
/// <c>StripeCustomerId</c> on the company. The price ID is resolved from
/// <c>SubscriptionPlanConfig</c> in the database; the method validates that the stored ID
/// starts with <c>price_</c> (not <c>prod_</c>) and provides a human-readable error message
/// if misconfigured. Returns the Stripe-hosted checkout URL to which the caller should redirect.
/// </summary>
public async Task<string> 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<string, string>
{
["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<string> { "card" },
LineItems = new List<SessionLineItemOptions>
{
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<string, string>
{
["companyId"] = companyId.ToString(),
["plan"] = newPlan.ToString() // stored as int string
}
});
return session.Url;
}
/// <summary>
/// Creates a Stripe Checkout session for a new registrant who has not yet been issued a
/// company record in the database. Unlike <see cref="CreateCheckoutSessionAsync"/>, this
/// session is keyed by <paramref name="email"/> (via <c>CustomerEmail</c>) rather than a
/// stored <c>StripeCustomerId</c>, and carries a <c>registration=true</c> metadata flag so
/// the subsequent <see cref="FulfillRegistrationCheckoutAsync"/> call can distinguish
/// registration sessions from plan-upgrade sessions in webhook handlers.
/// Returns the Stripe-hosted checkout URL.
/// </summary>
public async Task<string> 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<string> { "card" },
LineItems = new List<SessionLineItemOptions>
{
new() { Price = priceId, Quantity = 1 }
},
SuccessUrl = (successUrl.Contains('?') ? successUrl + "&" : successUrl + "?") + "session_id={CHECKOUT_SESSION_ID}",
CancelUrl = cancelUrl,
Metadata = new Dictionary<string, string>
{
["registration"] = "true",
["plan"] = plan.ToString()
}
});
return session.Url;
}
/// <summary>
/// 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 <c>CurrentPeriodEnd</c>, 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.
/// </summary>
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<string> { "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);
}
/// <summary>
/// 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 <c>checkout.session.completed</c> 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 <c>CurrentPeriodEnd</c>
/// is available without a second round-trip. The company ID and target plan are read from
/// session metadata set by <see cref="CreateCheckoutSessionAsync"/>.
/// </summary>
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<string> { "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);
}
/// <summary>
/// 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 <c>SubscriptionPlanConfig</c> row so the <c>SubscriptionPlan</c>
/// 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.
/// </summary>
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<string> { "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);
}
/// <summary>
/// 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
/// <paramref name="returnUrl"/> is where Stripe sends the user when they close the portal.
/// </summary>
public async Task<string> 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;
}
/// <summary>
/// Verifies and dispatches an inbound Stripe webhook event. The HMAC signature in
/// <paramref name="stripeSignature"/> is validated against <c>_webhookSecret</c> before any
/// processing; a <see cref="StripeException"/> 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: <c>checkout.session.completed</c>,
/// <c>customer.subscription.updated</c>, <c>customer.subscription.deleted</c>, and
/// <c>invoice.payment_failed</c>. Unknown event types are logged and ignored, as Stripe
/// delivers many event types that are irrelevant to this platform.
/// </summary>
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;
}
}
/// <summary>
/// Handles the <c>checkout.session.completed</c> webhook by delegating to
/// <see cref="FulfillCheckoutAsync"/>. 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.
/// </summary>
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);
}
/// <summary>
/// Handles the <c>customer.subscription.updated</c> 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 <c>SubscriptionStatus</c>
/// and <c>SubscriptionEndDate</c> from the live Stripe subscription object.
///
/// When a customer cancels via the billing portal, Stripe keeps the subscription in
/// <c>"active"</c> status but sets <c>cancel_at_period_end = true</c>. Without this check the
/// handler would leave the company as Active. We treat <c>cancel_at_period_end</c> as
/// <c>Canceled</c> immediately so the status reflects the customer's intent, while
/// <c>IsActive</c> and <c>SubscriptionEndDate</c> are left unchanged so they retain access
/// until the period actually ends (at which point <c>customer.subscription.deleted</c> fires
/// and <see cref="HandleSubscriptionDeletedAsync"/> deactivates the company).
/// </summary>
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);
}
/// <summary>
/// Handles the <c>customer.subscription.deleted</c> 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 <c>SubscriptionStatus</c> to
/// <see cref="SubscriptionStatus.Canceled"/> and <c>IsActive = false</c> 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.
/// </summary>
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}");
}
/// <summary>
/// Handles the <c>invoice.payment_failed</c> webhook, which fires when Stripe cannot charge
/// the tenant's saved payment method during renewal. Moves the company to
/// <see cref="SubscriptionStatus.GracePeriod"/> 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.
/// </summary>
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}");
}
}