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( IPlatformControlPlanePersistence dbContext, IOperationAuditService auditService) : ISaasCatalogAdminService { public async Task 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 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 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 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 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 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 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 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 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 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) { return auditService.WriteAsync(new BackofficeOperationAuditCommand( null, actor.UserId, action, targetType, targetId.ToString(), JsonSerializer.SerializeToElement(details)), cancellationToken); } private static PlatformBillingException Error(string message, string code) { return new PlatformBillingException(message, code); } private static string Normalize(string value) { return Required(value, "code").ToLowerInvariant(); } private static string NormalizeCurrency(string value) { return Required(value, "currency").ToUpperInvariant(); } private static string Required(string value, string field) { return string.IsNullOrWhiteSpace(value) ? throw Error($"{field} is required.", "required_field") : value.Trim(); } private static string? Clean(string? value) { return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } private static JsonElement ObjectOrEmpty(JsonElement value) { return value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDocument.Parse("{}").RootElement.Clone(); } }