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

351 lines
18 KiB
C#

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();
}