forked from gongxuegit/tiku-backend.net
feat(saas): implement marketplace and tenant onboarding
This commit is contained in:
350
Tiku.Infrastructure/PlatformBilling/SaasCatalogAdminService.cs
Normal file
350
Tiku.Infrastructure/PlatformBilling/SaasCatalogAdminService.cs
Normal file
@@ -0,0 +1,350 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Backoffice;
|
||||
using Tiku.Application.PlatformBilling;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.PlatformBilling;
|
||||
|
||||
internal sealed class SaasCatalogAdminService(
|
||||
TikuDbContext dbContext,
|
||||
IOperationAuditService auditService) : ISaasCatalogAdminService
|
||||
{
|
||||
public async Task<SaasCatalogSnapshot> GetCatalogAsync(
|
||||
SaasCatalogActor actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var features = await dbContext.SaasFeatures.AsNoTracking()
|
||||
.OrderBy(value => value.SortOrder).ThenBy(value => value.Code)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var limits = await dbContext.SaasFeatureLimitDefinitions.AsNoTracking()
|
||||
.OrderBy(value => value.FeatureCode).ThenBy(value => value.MetricCode)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var offerings = await dbContext.SaasOfferings.AsNoTracking()
|
||||
.OrderBy(value => value.SortOrder).ThenBy(value => value.Code)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var versions = await LoadVersionsAsync(null, cancellationToken);
|
||||
return new SaasCatalogSnapshot(features, limits, offerings, versions);
|
||||
}
|
||||
|
||||
public async Task<SaasFeatureLimitDefinition> UpsertLimitDefinitionAsync(
|
||||
SaasCatalogActor actor,
|
||||
UpsertSaasFeatureLimitCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var metricCode = Normalize(command.MetricCode);
|
||||
var featureCode = Normalize(command.FeatureCode);
|
||||
if (command.WarningPercent != 80 || !command.IsHardLimit)
|
||||
{
|
||||
throw Error("Phase nine supports hard limits with an 80 percent warning threshold.", "saas_feature_limit_policy_invalid");
|
||||
}
|
||||
if (!await dbContext.SaasFeatures.AnyAsync(value =>
|
||||
value.Code == featureCode && value.Status == SaasFeatureStatus.Active, cancellationToken))
|
||||
{
|
||||
throw Error("SaaS feature was not found or active.", "saas_feature_not_found");
|
||||
}
|
||||
|
||||
var item = command.Id.HasValue
|
||||
? await dbContext.SaasFeatureLimitDefinitions.SingleOrDefaultAsync(value => value.Id == command.Id, cancellationToken)
|
||||
: await dbContext.SaasFeatureLimitDefinitions.SingleOrDefaultAsync(value => value.MetricCode == metricCode, cancellationToken);
|
||||
if (item is null)
|
||||
{
|
||||
item = new SaasFeatureLimitDefinition { MetricCode = metricCode };
|
||||
dbContext.SaasFeatureLimitDefinitions.Add(item);
|
||||
}
|
||||
else if (await dbContext.SaasOfferingVersionLimits.AnyAsync(value => value.MetricCode == item.MetricCode, cancellationToken) &&
|
||||
(!string.Equals(item.MetricCode, metricCode, StringComparison.Ordinal) ||
|
||||
!string.Equals(item.FeatureCode, featureCode, StringComparison.Ordinal) ||
|
||||
item.Kind != command.Kind))
|
||||
{
|
||||
throw Error("A limit definition referenced by offering versions cannot change identity or kind.", "saas_feature_limit_locked");
|
||||
}
|
||||
|
||||
item.MetricCode = metricCode;
|
||||
item.FeatureCode = featureCode;
|
||||
item.Name = Required(command.Name, "name");
|
||||
item.Unit = Required(command.Unit, "unit");
|
||||
item.Kind = command.Kind;
|
||||
item.WarningPercent = command.WarningPercent;
|
||||
item.IsHardLimit = command.IsHardLimit;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await AuditAsync(actor, "platform.saas.feature_limit.upserted", "saas_feature_limit_definitions", item.Id,
|
||||
new { item.MetricCode, item.FeatureCode, item.Kind }, cancellationToken);
|
||||
return item;
|
||||
}
|
||||
|
||||
public async Task<SaasFeature> UpsertFeatureAsync(
|
||||
SaasCatalogActor actor,
|
||||
UpsertSaasFeatureCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var code = Normalize(command.Code);
|
||||
if (!SaasFeatureCatalog.All.Contains(code))
|
||||
{
|
||||
throw Error("Feature code is not part of the application catalog.", "saas_feature_code_unknown");
|
||||
}
|
||||
if (command.ReferencePriceCents < 0)
|
||||
{
|
||||
throw Error("Reference price cannot be negative.", "saas_feature_price_invalid");
|
||||
}
|
||||
|
||||
var item = command.Id.HasValue
|
||||
? await dbContext.SaasFeatures.SingleOrDefaultAsync(value => value.Id == command.Id, cancellationToken)
|
||||
: await dbContext.SaasFeatures.SingleOrDefaultAsync(value => value.Code == code, cancellationToken);
|
||||
if (item is null)
|
||||
{
|
||||
item = new SaasFeature { Code = code };
|
||||
dbContext.SaasFeatures.Add(item);
|
||||
}
|
||||
if (item.IsCore)
|
||||
{
|
||||
throw Error("Core features cannot be changed through the product catalog.", "saas_core_feature_locked");
|
||||
}
|
||||
|
||||
item.Code = code;
|
||||
item.Name = Required(command.Name, "name");
|
||||
item.Category = Required(command.Category, "category");
|
||||
item.Description = Clean(command.Description);
|
||||
item.ReferencePriceCents = command.ReferencePriceCents;
|
||||
item.Currency = NormalizeCurrency(command.Currency);
|
||||
item.Status = command.Status;
|
||||
item.SortOrder = command.SortOrder;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await AuditAsync(actor, "platform.saas.feature.upserted", "saas_features", item.Id, new { item.Code, item.Status }, cancellationToken);
|
||||
return item;
|
||||
}
|
||||
|
||||
public async Task<SaasOffering> UpsertOfferingAsync(
|
||||
SaasCatalogActor actor,
|
||||
UpsertSaasOfferingCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var code = Normalize(command.Code);
|
||||
var item = command.Id.HasValue
|
||||
? await dbContext.SaasOfferings.SingleOrDefaultAsync(value => value.Id == command.Id, cancellationToken)
|
||||
: await dbContext.SaasOfferings.SingleOrDefaultAsync(value => value.Code == code, cancellationToken);
|
||||
if (item is null)
|
||||
{
|
||||
item = new SaasOffering { Code = code };
|
||||
dbContext.SaasOfferings.Add(item);
|
||||
}
|
||||
else if (item.Type != command.Type && await dbContext.SaasOfferingVersions.AnyAsync(value => value.OfferingId == item.Id, cancellationToken))
|
||||
{
|
||||
throw Error("Offering type cannot change after versions exist.", "saas_offering_type_locked");
|
||||
}
|
||||
|
||||
item.Code = code;
|
||||
item.Name = Required(command.Name, "name");
|
||||
item.Type = command.Type;
|
||||
item.Status = command.Status;
|
||||
item.Description = Clean(command.Description);
|
||||
item.SortOrder = command.SortOrder;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await AuditAsync(actor, "platform.saas.offering.upserted", "saas_offerings", item.Id, new { item.Code, item.Type, item.Status }, cancellationToken);
|
||||
return item;
|
||||
}
|
||||
|
||||
public async Task<SaasOfferingVersionItem> UpsertDraftVersionAsync(
|
||||
SaasCatalogActor actor,
|
||||
UpsertSaasOfferingVersionCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var offering = await dbContext.SaasOfferings.SingleOrDefaultAsync(value => value.Id == command.OfferingId, cancellationToken)
|
||||
?? throw Error("SaaS offering was not found.", "saas_offering_not_found");
|
||||
if (command.AmountCents < 0 || command.OriginalAmountCents < command.AmountCents)
|
||||
{
|
||||
throw Error("Offering version price is invalid.", "saas_offering_price_invalid");
|
||||
}
|
||||
|
||||
var featureCodes = command.FeatureCodes.Select(Normalize).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray();
|
||||
if (offering.Type == SaasOfferingType.BasePlan && featureCodes.Length == 0)
|
||||
{
|
||||
throw Error("A base plan must contain at least one sellable feature.", "saas_base_plan_features_required");
|
||||
}
|
||||
var validFeatures = await dbContext.SaasFeatures.CountAsync(value =>
|
||||
featureCodes.Contains(value.Code) && value.Status == SaasFeatureStatus.Active && !value.IsCore, cancellationToken);
|
||||
if (validFeatures != featureCodes.Length)
|
||||
{
|
||||
throw Error("One or more features are unavailable.", "saas_feature_unavailable");
|
||||
}
|
||||
|
||||
var metricCodes = command.Limits.Keys.Select(Normalize).Distinct(StringComparer.Ordinal).ToArray();
|
||||
var limitDefinitions = await dbContext.SaasFeatureLimitDefinitions.AsNoTracking()
|
||||
.Where(value => metricCodes.Contains(value.MetricCode))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
if (command.Limits.Any(value => value.Value < 0) ||
|
||||
limitDefinitions.Length != metricCodes.Length ||
|
||||
limitDefinitions.Any(value => !featureCodes.Contains(value.FeatureCode)))
|
||||
{
|
||||
throw Error("One or more feature limits are invalid.", "saas_feature_limit_invalid");
|
||||
}
|
||||
|
||||
SaasOfferingVersion version;
|
||||
if (command.Id.HasValue)
|
||||
{
|
||||
version = await dbContext.SaasOfferingVersions.SingleOrDefaultAsync(value => value.Id == command.Id, cancellationToken)
|
||||
?? throw Error("Offering version was not found.", "saas_offering_version_not_found");
|
||||
if (version.Status != SaasOfferingVersionStatus.Draft)
|
||||
{
|
||||
throw Error("Published offering versions are immutable.", "saas_offering_version_immutable");
|
||||
}
|
||||
if (version.OfferingId != command.OfferingId)
|
||||
{
|
||||
throw Error("Offering version cannot move to another offering.", "saas_offering_version_offering_locked");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var nextVersion = (await dbContext.SaasOfferingVersions
|
||||
.Where(value => value.OfferingId == command.OfferingId)
|
||||
.MaxAsync(value => (int?)value.Version, cancellationToken) ?? 0) + 1;
|
||||
version = new SaasOfferingVersion { OfferingId = command.OfferingId, Version = nextVersion };
|
||||
dbContext.SaasOfferingVersions.Add(version);
|
||||
}
|
||||
|
||||
version.BillingCycle = command.BillingCycle;
|
||||
version.OriginalAmountCents = command.OriginalAmountCents;
|
||||
version.AmountCents = command.AmountCents;
|
||||
version.Currency = NormalizeCurrency(command.Currency);
|
||||
version.EffectiveAt = command.EffectiveAt;
|
||||
version.Metadata = ObjectOrEmpty(command.Metadata);
|
||||
|
||||
await dbContext.SaasOfferingVersionFeatures.Where(value => value.OfferingVersionId == version.Id).ExecuteDeleteAsync(cancellationToken);
|
||||
await dbContext.SaasOfferingVersionLimits.Where(value => value.OfferingVersionId == version.Id).ExecuteDeleteAsync(cancellationToken);
|
||||
dbContext.SaasOfferingVersionFeatures.AddRange(featureCodes.Select(code => new SaasOfferingVersionFeature
|
||||
{
|
||||
OfferingVersionId = version.Id,
|
||||
FeatureCode = code
|
||||
}));
|
||||
dbContext.SaasOfferingVersionLimits.AddRange(command.Limits.Select(value => new SaasOfferingVersionLimit
|
||||
{
|
||||
OfferingVersionId = version.Id,
|
||||
MetricCode = Normalize(value.Key),
|
||||
LimitValue = value.Value
|
||||
}));
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await AuditAsync(actor, "platform.saas.offering_version.saved", "saas_offering_versions", version.Id, new { offering.Code, version.Version }, cancellationToken);
|
||||
return (await LoadVersionsAsync(version.Id, cancellationToken)).Single();
|
||||
}
|
||||
|
||||
public async Task<SaasOfferingVersionItem> PublishVersionAsync(
|
||||
SaasCatalogActor actor,
|
||||
Guid versionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var version = await RequireDraftAsync(versionId, cancellationToken);
|
||||
var hasFeatures = await dbContext.SaasOfferingVersionFeatures.AnyAsync(value => value.OfferingVersionId == versionId, cancellationToken);
|
||||
if (!hasFeatures)
|
||||
{
|
||||
throw Error("Offering version has no features.", "saas_offering_version_empty");
|
||||
}
|
||||
|
||||
version.Status = SaasOfferingVersionStatus.Published;
|
||||
version.PublishedAt = DateTimeOffset.UtcNow;
|
||||
version.EffectiveAt ??= version.PublishedAt;
|
||||
var offering = await dbContext.SaasOfferings.SingleAsync(value => value.Id == version.OfferingId, cancellationToken);
|
||||
offering.Status = SaasOfferingStatus.Active;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await AuditAsync(actor, "platform.saas.offering_version.published", "saas_offering_versions", version.Id, new { version.Version }, cancellationToken);
|
||||
return (await LoadVersionsAsync(version.Id, cancellationToken)).Single();
|
||||
}
|
||||
|
||||
public async Task<SaasOfferingVersionItem> CloneVersionAsync(
|
||||
SaasCatalogActor actor,
|
||||
Guid versionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var source = await dbContext.SaasOfferingVersions.AsNoTracking().SingleOrDefaultAsync(value => value.Id == versionId, cancellationToken)
|
||||
?? throw Error("Offering version was not found.", "saas_offering_version_not_found");
|
||||
var features = await dbContext.SaasOfferingVersionFeatures.AsNoTracking().Where(value => value.OfferingVersionId == versionId).Select(value => value.FeatureCode).ToArrayAsync(cancellationToken);
|
||||
var limits = await dbContext.SaasOfferingVersionLimits.AsNoTracking().Where(value => value.OfferingVersionId == versionId).ToDictionaryAsync(value => value.MetricCode, value => value.LimitValue, cancellationToken);
|
||||
return await UpsertDraftVersionAsync(actor, new UpsertSaasOfferingVersionCommand(
|
||||
null,
|
||||
source.OfferingId,
|
||||
source.BillingCycle,
|
||||
source.OriginalAmountCents,
|
||||
source.AmountCents,
|
||||
source.Currency,
|
||||
null,
|
||||
features,
|
||||
limits,
|
||||
source.Metadata), cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<SaasOfferingVersionItem> RetireVersionAsync(
|
||||
SaasCatalogActor actor,
|
||||
Guid versionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var version = await dbContext.SaasOfferingVersions.SingleOrDefaultAsync(value => value.Id == versionId, cancellationToken)
|
||||
?? throw Error("Offering version was not found.", "saas_offering_version_not_found");
|
||||
if (version.Status != SaasOfferingVersionStatus.Published)
|
||||
{
|
||||
throw Error("Only a published offering version can be retired.", "saas_offering_version_status_invalid");
|
||||
}
|
||||
version.Status = SaasOfferingVersionStatus.Retired;
|
||||
version.RetiredAt = DateTimeOffset.UtcNow;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await AuditAsync(actor, "platform.saas.offering_version.retired", "saas_offering_versions", version.Id, new { version.Version }, cancellationToken);
|
||||
return (await LoadVersionsAsync(version.Id, cancellationToken)).Single();
|
||||
}
|
||||
|
||||
private async Task<SaasOfferingVersionItem[]> LoadVersionsAsync(Guid? versionId, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = from version in dbContext.SaasOfferingVersions.AsNoTracking()
|
||||
join offering in dbContext.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id
|
||||
select new { Version = version, Offering = offering };
|
||||
if (versionId.HasValue)
|
||||
{
|
||||
query = query.Where(value => value.Version.Id == versionId);
|
||||
}
|
||||
var rows = await query.OrderBy(value => value.Offering.SortOrder).ThenBy(value => value.Offering.Code).ThenByDescending(value => value.Version.Version).ToArrayAsync(cancellationToken);
|
||||
var ids = rows.Select(value => value.Version.Id).ToArray();
|
||||
var features = await dbContext.SaasOfferingVersionFeatures.AsNoTracking().Where(value => ids.Contains(value.OfferingVersionId)).ToArrayAsync(cancellationToken);
|
||||
var limits = await dbContext.SaasOfferingVersionLimits.AsNoTracking().Where(value => ids.Contains(value.OfferingVersionId)).ToArrayAsync(cancellationToken);
|
||||
return rows.Select(row => new SaasOfferingVersionItem(
|
||||
row.Version.Id,
|
||||
row.Offering.Id,
|
||||
row.Offering.Code,
|
||||
row.Offering.Name,
|
||||
row.Offering.Type,
|
||||
row.Version.Version,
|
||||
row.Version.Status,
|
||||
row.Version.BillingCycle,
|
||||
row.Version.OriginalAmountCents,
|
||||
row.Version.AmountCents,
|
||||
row.Version.Currency,
|
||||
row.Version.EffectiveAt,
|
||||
row.Version.PublishedAt,
|
||||
features.Where(value => value.OfferingVersionId == row.Version.Id).Select(value => value.FeatureCode).Order(StringComparer.Ordinal).ToArray(),
|
||||
limits.Where(value => value.OfferingVersionId == row.Version.Id).ToDictionary(value => value.MetricCode, value => value.LimitValue, StringComparer.Ordinal)))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private async Task<SaasOfferingVersion> RequireDraftAsync(Guid versionId, CancellationToken cancellationToken)
|
||||
{
|
||||
var version = await dbContext.SaasOfferingVersions.SingleOrDefaultAsync(value => value.Id == versionId, cancellationToken)
|
||||
?? throw Error("Offering version was not found.", "saas_offering_version_not_found");
|
||||
return version.Status == SaasOfferingVersionStatus.Draft
|
||||
? version
|
||||
: throw Error("Published offering versions are immutable.", "saas_offering_version_immutable");
|
||||
}
|
||||
|
||||
private Task AuditAsync(SaasCatalogActor actor, string action, string targetType, Guid targetId, object details, CancellationToken cancellationToken) =>
|
||||
auditService.WriteAsync(new BackofficeOperationAuditCommand(
|
||||
null,
|
||||
actor.UserId,
|
||||
action,
|
||||
targetType,
|
||||
targetId.ToString(),
|
||||
JsonSerializer.SerializeToElement(details)), cancellationToken);
|
||||
|
||||
private static PlatformBillingException Error(string message, string code) => new(message, code);
|
||||
private static string Normalize(string value) => Required(value, "code").ToLowerInvariant();
|
||||
private static string NormalizeCurrency(string value) => Required(value, "currency").ToUpperInvariant();
|
||||
private static string Required(string value, string field) => string.IsNullOrWhiteSpace(value) ? throw Error($"{field} is required.", "required_field") : value.Trim();
|
||||
private static string? Clean(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
private static JsonElement ObjectOrEmpty(JsonElement value) => value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDocument.Parse("{}").RootElement.Clone();
|
||||
}
|
||||
Reference in New Issue
Block a user