Files
tiku-backend.net/Tiku.Infrastructure/PlatformAdmin/PlatformAdminService.cs

1589 lines
77 KiB
C#

using System.Text.Json;
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Npgsql;
using Tiku.Application.PlatformAdmin;
using Tiku.Application.Security;
using Tiku.Domain.Commerce;
using Tiku.Domain.Identity;
using Tiku.Domain.Operations;
using Tiku.Domain.Platform;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
using Tiku.Infrastructure.Tenancy;
using Tiku.Application.Tenancy;
namespace Tiku.Infrastructure.PlatformAdmin;
internal sealed class PlatformAdminService(
ICurrentAccessContext currentAccessContext,
ITenantExecutionScope tenantExecutionScope,
IOptions<TenantProvisioningOptions> provisioningOptions,
IOptions<DomainLifecycleOptions> domainOptions) : IPlatformAdminService
{
private readonly TenantProvisioningOptions provisioning = provisioningOptions.Value;
private readonly DomainLifecycleOptions domains = domainOptions.Value;
public async Task<PlatformOverview> GetOverviewAsync(
PlatformAdminActor actor,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformDashboardView, cancellationToken);
return await ExecuteSystemAsync("platform overview", async dbContext =>
{
var row = await dbContext.Database.SqlQuery<PlatformOverviewRow>($"""
SELECT
(SELECT count(*)::integer FROM tenants WHERE mode <> 'platform_owned') AS "TenantCount",
(SELECT count(*)::integer FROM tenants WHERE mode <> 'platform_owned' AND status = 'active') AS "ActiveTenantCount",
(SELECT count(*)::integer FROM tenants WHERE mode <> 'platform_owned' AND status = 'suspended') AS "SuspendedTenantCount",
(SELECT count(*)::integer FROM orders) AS "OrderCount",
(SELECT count(*)::integer FROM orders WHERE status = 'paid') AS "PaidOrderCount",
(SELECT COALESCE(sum(amount_cents - refunded_amount_cents), 0)::integer FROM orders WHERE status IN ('paid', 'partially_refunded')) AS "RevenueCents",
(SELECT count(*)::integer FROM question_banks) AS "QuestionBankCount",
(SELECT count(*)::integer FROM questions WHERE status = 'published') AS "QuestionCount",
(SELECT count(DISTINCT user_id)::integer FROM practice_sessions WHERE started_at >= {DateTimeOffset.UtcNow.AddDays(-7)}) AS "LearningActiveUserCount"
""").SingleAsync(cancellationToken);
return new PlatformOverview(
row.TenantCount,
row.ActiveTenantCount,
row.SuspendedTenantCount,
row.OrderCount,
row.PaidOrderCount,
row.RevenueCents,
row.QuestionBankCount,
row.QuestionCount,
row.LearningActiveUserCount);
}, cancellationToken);
}
private sealed class PlatformOverviewRow
{
public int TenantCount { get; init; }
public int ActiveTenantCount { get; init; }
public int SuspendedTenantCount { get; init; }
public int OrderCount { get; init; }
public int PaidOrderCount { get; init; }
public int RevenueCents { get; init; }
public int QuestionBankCount { get; init; }
public int QuestionCount { get; init; }
public int LearningActiveUserCount { get; init; }
}
public async Task<PlatformTenantList> GetTenantsAsync(
PlatformAdminActor actor,
PlatformAdminQuery query,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
return await ExecuteSystemAsync("platform tenant list", async dbContext =>
{
var tenants = dbContext.Tenants.AsNoTracking()
.Where(tenant => tenant.Mode != TenantMode.PlatformOwned);
if (!string.IsNullOrWhiteSpace(query.Search))
{
var search = query.Search.Trim();
tenants = tenants.Where(tenant =>
tenant.Slug.Contains(search) ||
tenant.Name.Contains(search) ||
(tenant.LegalName != null && tenant.LegalName.Contains(search)));
}
if (!string.IsNullOrWhiteSpace(query.Status))
{
tenants = tenants.Where(tenant => tenant.Status == ParseTenantStatus(query.Status));
}
var rows = await tenants
.OrderByDescending(tenant => tenant.CreatedAt)
.Take(Limit(query.Limit))
.Select(tenant => new
{
Tenant = tenant,
DomainCount = dbContext.TenantDomains.Count(domain => domain.TenantId == tenant.Id),
SubscriptionExpiresAt = dbContext.TenantSaasSubscriptions
.Where(subscription => subscription.TenantId == tenant.Id)
.OrderByDescending(subscription => subscription.CurrentPeriodEnd)
.Select(subscription => (DateTimeOffset?)subscription.CurrentPeriodEnd)
.FirstOrDefault()
})
.ToArrayAsync(cancellationToken);
return new PlatformTenantList(rows.Select(row => ToTenantItem(row.Tenant, row.DomainCount, row.SubscriptionExpiresAt)).ToArray());
}, cancellationToken);
}
public async Task<PlatformTenantDetail> GetTenantDetailAsync(
PlatformAdminActor actor,
Guid tenantId,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
return await ExecuteSystemAsync("platform tenant detail", async dbContext =>
{
var tenant = await dbContext.Tenants.AsNoTracking()
.SingleOrDefaultAsync(item => item.Id == tenantId && item.Mode != TenantMode.PlatformOwned, cancellationToken)
?? throw new PlatformAdminException("Tenant was not found.", "tenant_not_found");
var domains = await dbContext.TenantDomains.AsNoTracking()
.Where(domain => domain.TenantId == tenantId)
.OrderByDescending(domain => domain.IsPrimary)
.ThenBy(domain => domain.Host)
.ToArrayAsync(cancellationToken);
var subscriptions = await dbContext.TenantSaasSubscriptions.AsNoTracking()
.Where(subscription => subscription.TenantId == tenantId)
.OrderByDescending(subscription => subscription.CreatedAt)
.ToArrayAsync(cancellationToken);
var billingProfile = await dbContext.TenantBillingProfiles.AsNoTracking()
.SingleOrDefaultAsync(profile => profile.TenantId == tenantId, cancellationToken);
var billingPolicy = await dbContext.TenantBillingPolicies.AsNoTracking()
.SingleOrDefaultAsync(policy => policy.TenantId == tenantId, cancellationToken);
return new PlatformTenantDetail(
ToTenantItem(tenant, domains.Length, subscriptions.FirstOrDefault()?.CurrentPeriodEnd),
domains.Select(ToDomainItemWithInstructions).ToArray(),
subscriptions.Select(ToSubscriptionItem).ToArray(),
billingProfile is null ? null : ToBillingProfileItem(billingProfile),
billingPolicy is null ? null : ToBillingPolicyItem(billingPolicy),
await OwnerActivationStatusAsync(dbContext, tenant, cancellationToken));
}, cancellationToken);
}
public async Task<PlatformTenantProvisioningResult> CreateTenantAsync(
PlatformAdminActor actor,
CreatePlatformTenantCommand command,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
var idempotencyKey = Required(command.IdempotencyKey, "Idempotency-Key");
var requestHash = ProvisioningRequestHash(command);
try
{
return await ExecuteSystemAsync("platform tenant create", async (provider, dbContext) =>
{
var existingRequest = await dbContext.PlatformOperationIdempotencies.AsNoTracking()
.SingleOrDefaultAsync(value =>
value.ActorUserId == actor.UserId &&
value.Scope == "platform.tenant.create" &&
value.IdempotencyKey == idempotencyKey,
cancellationToken);
if (existingRequest is not null)
{
if (!string.Equals(existingRequest.RequestHash, requestHash, StringComparison.Ordinal))
{
throw new PlatformAdminException(
"Idempotency key was already used with a different request.",
"idempotency_conflict");
}
return await ProvisioningReplayResultAsync(dbContext, existingRequest.ResourceId, cancellationToken);
}
var tenantId = Guid.NewGuid();
dbContext.PlatformOperationIdempotencies.Add(new PlatformOperationIdempotency
{
ActorUserId = actor.UserId,
Scope = "platform.tenant.create",
IdempotencyKey = idempotencyKey,
RequestHash = requestHash,
ResourceId = tenantId
});
await dbContext.SaveChangesAsync(cancellationToken);
var slug = NormalizeCode(command.Slug);
if (await dbContext.Tenants.AnyAsync(tenant => tenant.Slug == slug, cancellationToken))
{
throw new PlatformAdminException("Tenant slug already exists.", "tenant_slug_exists");
}
var ownerEmail = Normalize(command.OwnerEmail);
var ownerPhone = Normalize(command.OwnerPhone);
var ownerIdentifier = ownerEmail ?? ownerPhone;
if (ownerIdentifier is null)
{
throw new PlatformAdminException("Owner email or phone is required.", "tenant_owner_identifier_required");
}
if (await dbContext.Users.AnyAsync(user =>
(ownerEmail != null && user.NormalizedEmail == ownerEmail.ToUpperInvariant()) ||
(ownerPhone != null && user.Phone == ownerPhone), cancellationToken))
{
throw new PlatformAdminException("Tenant owner already exists.", "tenant_owner_exists");
}
SaasOfferingVersion? initialVersion = null;
if (command.InitialOfferingVersionId.HasValue)
{
initialVersion = await dbContext.SaasOfferingVersions.AsNoTracking()
.SingleOrDefaultAsync(value =>
value.Id == command.InitialOfferingVersionId &&
value.Status == SaasOfferingVersionStatus.Published,
cancellationToken)
?? throw new PlatformAdminException("Initial offering version was not found or published.", "saas_offering_version_not_found");
var offeringType = await dbContext.SaasOfferings.AsNoTracking()
.Where(value => value.Id == initialVersion.OfferingId)
.Select(value => value.Type)
.SingleAsync(cancellationToken);
if (offeringType != SaasOfferingType.BasePlan)
{
throw new PlatformAdminException("Initial offering must be a base plan.", "saas_base_offering_required");
}
}
else
{
var now = DateTimeOffset.UtcNow;
initialVersion = await (
from offering in dbContext.SaasOfferings.AsNoTracking()
join version in dbContext.SaasOfferingVersions.AsNoTracking() on offering.Id equals version.OfferingId
where offering.Code == NormalizeCode(provisioning.DefaultBaseOfferingCode) &&
offering.Type == SaasOfferingType.BasePlan &&
offering.Status == SaasOfferingStatus.Active &&
version.Status == SaasOfferingVersionStatus.Published &&
(version.EffectiveAt == null || version.EffectiveAt <= now)
orderby version.Version descending
select version).FirstOrDefaultAsync(cancellationToken)
?? throw new PlatformAdminException(
"The default base offering does not have an effective published version.",
"default_offering_unavailable");
}
var tenant = new Tenant
{
Id = tenantId,
Slug = slug,
Name = command.Name.Trim(),
LegalName = Normalize(command.LegalName),
Status = command.Status,
Mode = TenantMode.Saas,
BillingStatus = initialVersion is null ? command.BillingStatus : BillingStatus.Trial,
Metadata = JsonObjectOrDefault(command.Metadata)
};
dbContext.Tenants.Add(tenant);
dbContext.AuthorizationScopeVersions.Add(new AuthorizationScopeVersion
{
Realm = AuthRealm.Tenant,
TenantId = tenant.Id
});
var owner = new User
{
Email = ownerEmail,
NormalizedEmail = ownerEmail?.ToUpperInvariant(),
UserName = ownerIdentifier,
NormalizedUserName = ownerIdentifier.ToUpperInvariant(),
Phone = ownerPhone,
PhoneNumber = ownerPhone,
Name = command.OwnerName.Trim(),
PrimaryRole = "tenant_owner",
Status = UserStatus.Active,
ForcePasswordChange = true,
EmailConfirmed = ownerEmail is not null,
PhoneNumberConfirmed = ownerPhone is not null
};
var userManager = provider.GetRequiredService<UserManager<User>>();
var createOwner = await userManager.CreateAsync(owner);
if (!createOwner.Succeeded)
{
throw new PlatformAdminException(
string.Join("; ", createOwner.Errors.Select(error => error.Description)),
"tenant_owner_password_invalid");
}
tenant.OwnerUserId = owner.Id;
dbContext.TenantMemberships.Add(new TenantMembership
{
TenantId = tenant.Id,
UserId = owner.Id,
Role = TenantRole.TenantOwner,
Status = MembershipStatus.Active
});
dbContext.TenantAuthPolicies.Add(new TenantAuthPolicy
{
TenantId = tenant.Id,
AllowExternalStudentSelfRegistration = false
});
var primaryDomain = CreatePrimaryDomain(tenant.Id, command.PrimaryDomainHost);
if (await dbContext.TenantDomains.AnyAsync(value => value.Host == primaryDomain.Host, cancellationToken))
{
throw new PlatformAdminException("Primary domain is already assigned.", "tenant_domain_exists");
}
dbContext.TenantDomains.Add(primaryDomain);
dbContext.TenantFrontendConfigs.Add(TenantFrontendConfigDefaults.Create(tenant.Id, tenant.Name));
await EnsureTenantOwnerRoleAsync(dbContext, tenant.Id, owner.Id, cancellationToken);
var policy = new TenantBillingPolicy
{
TenantId = tenant.Id,
CollectionMode = command.CollectionMode,
DefaultPaymentProvider = NormalizeCode(command.DefaultPaymentProvider),
AutoGenerateRenewal = command.AutoGenerateRenewal,
RenewalLeadDays = Math.Clamp(command.RenewalLeadDays, 1, 90)
};
dbContext.TenantBillingPolicies.Add(policy);
DateTimeOffset? subscriptionExpiresAt = null;
{
var now = DateTimeOffset.UtcNow;
var trialDays = command.TrialDays ?? provisioning.DefaultTrialDays;
subscriptionExpiresAt = now.AddDays(Math.Clamp(trialDays, 1, 365));
var subscription = new TenantSaasSubscription
{
TenantId = tenant.Id,
BaseOfferingVersionId = initialVersion!.Id,
Status = TenantSaasSubscriptionStatus.Trial,
StartsAt = now,
CurrentPeriodStart = now,
CurrentPeriodEnd = subscriptionExpiresAt.Value,
LifecycleVersion = 1
};
dbContext.TenantSaasSubscriptions.Add(subscription);
dbContext.TenantSaasSubscriptionItems.Add(new TenantSaasSubscriptionItem
{
TenantId = tenant.Id,
SubscriptionId = subscription.Id,
OfferingVersionId = initialVersion.Id,
ItemType = TenantSaasSubscriptionItemType.BasePlan,
Status = TenantSaasSubscriptionItemStatus.Active,
StartsAt = now,
EndsAt = subscriptionExpiresAt.Value
});
}
AddAudit(dbContext, actor, "platform.tenant.created", tenant.Id, new { tenant.Slug, tenant.Name, tenant.Status, tenant.BillingStatus });
await dbContext.SaveChangesAsync(cancellationToken);
return new PlatformTenantProvisioningResult(
ToTenantItem(tenant, 1, subscriptionExpiresAt),
owner.Id,
ownerIdentifier,
owner.ForcePasswordChange,
ToDomainItemWithInstructions(primaryDomain),
new PlatformOwnerActivationStatus("domain_pending", null, null),
false);
}, cancellationToken);
}
catch (DbUpdateException exception) when (IsPlatformOperationIdempotencyConflict(exception))
{
return await ExecuteSystemAsync("platform tenant create idempotency replay", async dbContext =>
{
var existingRequest = await dbContext.PlatformOperationIdempotencies.AsNoTracking()
.SingleAsync(value => value.ActorUserId == actor.UserId &&
value.Scope == "platform.tenant.create" &&
value.IdempotencyKey == idempotencyKey,
cancellationToken);
if (!string.Equals(existingRequest.RequestHash, requestHash, StringComparison.Ordinal))
{
throw new PlatformAdminException(
"Idempotency key was already used with a different request.",
"idempotency_conflict");
}
return await ProvisioningReplayResultAsync(dbContext, existingRequest.ResourceId, cancellationToken);
}, cancellationToken);
}
}
public async Task<PlatformTenantDomainItem> ReplacePrimaryDomainAsync(
PlatformAdminActor actor,
ReplacePlatformPrimaryDomainCommand command,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
if (string.IsNullOrWhiteSpace(command.Reason))
{
throw new PlatformAdminException("Primary domain replacement reason is required.", "domain_change_reason_required");
}
return await ExecuteSystemAsync("platform primary domain replace", async dbContext =>
{
var tenant = await dbContext.Tenants.SingleOrDefaultAsync(value =>
value.Id == command.TenantId && value.Mode != TenantMode.PlatformOwned, cancellationToken)
?? throw new PlatformAdminException("Tenant was not found.", "tenant_not_found");
var existing = await dbContext.TenantDomains
.Where(value => value.TenantId == tenant.Id && value.IsPrimary)
.ToArrayAsync(cancellationToken);
foreach (var domain in existing)
{
domain.IsPrimary = false;
domain.Status = TenantDomainStatus.Disabled;
}
var now = DateTimeOffset.UtcNow;
await dbContext.TenantOwnerActivationGrants
.Where(value => value.TenantId == tenant.Id && value.ConsumedAt == null && value.RevokedAt == null)
.ExecuteUpdateAsync(setters => setters
.SetProperty(value => value.RevokedAt, now)
.SetProperty(value => value.RevokedBy, actor.UserId)
.SetProperty(value => value.RevocationReason, "Primary domain replaced: " + command.Reason),
cancellationToken);
var next = CreatePrimaryDomain(tenant.Id, command.Host);
if (await dbContext.TenantDomains.AnyAsync(value => value.Host == next.Host, cancellationToken))
{
throw new PlatformAdminException("Primary domain is already assigned.", "tenant_domain_exists");
}
dbContext.TenantDomains.Add(next);
AddAudit(dbContext, actor, "platform.tenant_primary_domain.replaced", tenant.Id,
new { next.Id, next.Host, command.Reason });
await dbContext.SaveChangesAsync(cancellationToken);
return ToDomainItemWithInstructions(next);
}, cancellationToken);
}
public async Task<PlatformOwnerActivationLinkResult> IssueOwnerActivationLinkAsync(
PlatformAdminActor actor,
IssuePlatformOwnerActivationLinkCommand command,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
var idempotencyKey = Required(command.IdempotencyKey, "Idempotency-Key");
if (string.IsNullOrWhiteSpace(command.Reason))
{
throw new PlatformAdminException("Owner activation issuance reason is required.", "owner_activation_reason_required");
}
return await ExecuteSystemAsync("platform owner activation link issue", async dbContext =>
{
var lockKey = $"owner-activation:{command.TenantId:N}";
await dbContext.Database.ExecuteSqlInterpolatedAsync(
$"select pg_advisory_xact_lock(hashtextextended({lockKey}, 0))",
cancellationToken);
var existingRequest = await dbContext.PlatformOperationIdempotencies.AsNoTracking()
.SingleOrDefaultAsync(value => value.ActorUserId == actor.UserId &&
value.Scope == "platform.tenant.owner_activation.issue" &&
value.IdempotencyKey == idempotencyKey, cancellationToken);
var requestHash = OwnerActivationRequestHash(command);
if (existingRequest is not null)
{
if (!string.Equals(existingRequest.RequestHash, requestHash, StringComparison.Ordinal))
{
throw new PlatformAdminException(
"Idempotency key was already used with a different request.", "idempotency_conflict");
}
return await OwnerActivationReplayResultAsync(dbContext, existingRequest.ResourceId, cancellationToken);
}
var tenant = await dbContext.Tenants.SingleOrDefaultAsync(value =>
value.Id == command.TenantId && value.Status == TenantStatus.Active &&
value.Mode != TenantMode.PlatformOwned, cancellationToken)
?? throw new PlatformAdminException("An active tenant was not found.", "tenant_not_active");
var ownerId = tenant.OwnerUserId
?? throw new PlatformAdminException("Tenant owner was not found.", "tenant_owner_not_found");
var owner = await dbContext.Users.SingleAsync(value => value.Id == ownerId, cancellationToken);
if (owner.PasswordHash is not null || !owner.ForcePasswordChange)
{
throw new PlatformAdminException("Tenant owner is already activated.", "owner_already_activated");
}
var primaryDomain = await dbContext.TenantDomains.SingleOrDefaultAsync(value =>
value.TenantId == tenant.Id && value.IsPrimary && value.Status == TenantDomainStatus.Active,
cancellationToken)
?? throw new PlatformAdminException("The primary domain is not active.", "primary_domain_not_active");
var now = DateTimeOffset.UtcNow;
var subscriptionActive = await dbContext.TenantSaasSubscriptions.AsNoTracking().AnyAsync(value =>
value.TenantId == tenant.Id &&
(value.Status == TenantSaasSubscriptionStatus.Trial || value.Status == TenantSaasSubscriptionStatus.Active) &&
value.StartsAt <= now && value.CurrentPeriodEnd > now, cancellationToken);
if (!subscriptionActive)
{
throw new PlatformAdminException("An active trial or subscription is required.", "subscription_inactive");
}
var current = await dbContext.TenantOwnerActivationGrants
.Where(value => value.TenantId == tenant.Id && value.UserId == ownerId &&
value.ConsumedAt == null && value.RevokedAt == null)
.OrderByDescending(value => value.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
if (current is not null && current.ExpiresAt > now && !command.ReplaceExisting)
{
throw new PlatformAdminException("An owner activation link is already active.", "owner_activation_already_issued");
}
if (current is not null)
{
current.RevokedAt = now;
current.RevokedBy = actor.UserId;
current.RevocationReason = command.ReplaceExisting
? command.Reason.Trim()
: "Expired activation link replaced.";
}
var token = Base64Url(RandomNumberGenerator.GetBytes(32));
var grant = new TenantOwnerActivationGrant
{
TenantId = tenant.Id,
UserId = ownerId,
CreatedBy = actor.UserId,
DomainId = primaryDomain.Id,
TokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))).ToLowerInvariant(),
ExpiresAt = now.AddMinutes(provisioning.OwnerActivationMinutes)
};
dbContext.TenantOwnerActivationGrants.Add(grant);
dbContext.PlatformOperationIdempotencies.Add(new PlatformOperationIdempotency
{
ActorUserId = actor.UserId,
Scope = "platform.tenant.owner_activation.issue",
IdempotencyKey = idempotencyKey,
RequestHash = requestHash,
ResourceId = grant.Id
});
AddAudit(dbContext, actor, "platform.tenant_owner_activation.issued", tenant.Id,
new { grant.Id, DomainId = primaryDomain.Id, grant.ExpiresAt, command.ReplaceExisting, command.Reason });
await dbContext.SaveChangesAsync(cancellationToken);
return new PlatformOwnerActivationLinkResult(
grant.Id,
BuildOwnerActivationUrl(primaryDomain.Host, grant.Id, token),
grant.ExpiresAt,
false);
}, cancellationToken);
}
public async Task<PlatformTenantItem> UpdateTenantStatusAsync(
PlatformAdminActor actor,
UpdatePlatformTenantStatusCommand command,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
return await ExecuteSystemAsync("platform tenant status update", async (provider, dbContext) =>
{
var tenant = await dbContext.Tenants
.SingleOrDefaultAsync(item => item.Id == command.TenantId && item.Mode != TenantMode.PlatformOwned, cancellationToken)
?? throw new PlatformAdminException("Tenant was not found.", "tenant_not_found");
var fromStatus = tenant.Status;
tenant.Status = command.Status;
AddAudit(dbContext, actor, "platform.tenant.status_changed", tenant.Id, new
{
FromStatus = fromStatus,
ToStatus = tenant.Status,
BillingStatus = tenant.BillingStatus,
command.Reason
});
await dbContext.SaveChangesAsync(cancellationToken);
await provider.GetRequiredService<ITenantFeatureCacheInvalidator>()
.InvalidateAsync(tenant.Id, cancellationToken);
await provider.GetRequiredService<IAuthorizationStateInvalidator>()
.InvalidateTenantAsync(tenant.Id, cancellationToken);
var domainCount = await dbContext.TenantDomains.CountAsync(domain => domain.TenantId == tenant.Id, cancellationToken);
var expiresAt = await dbContext.TenantSaasSubscriptions
.Where(subscription => subscription.TenantId == tenant.Id)
.OrderByDescending(subscription => subscription.CurrentPeriodEnd)
.Select(subscription => (DateTimeOffset?)subscription.CurrentPeriodEnd)
.FirstOrDefaultAsync(cancellationToken);
return ToTenantItem(tenant, domainCount, expiresAt);
}, cancellationToken);
}
public async Task<TenantBillingProfileItem> UpsertTenantBillingProfileAsync(
PlatformAdminActor actor,
UpsertPlatformTenantBillingProfileCommand command,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
return await ExecuteSystemAsync("platform tenant billing profile upsert", async dbContext =>
{
await RequireTenantAsync(dbContext, command.TenantId, cancellationToken);
var profile = await dbContext.TenantBillingProfiles.SingleOrDefaultAsync(
item => item.TenantId == command.TenantId,
cancellationToken);
if (profile is null)
{
profile = new TenantBillingProfile { TenantId = command.TenantId };
dbContext.TenantBillingProfiles.Add(profile);
}
profile.BillingName = Normalize(command.BillingName);
profile.TaxId = Normalize(command.TaxId);
profile.ContactName = Normalize(command.ContactName);
profile.ContactPhone = Normalize(command.ContactPhone);
profile.ContactEmail = Normalize(command.ContactEmail);
profile.BillingAddress = Normalize(command.BillingAddress);
profile.InvoiceTitle = Normalize(command.InvoiceTitle);
profile.InvoiceType = command.InvoiceType;
profile.BankName = Normalize(command.BankName);
profile.BankAccountMasked = MaskBankAccount(command.BankAccountMasked);
profile.Metadata = JsonObjectOrDefault(command.Metadata);
AddAudit(dbContext, actor, "platform.tenant.billing_profile.updated", command.TenantId, new { profile.BillingName, profile.InvoiceType });
await dbContext.SaveChangesAsync(cancellationToken);
return ToBillingProfileItem(profile);
}, cancellationToken);
}
public async Task<TenantBillingPolicyItem> GetTenantBillingPolicyAsync(
PlatformAdminActor actor,
Guid tenantId,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
return await ExecuteSystemAsync("platform tenant billing policy get", async dbContext =>
{
await RequireTenantAsync(dbContext, tenantId, cancellationToken);
var policy = await dbContext.TenantBillingPolicies.AsNoTracking()
.SingleOrDefaultAsync(value => value.TenantId == tenantId, cancellationToken)
?? new TenantBillingPolicy { TenantId = tenantId };
return ToBillingPolicyItem(policy);
}, cancellationToken);
}
public async Task<TenantBillingPolicyItem> UpsertTenantBillingPolicyAsync(
PlatformAdminActor actor,
UpsertTenantBillingPolicyCommand command,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
return await ExecuteSystemAsync("platform tenant billing policy upsert", async dbContext =>
{
await RequireTenantAsync(dbContext, command.TenantId, cancellationToken);
if (string.IsNullOrWhiteSpace(command.Reason))
{
throw new PlatformAdminException("Billing policy change reason is required.", "platform_billing_reason_required");
}
var policy = await dbContext.TenantBillingPolicies
.SingleOrDefaultAsync(value => value.TenantId == command.TenantId, cancellationToken);
if (policy is null)
{
policy = new TenantBillingPolicy { TenantId = command.TenantId };
dbContext.TenantBillingPolicies.Add(policy);
}
policy.CollectionMode = command.CollectionMode;
policy.DefaultPaymentProvider = NormalizeCode(command.DefaultPaymentProvider);
policy.AutoGenerateRenewal = command.AutoGenerateRenewal;
policy.RenewalLeadDays = Math.Clamp(command.RenewalLeadDays, 1, 90);
AddAudit(dbContext, actor, "platform.tenant.billing_policy.updated", command.TenantId, new
{
policy.CollectionMode,
policy.DefaultPaymentProvider,
policy.AutoGenerateRenewal,
policy.RenewalLeadDays,
command.Reason
});
await dbContext.SaveChangesAsync(cancellationToken);
return ToBillingPolicyItem(policy);
}, cancellationToken);
}
public async Task<PlatformDomainList> GetDomainsAsync(
PlatformAdminActor actor,
PlatformAdminQuery query,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
return await ExecuteSystemAsync("platform domain list", async dbContext =>
{
var domains = dbContext.TenantDomains.AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Status))
{
domains = domains.Where(domain => domain.Status == ParseDomainStatus(query.Status));
}
if (!string.IsNullOrWhiteSpace(query.Search))
{
var search = query.Search.Trim();
domains = domains.Where(domain => domain.Host.Contains(search));
}
return new PlatformDomainList(await domains
.OrderByDescending(domain => domain.UpdatedAt)
.Take(Limit(query.Limit))
.Select(domain => ToDomainItem(domain))
.ToArrayAsync(cancellationToken));
}, cancellationToken);
}
public async Task<PlatformDomainRecheckResult> RecheckDomainAsync(
PlatformAdminActor actor,
Guid domainId,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
return await ExecuteSystemAsync("platform domain recheck", async dbContext =>
{
var domain = await dbContext.TenantDomains.SingleOrDefaultAsync(domain => domain.Id == domainId, cancellationToken)
?? throw new PlatformAdminException("Tenant domain was not found.", "domain_not_found");
domain.Status = domain.Status == TenantDomainStatus.Disabled ? TenantDomainStatus.Disabled : TenantDomainStatus.Pending;
domain.LastCheckedAt = DateTimeOffset.UtcNow;
domain.LastFailureReason = null;
dbContext.BackgroundJobs.Add(new BackgroundJob
{
TenantId = domain.TenantId,
JobType = "tenant_domain_recheck",
Payload = JsonSerializer.SerializeToElement(new { domain.Id, domain.Host }),
MaxRetries = 3
});
AddAudit(dbContext, actor, "platform.tenant_domain.recheck_requested", domain.TenantId, new { domain.Id, domain.Host });
await dbContext.SaveChangesAsync(cancellationToken);
return new PlatformDomainRecheckResult(domain.Id, domain.Status, domain.LastCheckedAt.Value);
}, cancellationToken);
}
public async Task<PlatformStaffList> GetStaffAsync(
PlatformAdminActor actor,
PlatformAdminQuery query,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformStaffManage, cancellationToken);
return await ExecuteSystemAsync("platform staff list", async dbContext =>
{
var roleRows = await (
from userRole in dbContext.PlatformBackendUserRoles.AsNoTracking()
join role in dbContext.PlatformBackendRoles.AsNoTracking() on userRole.RoleId equals role.Id
join user in dbContext.Users.AsNoTracking() on userRole.UserId equals user.Id
select new { user, role.Code })
.ToArrayAsync(cancellationToken);
var items = roleRows
.GroupBy(row => row.user.Id)
.Select(group => ToStaffItem(group.First().user, group.Select(row => row.Code).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray()))
.OrderBy(item => item.Email ?? item.PhoneMasked ?? item.UserId.ToString())
.Take(Limit(query.Limit))
.ToArray();
return new PlatformStaffList(items);
}, cancellationToken);
}
public async Task<PlatformStaffItem> UpsertStaffAsync(
PlatformAdminActor actor,
UpsertPlatformStaffCommand command,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformStaffManage, cancellationToken);
return await ExecuteSystemAsync("platform staff upsert", async (provider, dbContext) =>
{
var user = command.UserId.HasValue
? await dbContext.Users.SingleOrDefaultAsync(item => item.Id == command.UserId.Value, cancellationToken)
: await dbContext.Users.SingleOrDefaultAsync(item =>
(!string.IsNullOrWhiteSpace(command.Email) && item.Email == command.Email) ||
(!string.IsNullOrWhiteSpace(command.Phone) && item.Phone == command.Phone),
cancellationToken);
if (user is null)
{
user = new User
{
Email = Normalize(command.Email),
NormalizedEmail = Normalize(command.Email)?.ToUpperInvariant(),
UserName = Normalize(command.Email) ?? Normalize(command.Phone),
NormalizedUserName = (Normalize(command.Email) ?? Normalize(command.Phone))?.ToUpperInvariant(),
Phone = Normalize(command.Phone),
PhoneNumber = Normalize(command.Phone),
Name = Normalize(command.Name),
PrimaryRole = "platform_admin",
Status = command.Status,
ForcePasswordChange = true
};
dbContext.Users.Add(user);
}
else
{
user.Email = Normalize(command.Email) ?? user.Email;
user.NormalizedEmail = user.Email?.ToUpperInvariant();
user.Phone = Normalize(command.Phone) ?? user.Phone;
user.PhoneNumber = user.Phone;
user.Name = Normalize(command.Name) ?? user.Name;
user.Status = command.Status;
user.PrimaryRole = "platform_admin";
}
var roleIds = command.RoleIds.Distinct().ToArray();
var roleCount = await dbContext.PlatformBackendRoles.CountAsync(role => roleIds.Contains(role.Id), cancellationToken);
if (roleCount != roleIds.Length)
{
throw new PlatformAdminException("One or more platform roles were not found.", "role_not_found");
}
await dbContext.SaveChangesAsync(cancellationToken);
await dbContext.PlatformBackendUserRoles.Where(binding => binding.UserId == user.Id).ExecuteDeleteAsync(cancellationToken);
dbContext.PlatformBackendUserRoles.AddRange(roleIds.Select(roleId => new PlatformBackendUserRole
{
UserId = user.Id,
RoleId = roleId
}));
AddAudit(dbContext, actor, "platform.staff.upserted", user.Id, new { user.Email, Phone = MaskPhone(user.Phone), user.Status, RoleIds = roleIds });
await dbContext.SaveChangesAsync(cancellationToken);
var invalidator = provider.GetRequiredService<IAuthorizationStateInvalidator>();
await invalidator.InvalidateUserAsync(user.Id, cancellationToken);
await invalidator.BumpScopeAsync(AuthRealm.Platform, null, cancellationToken);
var roleCodes = await dbContext.PlatformBackendRoles.AsNoTracking()
.Where(role => roleIds.Contains(role.Id))
.Select(role => role.Code)
.ToArrayAsync(cancellationToken);
return ToStaffItem(user, roleCodes);
}, cancellationToken);
}
public async Task<PlatformStaffItem> UpdateStaffStatusAsync(
PlatformAdminActor actor,
UpdatePlatformStaffStatusCommand command,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformStaffManage, cancellationToken);
return await ExecuteSystemAsync("platform staff status update", async (provider, dbContext) =>
{
var user = await dbContext.Users.SingleOrDefaultAsync(item => item.Id == command.UserId, cancellationToken)
?? throw new PlatformAdminException("Platform staff user was not found.", "staff_not_found");
var from = user.Status;
user.Status = command.Status;
AddAudit(dbContext, actor, "platform.staff.status_changed", user.Id, new { From = from, To = command.Status, command.Reason });
await dbContext.SaveChangesAsync(cancellationToken);
await provider.GetRequiredService<IAuthorizationStateInvalidator>()
.InvalidateUserAsync(user.Id, cancellationToken);
var roleCodes = await (
from binding in dbContext.PlatformBackendUserRoles.AsNoTracking()
join role in dbContext.PlatformBackendRoles.AsNoTracking() on binding.RoleId equals role.Id
where binding.UserId == user.Id
select role.Code)
.ToArrayAsync(cancellationToken);
return ToStaffItem(user, roleCodes);
}, cancellationToken);
}
public async Task<PlatformAuditLogList> GetAuditLogsAsync(
PlatformAdminActor actor,
PlatformAdminQuery query,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformAuditView, cancellationToken);
return await ExecuteSystemAsync("platform audit log list", async dbContext =>
{
var logs = dbContext.AuditLogs.AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Search))
{
var search = query.Search.Trim();
logs = logs.Where(log => log.Action.Contains(search) || (log.TargetType != null && log.TargetType.Contains(search)));
}
return new PlatformAuditLogList(await logs
.OrderByDescending(log => log.CreatedAt)
.Take(Limit(query.Limit))
.Select(log => new PlatformAuditLogItem(
log.Id,
log.TenantId,
log.ActorUserId,
log.Action,
log.TargetType,
log.TargetId,
log.Details,
log.IpAddress,
log.UserAgent,
log.CreatedAt))
.ToArrayAsync(cancellationToken));
}, cancellationToken);
}
public async Task<PlatformAuditAlertList> GetAuditAlertsAsync(
PlatformAdminActor actor,
PlatformAdminQuery query,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformAuditView, cancellationToken);
return await ExecuteSystemAsync("platform audit alert list", async dbContext =>
{
var alerts = dbContext.PlatformAuditAlerts.AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Status))
{
alerts = alerts.Where(alert => alert.Status == ParseAuditAlertStatus(query.Status));
}
return new PlatformAuditAlertList(await alerts
.OrderByDescending(alert => alert.LastSeenAt)
.Take(Limit(query.Limit))
.ToArrayAsync(cancellationToken));
}, cancellationToken);
}
public async Task<PlatformAuditAlert> UpdateAuditAlertStatusAsync(
PlatformAdminActor actor,
UpdatePlatformAuditAlertStatusCommand command,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformAuditView, cancellationToken);
return await ExecuteSystemAsync("platform audit alert status update", async dbContext =>
{
var alert = await dbContext.PlatformAuditAlerts.SingleOrDefaultAsync(item => item.Id == command.AlertId, cancellationToken)
?? throw new PlatformAdminException("Platform audit alert was not found.", "audit_alert_not_found");
alert.Status = command.Status;
alert.ResolutionNote = Normalize(command.ResolutionNote);
if (command.Status == PlatformAuditAlertStatus.Acknowledged)
{
alert.AcknowledgedBy = actor.UserId;
alert.AcknowledgedAt ??= DateTimeOffset.UtcNow;
}
else if (command.Status is PlatformAuditAlertStatus.Resolved or PlatformAuditAlertStatus.Ignored)
{
alert.ResolvedBy = actor.UserId;
alert.ResolvedAt ??= DateTimeOffset.UtcNow;
}
AddAudit(dbContext, actor, "platform.audit_alert.status_changed", alert.Id, new { alert.Status, command.ResolutionNote });
await dbContext.SaveChangesAsync(cancellationToken);
return alert;
}, cancellationToken);
}
public async Task<PlatformBillingDunningChannelList> GetBillingDunningChannelsAsync(
PlatformAdminActor actor,
PlatformAdminQuery query,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken);
return await ExecuteSystemAsync("platform dunning channel list", async dbContext =>
{
var channels = dbContext.PlatformBillingDunningNotificationChannels.AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Search))
{
var search = query.Search.Trim();
channels = channels.Where(channel =>
channel.ChannelCode.Contains(search) ||
channel.Name.Contains(search));
}
if (!string.IsNullOrWhiteSpace(query.Status))
{
var enabled = ParseEnabledStatus(query.Status);
channels = channels.Where(channel => channel.Enabled == enabled);
}
return new PlatformBillingDunningChannelList(await channels
.OrderByDescending(channel => channel.Enabled)
.ThenBy(channel => channel.MinReminderLevel)
.ThenBy(channel => channel.ChannelCode)
.Take(Limit(query.Limit))
.Select(channel => ToDunningChannelItem(channel))
.ToArrayAsync(cancellationToken));
}, cancellationToken);
}
public async Task<PlatformBillingDunningChannelItem> UpsertBillingDunningChannelAsync(
PlatformAdminActor actor,
UpsertPlatformBillingDunningChannelCommand command,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken);
return await ExecuteSystemAsync("platform dunning channel upsert", async dbContext =>
{
var code = NormalizeCode(command.ChannelCode);
var channel = command.ChannelId.HasValue
? await dbContext.PlatformBillingDunningNotificationChannels.SingleOrDefaultAsync(item => item.Id == command.ChannelId.Value, cancellationToken)
: await dbContext.PlatformBillingDunningNotificationChannels.SingleOrDefaultAsync(item => item.ChannelCode == code, cancellationToken);
if (channel is null)
{
channel = new PlatformBillingDunningNotificationChannel { ChannelCode = code };
dbContext.PlatformBillingDunningNotificationChannels.Add(channel);
}
channel.ChannelCode = code;
channel.Name = command.Name.Trim();
channel.Description = Normalize(command.Description);
channel.Enabled = command.Enabled;
channel.Provider = command.Provider;
channel.WebhookUrl = command.WebhookUrl.Trim();
channel.SecretRef = Normalize(command.SecretRef);
channel.ReminderTypes = NormalizeArray(command.ReminderTypes, ["overdue", "final_notice"]);
channel.ReminderChannels = NormalizeArray(command.ReminderChannels, ["internal"]);
channel.MinReminderLevel = Math.Clamp(command.MinReminderLevel, 1, 20);
channel.TenantIds = command.TenantIds.Where(id => id != Guid.Empty).Distinct().ToArray();
channel.TimeoutSeconds = Math.Clamp(command.TimeoutSeconds, 1, 60);
channel.Metadata = JsonObjectOrDefault(command.Metadata);
AddAudit(dbContext, actor, "platform.billing_dunning_channel.upserted", channel.Id, new
{
channel.ChannelCode,
channel.Name,
channel.Enabled,
channel.Provider,
Webhook = MaskWebhook(channel.WebhookUrl),
channel.SecretRef
});
await dbContext.SaveChangesAsync(cancellationToken);
return ToDunningChannelItem(channel);
}, cancellationToken);
}
public async Task<PlatformBillingDunningChannelItem> DisableBillingDunningChannelAsync(
PlatformAdminActor actor,
DisablePlatformBillingDunningChannelCommand command,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken);
return await ExecuteSystemAsync("platform dunning channel disable", async dbContext =>
{
var channel = await dbContext.PlatformBillingDunningNotificationChannels.SingleOrDefaultAsync(item => item.Id == command.ChannelId, cancellationToken)
?? throw new PlatformAdminException("Platform dunning channel was not found.", "dunning_channel_not_found");
channel.Enabled = false;
AddAudit(dbContext, actor, "platform.billing_dunning_channel.disabled", channel.Id, new
{
channel.ChannelCode,
command.Reason
});
await dbContext.SaveChangesAsync(cancellationToken);
return ToDunningChannelItem(channel);
}, cancellationToken);
}
public async Task<PlatformBillingDunningEventList> GetBillingDunningEventsAsync(
PlatformAdminActor actor,
PlatformAdminQuery query,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken);
return await ExecuteSystemAsync("platform dunning event list", async dbContext =>
{
var events = dbContext.PlatformBillingDunningNotificationEvents.AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Status))
{
events = events.Where(item => item.Status == ParseDunningEventStatus(query.Status));
}
return new PlatformBillingDunningEventList(await events
.OrderByDescending(item => item.CreatedAt)
.Take(Limit(query.Limit))
.Select(item => ToDunningEventItem(item))
.ToArrayAsync(cancellationToken));
}, cancellationToken);
}
public async Task<PlatformBillingDunningEventItem> GetBillingDunningEventDetailAsync(
PlatformAdminActor actor,
Guid eventId,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken);
return await ExecuteSystemAsync("platform dunning event detail", async dbContext =>
{
var item = await dbContext.PlatformBillingDunningNotificationEvents.AsNoTracking()
.SingleOrDefaultAsync(item => item.Id == eventId, cancellationToken)
?? throw new PlatformAdminException("Platform dunning event was not found.", "dunning_event_not_found");
return ToDunningEventItem(item);
}, cancellationToken);
}
public async Task<PlatformBillingDunningEventItem> RetryBillingDunningEventAsync(
PlatformAdminActor actor,
RetryPlatformBillingDunningEventCommand command,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken);
return await ExecuteSystemAsync("platform dunning event retry", async dbContext =>
{
var item = await dbContext.PlatformBillingDunningNotificationEvents.SingleOrDefaultAsync(item => item.Id == command.EventId, cancellationToken)
?? throw new PlatformAdminException("Platform dunning event was not found.", "dunning_event_not_found");
item.Status = PlatformBillingDunningNotificationStatus.Pending;
item.NextAttemptAt = DateTimeOffset.UtcNow;
item.LastError = null;
AddAudit(dbContext, actor, "platform.billing_dunning_event.retry_requested", item.Id, new
{
item.TenantId,
item.ChannelId,
item.ReminderId,
item.InvoiceId,
command.Reason
});
await dbContext.SaveChangesAsync(cancellationToken);
return ToDunningEventItem(item);
}, cancellationToken);
}
public Task<PlatformBillingDunningEventItem> AcknowledgeBillingDunningEventAsync(
PlatformAdminActor actor,
ResolvePlatformBillingDunningEventCommand command,
CancellationToken cancellationToken = default) =>
ResolveBillingDunningEventAsync(actor, command, PlatformBillingDunningNotificationStatus.Acknowledged, cancellationToken);
public Task<PlatformBillingDunningEventItem> IgnoreBillingDunningEventAsync(
PlatformAdminActor actor,
ResolvePlatformBillingDunningEventCommand command,
CancellationToken cancellationToken = default) =>
ResolveBillingDunningEventAsync(actor, command, PlatformBillingDunningNotificationStatus.Ignored, cancellationToken);
private async Task<PlatformBillingDunningEventItem> ResolveBillingDunningEventAsync(
PlatformAdminActor actor,
ResolvePlatformBillingDunningEventCommand command,
PlatformBillingDunningNotificationStatus status,
CancellationToken cancellationToken)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken);
return await ExecuteSystemAsync("platform dunning event resolve", async dbContext =>
{
var item = await dbContext.PlatformBillingDunningNotificationEvents
.SingleOrDefaultAsync(value => value.Id == command.EventId, cancellationToken)
?? throw new PlatformAdminException("Platform dunning event was not found.", "dunning_event_not_found");
if (item.Status == PlatformBillingDunningNotificationStatus.Processing ||
item.Status is PlatformBillingDunningNotificationStatus.Acknowledged or PlatformBillingDunningNotificationStatus.Ignored)
{
throw new PlatformAdminException("Platform dunning event cannot be resolved from its current status.", "dunning_event_status_invalid");
}
var fromStatus = item.Status;
item.Status = status;
item.NextAttemptAt = null;
AddAudit(dbContext, actor,
status == PlatformBillingDunningNotificationStatus.Acknowledged
? "platform.billing_dunning_event.acknowledged"
: "platform.billing_dunning_event.ignored",
item.Id,
new { item.TenantId, FromStatus = fromStatus, ToStatus = status, command.Reason });
await dbContext.SaveChangesAsync(cancellationToken);
return ToDunningEventItem(item);
}, cancellationToken);
}
private async Task AssertPlatformPermissionAsync(
PlatformAdminActor actor,
string permissionCode,
CancellationToken cancellationToken)
{
var access = await currentAccessContext.GetAsync(cancellationToken);
if (access.UserId != actor.UserId || !access.HasPlatformPermission(permissionCode))
{
throw new PlatformAdminException("Platform admin access is required.", "platform_access_denied");
}
}
private static bool IsPlatformOperationIdempotencyConflict(DbUpdateException exception) =>
exception.InnerException is PostgresException
{
SqlState: PostgresErrorCodes.UniqueViolation,
ConstraintName: { } constraintName
} && constraintName.StartsWith(
"ix_platform_operation_idempotencies_actor_user_id_scope_",
StringComparison.Ordinal);
private async Task<PlatformTenantProvisioningResult> ProvisioningReplayResultAsync(
TikuDbContext dbContext,
Guid tenantId,
CancellationToken cancellationToken)
{
var tenant = await dbContext.Tenants.AsNoTracking()
.SingleAsync(value => value.Id == tenantId, cancellationToken);
var ownerId = tenant.OwnerUserId
?? throw new PlatformAdminException("Tenant owner was not found.", "tenant_owner_not_found");
var owner = await dbContext.Users.AsNoTracking().SingleAsync(value => value.Id == ownerId, cancellationToken);
var primaryDomain = await dbContext.TenantDomains.AsNoTracking()
.SingleAsync(value => value.TenantId == tenant.Id && value.IsPrimary, cancellationToken);
var subscriptionExpiresAt = await dbContext.TenantSaasSubscriptions.AsNoTracking()
.Where(value => value.TenantId == tenant.Id)
.OrderByDescending(value => value.CurrentPeriodEnd)
.Select(value => (DateTimeOffset?)value.CurrentPeriodEnd)
.FirstOrDefaultAsync(cancellationToken);
return new PlatformTenantProvisioningResult(
ToTenantItem(tenant, 0, subscriptionExpiresAt),
ownerId,
owner.Email ?? owner.Phone ?? owner.UserName ?? ownerId.ToString(),
owner.ForcePasswordChange,
ToDomainItemWithInstructions(primaryDomain),
await OwnerActivationStatusAsync(dbContext, tenant, cancellationToken),
true);
}
private static async Task<PlatformOwnerActivationLinkResult> OwnerActivationReplayResultAsync(
TikuDbContext dbContext,
Guid activationId,
CancellationToken cancellationToken)
{
var grant = await dbContext.TenantOwnerActivationGrants.AsNoTracking()
.SingleAsync(value => value.Id == activationId, cancellationToken);
return new PlatformOwnerActivationLinkResult(grant.Id, null, grant.ExpiresAt, true);
}
private async Task<PlatformOwnerActivationStatus> OwnerActivationStatusAsync(
TikuDbContext dbContext,
Tenant tenant,
CancellationToken cancellationToken)
{
if (tenant.OwnerUserId is not { } ownerId)
{
return new PlatformOwnerActivationStatus("domain_pending", null, null);
}
var activated = await dbContext.Users.AsNoTracking().AnyAsync(value =>
value.Id == ownerId && value.PasswordHash != null && !value.ForcePasswordChange, cancellationToken);
if (activated)
{
return new PlatformOwnerActivationStatus("activated", null, null);
}
var grant = await dbContext.TenantOwnerActivationGrants.AsNoTracking()
.Where(value => value.TenantId == tenant.Id && value.UserId == ownerId &&
value.ConsumedAt == null && value.RevokedAt == null)
.OrderByDescending(value => value.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
if (grant is not null)
{
return new PlatformOwnerActivationStatus(
grant.ExpiresAt > DateTimeOffset.UtcNow ? "issued" : "expired",
grant.Id,
grant.ExpiresAt);
}
var domainActive = await dbContext.TenantDomains.AsNoTracking().AnyAsync(value =>
value.TenantId == tenant.Id && value.IsPrimary && value.Status == TenantDomainStatus.Active,
cancellationToken);
return new PlatformOwnerActivationStatus(domainActive ? "ready_to_issue" : "domain_pending", null, null);
}
private Task<TResult> ExecuteSystemAsync<TResult>(
string reason,
Func<TikuDbContext, Task<TResult>> operation,
CancellationToken cancellationToken)
{
return ExecuteSystemAsync(reason, (_, dbContext) => operation(dbContext), cancellationToken);
}
private Task<TResult> ExecuteSystemAsync<TResult>(
string reason,
Func<IServiceProvider, TikuDbContext, Task<TResult>> operation,
CancellationToken cancellationToken)
{
return tenantExecutionScope.ExecuteAsync(
new SystemScopeRequest(
null,
SystemScopeCallerType.Platform,
nameof(PlatformAdminService),
reason,
Guid.NewGuid().ToString("N"),
IsGlobal: true),
async (provider, _) => await operation(provider, provider.GetRequiredService<TikuDbContext>()),
cancellationToken);
}
private static async Task RequireTenantAsync(TikuDbContext dbContext, Guid tenantId, CancellationToken cancellationToken)
{
var exists = await dbContext.Tenants.AnyAsync(tenant => tenant.Id == tenantId && tenant.Mode != TenantMode.PlatformOwned, cancellationToken);
if (!exists)
{
throw new PlatformAdminException("Tenant was not found.", "tenant_not_found");
}
}
private static void AddAudit(TikuDbContext dbContext, PlatformAdminActor actor, string action, Guid targetId, object details)
{
dbContext.AuditLogs.Add(new AuditLog
{
ActorUserId = actor.UserId,
Action = action,
TargetType = action.Split('.')[1],
TargetId = targetId.ToString("N"),
Details = JsonSerializer.SerializeToElement(details)
});
}
private static PlatformTenantItem ToTenantItem(Tenant tenant, int domainCount, DateTimeOffset? subscriptionExpiresAt) =>
new(
tenant.Id,
tenant.Slug,
tenant.Name,
tenant.LegalName,
tenant.Status,
tenant.Mode,
tenant.BillingStatus,
subscriptionExpiresAt,
domainCount,
tenant.CreatedAt,
tenant.UpdatedAt);
private static PlatformTenantDomainItem ToDomainItem(TenantDomain domain) =>
new(
domain.Id,
domain.TenantId,
domain.Host,
domain.DomainType,
domain.Status,
domain.IsPrimary,
domain.VerifiedAt,
domain.DnsVerifiedAt,
domain.TlsReadyAt,
domain.LastCheckedAt,
domain.LastFailureReason,
null,
null,
null);
private PlatformTenantDomainItem ToDomainItemWithInstructions(TenantDomain domain) =>
ToDomainItem(domain) with
{
VerificationRecordName = $"{domains.VerificationRecordPrefix.Trim().TrimEnd('.')}.{domain.Host}",
VerificationToken = domain.VerificationToken,
CnameTarget = domains.AllowedCnameTargets.FirstOrDefault()
};
private static PlatformTenantSubscriptionItem ToSubscriptionItem(TenantSaasSubscription subscription) =>
new(
subscription.Id,
subscription.TenantId,
subscription.BaseOfferingVersionId,
subscription.Status,
subscription.StartsAt,
subscription.CurrentPeriodStart,
subscription.CurrentPeriodEnd,
subscription.CancelAtPeriodEnd,
subscription.Metadata);
private static TenantBillingProfileItem ToBillingProfileItem(TenantBillingProfile profile) =>
new(
profile.TenantId,
profile.BillingName,
profile.TaxId,
profile.ContactName,
MaskPhone(profile.ContactPhone),
profile.ContactEmail,
profile.BillingAddress,
profile.InvoiceTitle,
profile.InvoiceType,
profile.BankName,
profile.BankAccountMasked,
profile.Metadata);
private static TenantBillingPolicyItem ToBillingPolicyItem(TenantBillingPolicy policy) =>
new(
policy.TenantId,
policy.CollectionMode,
policy.DefaultPaymentProvider,
policy.AutoGenerateRenewal,
policy.RenewalLeadDays,
policy.CreatedAt,
policy.UpdatedAt);
private static PlatformBillingDunningChannelItem ToDunningChannelItem(PlatformBillingDunningNotificationChannel channel) =>
new(
channel.Id,
channel.ChannelCode,
channel.Name,
channel.Description,
channel.Enabled,
channel.Provider,
MaskWebhook(channel.WebhookUrl),
channel.SecretRef,
channel.ReminderTypes,
channel.ReminderChannels,
channel.MinReminderLevel,
channel.TenantIds,
channel.TimeoutSeconds,
channel.Metadata,
channel.CreatedAt,
channel.UpdatedAt);
private static PlatformBillingDunningEventItem ToDunningEventItem(PlatformBillingDunningNotificationEvent item) =>
new(
item.Id,
item.TenantId,
item.ChannelId,
item.ReminderId,
item.InvoiceId,
item.Provider,
item.Status,
item.Attempts,
item.ScheduledAt,
item.NextAttemptAt,
item.LastAttemptAt,
item.SentAt,
item.LastError,
item.LastHttpCode,
item.LastResponseSummary,
item.RequestPayload,
item.Metadata,
item.CreatedAt,
item.UpdatedAt);
private static PlatformStaffItem ToStaffItem(User user, IReadOnlyCollection<string> roleCodes) =>
new(
user.Id,
user.Name,
MaskPhone(user.Phone),
user.Email,
user.Status,
roleCodes,
user.CreatedAt,
user.UpdatedAt);
private static int Limit(int? limit) => Math.Clamp(limit ?? 50, 1, 200);
private static string NormalizeCode(string value) => value.Trim().ToLowerInvariant();
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static string Required(string? value, string name) =>
string.IsNullOrWhiteSpace(value)
? throw new PlatformAdminException($"{name} is required.", "idempotency_key_required")
: value.Trim();
private static string ProvisioningRequestHash(CreatePlatformTenantCommand command)
{
var payload = JsonSerializer.Serialize(new
{
command.Slug,
command.Name,
command.LegalName,
command.Status,
command.BillingStatus,
command.Metadata,
command.PrimaryDomainHost,
command.OwnerEmail,
command.OwnerPhone,
command.OwnerName,
command.InitialOfferingVersionId,
command.TrialDays,
command.CollectionMode,
command.DefaultPaymentProvider,
command.AutoGenerateRenewal,
command.RenewalLeadDays
});
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload))).ToLowerInvariant();
}
private static string OwnerActivationRequestHash(IssuePlatformOwnerActivationLinkCommand command)
{
var payload = JsonSerializer.Serialize(new
{
command.TenantId,
Reason = command.Reason.Trim(),
command.ReplaceExisting
});
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload))).ToLowerInvariant();
}
private static TenantDomain CreatePrimaryDomain(Guid tenantId, string host)
{
try
{
return TenantDomainProvisioning.CreatePrimary(tenantId, host);
}
catch (ArgumentException exception)
{
throw new PlatformAdminException(exception.Message, "tenant_domain_invalid");
}
}
private static string Base64Url(byte[] value) =>
Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_');
private string BuildOwnerActivationUrl(string host, Guid activationId, string token)
=> TenantOwnerActivationUrlPolicy.Build(provisioning, host, activationId, token);
private static JsonElement JsonObjectOrDefault(JsonElement value) =>
value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDocument.Parse("{}").RootElement.Clone();
private static string? MaskPhone(string? phone)
{
var value = Normalize(phone);
return value is { Length: >= 7 }
? $"{value[..3]}****{value[^4..]}"
: value;
}
private static string? MaskBankAccount(string? account)
{
var value = Normalize(account);
return value is { Length: > 8 }
? $"****{value[^4..]}"
: value;
}
private static string MaskWebhook(string webhookUrl)
{
var value = Normalize(webhookUrl) ?? string.Empty;
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
{
return value.Length <= 16 ? "****" : $"{value[..8]}****{value[^4..]}";
}
return $"{uri.Scheme}://{uri.Host}/****";
}
private static string[] NormalizeArray(IReadOnlyCollection<string> values, string[] fallback)
{
var normalized = values
.Select(Normalize)
.Where(value => value is not null)
.Select(value => value!)
.Distinct(StringComparer.Ordinal)
.ToArray();
return normalized.Length == 0 ? fallback : normalized;
}
private static bool ParseEnabledStatus(string status)
{
return NormalizeCode(status) switch
{
"enabled" or "active" or "true" => true,
"disabled" or "inactive" or "false" => false,
_ => throw InvalidStatus(status)
};
}
private static TenantStatus ParseTenantStatus(string status) =>
Enum.TryParse<TenantStatus>(status, true, out var value) ? value : throw InvalidStatus(status);
private static TenantDomainStatus ParseDomainStatus(string status) =>
Enum.TryParse<TenantDomainStatus>(status, true, out var value) ? value : throw InvalidStatus(status);
private static async Task EnsureTenantOwnerRoleAsync(
TikuDbContext dbContext,
Guid tenantId,
Guid ownerUserId,
CancellationToken cancellationToken)
{
var featureCodes = SaasFeatureCatalog.All.ToArray();
var existingFeatureCodes = await dbContext.SaasFeatures
.Where(value => featureCodes.Contains(value.Code))
.Select(value => value.Code)
.ToArrayAsync(cancellationToken);
dbContext.SaasFeatures.AddRange(featureCodes
.Except(existingFeatureCodes, StringComparer.Ordinal)
.Select((code, index) => new SaasFeature
{
Code = code,
Name = code,
Category = code.Split('.')[0],
IsCore = code == SaasFeatureCatalog.CoreBackoffice,
Status = SaasFeatureStatus.Active,
SortOrder = index * 10
}));
var moduleCodes = BackendPermissions.Tenant
.Select(PermissionModuleCatalog.ResolvePermissionModuleCode)
.Distinct(StringComparer.Ordinal)
.ToArray();
var existingModuleCodes = await dbContext.PermissionModules
.Where(value => moduleCodes.Contains(value.Code))
.Select(value => value.Code)
.ToArrayAsync(cancellationToken);
dbContext.PermissionModules.AddRange(moduleCodes
.Except(existingModuleCodes, StringComparer.Ordinal)
.Select(code => new PermissionModule
{
Code = code,
Name = code,
Area = BackendPermissionArea.Tenant,
RequiredFeatureCode = PermissionModuleCatalog.RequiredFeatures[code]
}));
var tenantPermissionCodes = BackendPermissions.Tenant.ToArray();
var existingPermissionCodes = await dbContext.BackendPermissions
.Where(value => tenantPermissionCodes.Contains(value.Code))
.Select(value => value.Code)
.ToArrayAsync(cancellationToken);
dbContext.BackendPermissions.AddRange(tenantPermissionCodes
.Except(existingPermissionCodes, StringComparer.Ordinal)
.Select(code => new BackendPermission
{
Code = code,
Name = code,
Area = BackendPermissionArea.Tenant,
PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(code),
IsSystem = true
}));
var role = new TenantBackendRole
{
TenantId = tenantId,
Code = "tenant_owner",
Name = "租户所有者",
Status = BackendRoleStatus.Active,
IsSystem = true,
Description = "系统内置租户所有者角色",
DataScope = JsonSerializer.SerializeToElement(new { mode = "all" })
};
dbContext.TenantBackendRoles.Add(role);
dbContext.TenantBackendRolePermissions.AddRange(tenantPermissionCodes.Select(code => new TenantBackendRolePermission
{
TenantId = tenantId,
RoleId = role.Id,
PermissionCode = code
}));
dbContext.TenantBackendUserRoles.Add(new TenantBackendUserRole
{
TenantId = tenantId,
UserId = ownerUserId,
RoleId = role.Id
});
}
private static PlatformAuditAlertStatus ParseAuditAlertStatus(string status) =>
Enum.TryParse<PlatformAuditAlertStatus>(status, true, out var value) ? value : throw InvalidStatus(status);
private static PlatformBillingDunningNotificationStatus ParseDunningEventStatus(string status) =>
Enum.TryParse<PlatformBillingDunningNotificationStatus>(status, true, out var value) ? value : throw InvalidStatus(status);
private static PlatformAdminException InvalidStatus(string status) =>
new($"Unsupported status '{status}'.", "invalid_status");
}