forked from xiongyuxing/tiku-backend.net
feat(saas): implement marketplace and tenant onboarding
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user