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 provisioningOptions, IOptions domainOptions) : IPlatformAdminService { private readonly TenantProvisioningOptions provisioning = provisioningOptions.Value; private readonly DomainLifecycleOptions domains = domainOptions.Value; public async Task 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($""" 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 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 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 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>(); 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 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 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 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() .InvalidateAsync(tenant.Id, cancellationToken); await provider.GetRequiredService() .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 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 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 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 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 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 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 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(); 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 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() .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 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 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 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 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 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 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 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 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 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 AcknowledgeBillingDunningEventAsync( PlatformAdminActor actor, ResolvePlatformBillingDunningEventCommand command, CancellationToken cancellationToken = default) => ResolveBillingDunningEventAsync(actor, command, PlatformBillingDunningNotificationStatus.Acknowledged, cancellationToken); public Task IgnoreBillingDunningEventAsync( PlatformAdminActor actor, ResolvePlatformBillingDunningEventCommand command, CancellationToken cancellationToken = default) => ResolveBillingDunningEventAsync(actor, command, PlatformBillingDunningNotificationStatus.Ignored, cancellationToken); private async Task 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 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 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 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 ExecuteSystemAsync( string reason, Func> operation, CancellationToken cancellationToken) { return ExecuteSystemAsync(reason, (_, dbContext) => operation(dbContext), cancellationToken); } private Task ExecuteSystemAsync( string reason, Func> 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()), 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 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 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(status, true, out var value) ? value : throw InvalidStatus(status); private static TenantDomainStatus ParseDomainStatus(string status) => Enum.TryParse(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(status, true, out var value) ? value : throw InvalidStatus(status); private static PlatformBillingDunningNotificationStatus ParseDunningEventStatus(string status) => Enum.TryParse(status, true, out var value) ? value : throw InvalidStatus(status); private static PlatformAdminException InvalidStatus(string status) => new($"Unsupported status '{status}'.", "invalid_status"); }