forked from xiongyuxing/tiku-backend.net
277 lines
12 KiB
C#
277 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;
|
|
}
|
|
|
|
await services.GetRequiredService<ITenantFeatureCacheInvalidator>()
|
|
.InvalidateAsync(tenantId, cancellationToken);
|
|
|
|
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);
|
|
}
|
|
await services.GetRequiredService<ITenantRuntimeCacheInvalidator>()
|
|
.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;
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|