using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Tiku.Application.PlatformBilling; using Tiku.Application.Security; using Tiku.Domain.Operations; using Tiku.Domain.Platform; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.PlatformBilling; internal sealed class SaasSubscriptionLifecycleService( TikuDbContext directoryDbContext, ITenantContext tenantContext, ITenantExecutionScope tenantExecutionScope, IOptions options) : ISaasSubscriptionLifecycleService { private readonly SaasSubscriptionLifecycleOptions options = options.Value; public async Task ProcessDueAsync( DateTimeOffset? asOf = null, CancellationToken cancellationToken = default) { if (!options.Enabled) return 0; if (!tenantContext.IsSystem || tenantContext.TenantId.HasValue) throw new InvalidOperationException( "SaaS subscription lifecycle discovery requires a global system context."); var effectiveAt = asOf ?? DateTimeOffset.UtcNow; var tenantIds = await directoryDbContext.TenantSaasSubscriptions.AsNoTracking() .Where(subscription => subscription.CurrentPeriodEnd <= effectiveAt && (subscription.Status == TenantSaasSubscriptionStatus.Trial || subscription.Status == TenantSaasSubscriptionStatus.Active || subscription.Status == TenantSaasSubscriptionStatus.PastDue)) .OrderBy(subscription => subscription.CurrentPeriodEnd) .ThenBy(subscription => subscription.TenantId) .Select(subscription => subscription.TenantId) .Distinct() .Take(Math.Clamp(options.BatchSize, 1, 1000)) .ToArrayAsync(cancellationToken); var changed = 0; foreach (var tenantId in tenantIds) { var wasChanged = await tenantExecutionScope.ExecuteAsync( new SystemScopeRequest( tenantId, SystemScopeCallerType.Worker, nameof(SaasSubscriptionLifecycleService), "Apply due SaaS subscription lifecycle transition", $"saas-subscription-lifecycle-{tenantId:N}-{effectiveAt:yyyyMMddHHmmss}"), (provider, token) => ProcessTenantAsync(provider, tenantId, effectiveAt, token), cancellationToken); if (wasChanged) changed++; } return changed; } private async Task ProcessTenantAsync( IServiceProvider services, Guid tenantId, DateTimeOffset asOf, CancellationToken cancellationToken) { var dbContext = services.GetRequiredService(); var subscription = await dbContext.TenantSaasSubscriptions .OrderByDescending(value => value.UpdatedAt) .FirstOrDefaultAsync(value => value.TenantId == tenantId && value.CurrentPeriodEnd <= asOf && (value.Status == TenantSaasSubscriptionStatus.Trial || value.Status == TenantSaasSubscriptionStatus.Active || value.Status == TenantSaasSubscriptionStatus.PastDue), cancellationToken); if (subscription is null) return false; var previousStatus = subscription.Status; var previousBaseVersionId = subscription.BaseOfferingVersionId; var items = await dbContext.TenantSaasSubscriptionItems .Where(value => value.TenantId == tenantId && value.SubscriptionId == subscription.Id) .ToArrayAsync(cancellationToken); var tenant = await dbContext.Tenants.SingleAsync(value => value.Id == tenantId, cancellationToken); string transition; if (subscription.CancelAtPeriodEnd) { transition = "cancelled_at_period_end"; subscription.Status = TenantSaasSubscriptionStatus.Cancelled; subscription.CancelledAt ??= asOf; subscription.ScheduledBaseOfferingVersionId = null; tenant.BillingStatus = BillingStatus.Cancelled; EndItems(items, subscription.CurrentPeriodEnd, true); } else if (subscription.ScheduledBaseOfferingVersionId is { } scheduledVersionId) { var scheduledBase = items.SingleOrDefault(value => value.ItemType == TenantSaasSubscriptionItemType.BasePlan && value.Status == TenantSaasSubscriptionItemStatus.Scheduled && value.OfferingVersionId == scheduledVersionId); if (scheduledBase is null) { var current = await dbContext.TenantSaasSubscriptions.AsNoTracking() .SingleAsync(value => value.TenantId == tenantId && value.Id == subscription.Id, cancellationToken); if (current.ScheduledBaseOfferingVersionId is null && current.BaseOfferingVersionId == scheduledVersionId && current.LifecycleVersion != subscription.LifecycleVersion) { dbContext.ChangeTracker.Clear(); return false; } throw new InvalidOperationException( $"Scheduled base offering version '{scheduledVersionId}' has no matching subscription item."); } transition = "scheduled_plan_activated"; var nextPeriodStart = subscription.CurrentPeriodEnd; EndItems(items.Where(value => value.Status == TenantSaasSubscriptionItemStatus.Active), nextPeriodStart, false); var scheduledItems = items.Where(value => value.Status == TenantSaasSubscriptionItemStatus.Scheduled) .ToArray(); foreach (var item in scheduledItems) { item.Status = TenantSaasSubscriptionItemStatus.Active; item.StartsAt = nextPeriodStart; } subscription.BaseOfferingVersionId = scheduledVersionId; subscription.ScheduledBaseOfferingVersionId = null; subscription.Status = TenantSaasSubscriptionStatus.Active; subscription.CurrentPeriodStart = nextPeriodStart; subscription.CurrentPeriodEnd = scheduledBase.EndsAt; subscription.CancelAtPeriodEnd = false; subscription.CancelledAt = null; tenant.BillingStatus = BillingStatus.Active; } else if (subscription.Status == TenantSaasSubscriptionStatus.Trial) { transition = "trial_expired"; subscription.Status = TenantSaasSubscriptionStatus.Expired; subscription.CancelledAt ??= asOf; tenant.BillingStatus = BillingStatus.Cancelled; EndItems(items, subscription.CurrentPeriodEnd, true); } else if (subscription.Status == TenantSaasSubscriptionStatus.Active) { transition = "payment_past_due"; subscription.Status = TenantSaasSubscriptionStatus.PastDue; tenant.BillingStatus = BillingStatus.PastDue; } else if (subscription.Status == TenantSaasSubscriptionStatus.PastDue && asOf >= subscription.CurrentPeriodEnd.AddDays(Math.Clamp(options.PastDueGraceDays, 0, 365))) { transition = "past_due_expired"; subscription.Status = TenantSaasSubscriptionStatus.Expired; subscription.CancelledAt ??= asOf; tenant.BillingStatus = BillingStatus.Cancelled; EndItems(items, subscription.CurrentPeriodEnd, true); } else { return false; } subscription.LifecycleVersion++; dbContext.AuditLogs.Add(new AuditLog { TenantId = tenantId, Action = $"platform_billing.subscription.{transition}", TargetType = "tenant_saas_subscriptions", TargetId = subscription.Id.ToString(), Details = JsonSerializer.SerializeToElement(new { previousStatus, subscription.Status, previousBaseVersionId, subscription.BaseOfferingVersionId, subscription.CurrentPeriodStart, subscription.CurrentPeriodEnd, subscription.LifecycleVersion }) }); try { await dbContext.SaveChangesAsync(cancellationToken); } catch (DbUpdateConcurrencyException) { dbContext.ChangeTracker.Clear(); return false; } await services.GetRequiredService() .InvalidateAsync(tenantId, cancellationToken); return true; } private static void EndItems( IEnumerable items, DateTimeOffset periodEnd, bool cancelScheduled) { foreach (var item in items) if (item.Status == TenantSaasSubscriptionItemStatus.Active) { item.Status = TenantSaasSubscriptionItemStatus.Expired; if (periodEnd > item.StartsAt && item.EndsAt > periodEnd) item.EndsAt = periodEnd; } else if (cancelScheduled && item.Status == TenantSaasSubscriptionItemStatus.Scheduled) { item.Status = TenantSaasSubscriptionItemStatus.Cancelled; } } }