223 lines
9.7 KiB
C#
223 lines
9.7 KiB
C#
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(
|
|
IPlatformControlPlanePersistence directoryPersistence,
|
|
ITenantContext tenantContext,
|
|
ITenantExecutionScope tenantExecutionScope,
|
|
IOptions<SaasSubscriptionLifecycleOptions> options) : ISaasSubscriptionLifecycleService
|
|
{
|
|
private readonly SaasSubscriptionLifecycleOptions options = options.Value;
|
|
|
|
public async Task<int> 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 directoryPersistence.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<bool> ProcessTenantAsync(
|
|
IServiceProvider services,
|
|
Guid tenantId,
|
|
DateTimeOffset asOf,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var dbContext = services.GetRequiredService<IPlatformControlPlanePersistence>();
|
|
var tenancyPersistence = services.GetRequiredService<ITenancyPersistence>();
|
|
var jobsOperationsPersistence = services.GetRequiredService<IJobsOperationsPersistence>();
|
|
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 tenancyPersistence.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++;
|
|
jobsOperationsPersistence.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<ITenantFeatureCacheInvalidator>()
|
|
.InvalidateAsync(tenantId, cancellationToken);
|
|
|
|
return true;
|
|
}
|
|
|
|
private static void EndItems(
|
|
IEnumerable<TenantSaasSubscriptionItem> 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;
|
|
}
|
|
}
|
|
}
|