382 lines
19 KiB
C#
382 lines
19 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(
|
|
IPlatformControlPlanePersistence 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)
|
|
{
|
|
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();
|
|
}
|
|
} |