Initial commit
This commit is contained in:
@@ -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}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user