612 lines
28 KiB
C#
612 lines
28 KiB
C#
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>
|
|
/// Verifies that the supplied Stripe Checkout session belongs to the registration flow and has
|
|
/// reached the paid/complete state. Returns <c>false</c> for any missing/invalid/unpaid session
|
|
/// so the caller can safely stop before creating any local company or user records.
|
|
/// </summary>
|
|
public async Task<bool> 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;
|
|
}
|
|
}
|
|
|
|
/// <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}");
|
|
}
|
|
}
|