Files
tiku-backend.net/Tiku.Infrastructure/PlatformBilling/SaasSubscriptionLifecycleService.cs

273 lines
12 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.Application.Tenancy;
using Tiku.Domain.Operations;
using Tiku.Domain.Platform;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Messaging;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.PlatformBilling;
internal sealed class SaasSubscriptionLifecycleService(
TikuDbContext directoryDbContext,
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 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<bool> ProcessTenantAsync(
IServiceProvider services,
Guid tenantId,
DateTimeOffset asOf,
CancellationToken cancellationToken)
{
var dbContext = services.GetRequiredService<TikuDbContext>();
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 oldFeatures = await LoadFeaturesAsync(dbContext, subscription, cancellationToken);
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, cancelScheduled: 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, cancelScheduled: 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, cancelScheduled: 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, cancelScheduled: 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;
}
var newFeatures = await LoadFeaturesAsync(dbContext, subscription, cancellationToken);
var changedFeatures = oldFeatures.Concat(newFeatures).Distinct(StringComparer.Ordinal).ToArray();
var eventPublisher = services.GetRequiredService<ISecurityEventPublisher>();
var eventVersion = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var correlationId = $"saas-subscription-{subscription.Id:N}-lifecycle-{subscription.LifecycleVersion}";
foreach (var featureCode in changedFeatures)
{
await eventPublisher.CapabilityChangedAsync(
tenantId,
featureCode,
transition,
eventVersion,
correlationId,
cancellationToken);
}
services.GetRequiredService<ITenantRuntimeCacheInvalidator>().Invalidate(tenantId);
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;
}
}
}
private static async Task<string[]> LoadFeaturesAsync(
TikuDbContext dbContext,
TenantSaasSubscription subscription,
CancellationToken cancellationToken)
{
var versionIds = await dbContext.TenantSaasSubscriptionItems.AsNoTracking()
.Where(value =>
value.TenantId == subscription.TenantId &&
value.SubscriptionId == subscription.Id &&
value.Status == TenantSaasSubscriptionItemStatus.Active)
.Select(value => value.OfferingVersionId)
.ToArrayAsync(cancellationToken);
if (!versionIds.Contains(subscription.BaseOfferingVersionId))
{
versionIds = [.. versionIds, subscription.BaseOfferingVersionId];
}
return await dbContext.SaasOfferingVersionFeatures.AsNoTracking()
.Where(value => versionIds.Contains(value.OfferingVersionId))
.Select(value => value.FeatureCode)
.Distinct()
.ToArrayAsync(cancellationToken);
}
}