using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; 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; namespace Tiku.Infrastructure.PlatformAdmin; internal sealed class PlatformAdminService( ICurrentAccessContext currentAccessContext, ITenantExecutionScope tenantExecutionScope) : IPlatformAdminService { public async Task GetOverviewAsync( PlatformAdminActor actor, CancellationToken cancellationToken = default) { await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformDashboardView, cancellationToken); return await ExecuteSystemAsync("platform overview", async dbContext => { var tenantCount = await dbContext.Tenants.CountAsync(tenant => tenant.Mode != TenantMode.PlatformOwned, cancellationToken); var activeTenantCount = await dbContext.Tenants.CountAsync(tenant => tenant.Mode != TenantMode.PlatformOwned && tenant.Status == TenantStatus.Active, cancellationToken); var suspendedTenantCount = await dbContext.Tenants.CountAsync(tenant => tenant.Mode != TenantMode.PlatformOwned && tenant.Status == TenantStatus.Suspended, cancellationToken); var orderCount = await dbContext.Orders.CountAsync(cancellationToken); var paidOrderCount = await dbContext.Orders.CountAsync(order => order.Status == OrderStatus.Paid, cancellationToken); var revenueCents = await dbContext.Orders .Where(order => order.Status == OrderStatus.Paid || order.Status == OrderStatus.PartiallyRefunded) .SumAsync(order => order.AmountCents - order.RefundedAmountCents, cancellationToken); var questionBankCount = await dbContext.QuestionBanks.CountAsync(cancellationToken); var questionCount = await dbContext.Questions.CountAsync(question => question.Status == QuestionStatus.Published, cancellationToken); var learningActiveUserCount = await dbContext.PracticeSessions .Where(session => session.StartedAt >= DateTimeOffset.UtcNow.AddDays(-7)) .Select(session => session.UserId) .Distinct() .CountAsync(cancellationToken); return new PlatformOverview( tenantCount, activeTenantCount, suspendedTenantCount, orderCount, paidOrderCount, revenueCents, questionBankCount, questionCount, learningActiveUserCount); }, cancellationToken); } 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.TenantSubscriptions .Where(subscription => subscription.TenantId == tenant.Id) .OrderByDescending(subscription => subscription.ExpiresAt) .Select(subscription => subscription.ExpiresAt) .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.TenantSubscriptions.AsNoTracking() .Where(subscription => subscription.TenantId == tenantId) .OrderByDescending(subscription => subscription.CreatedAt) .ToArrayAsync(cancellationToken); var billingProfile = await dbContext.TenantBillingProfiles.AsNoTracking() .SingleOrDefaultAsync(profile => profile.TenantId == tenantId, cancellationToken); return new PlatformTenantDetail( ToTenantItem(tenant, domains.Length, subscriptions.FirstOrDefault()?.ExpiresAt), domains.Select(ToDomainItem).ToArray(), subscriptions.Select(ToSubscriptionItem).ToArray(), billingProfile is null ? null : ToBillingProfileItem(billingProfile)); }, cancellationToken); } public async Task CreateTenantAsync( PlatformAdminActor actor, CreatePlatformTenantCommand command, CancellationToken cancellationToken = default) { await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); return await ExecuteSystemAsync("platform tenant create", async dbContext => { 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 tenant = new Tenant { Slug = slug, Name = command.Name.Trim(), LegalName = Normalize(command.LegalName), Status = command.Status, Mode = TenantMode.Saas, BillingStatus = command.BillingStatus, Metadata = JsonObjectOrDefault(command.Metadata) }; dbContext.Tenants.Add(tenant); AddAudit(dbContext, actor, "platform.tenant.created", tenant.Id, new { tenant.Slug, tenant.Name, tenant.Status, tenant.BillingStatus }); await dbContext.SaveChangesAsync(cancellationToken); return ToTenantItem(tenant, 0, null); }, 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 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; var fromBilling = tenant.BillingStatus; tenant.Status = command.Status; tenant.BillingStatus = command.BillingStatus; AddAudit(dbContext, actor, "platform.tenant.status_changed", tenant.Id, new { FromStatus = fromStatus, ToStatus = tenant.Status, FromBillingStatus = fromBilling, ToBillingStatus = tenant.BillingStatus, command.Reason }); await dbContext.SaveChangesAsync(cancellationToken); var domainCount = await dbContext.TenantDomains.CountAsync(domain => domain.TenantId == tenant.Id, cancellationToken); var expiresAt = await dbContext.TenantSubscriptions .Where(subscription => subscription.TenantId == tenant.Id) .OrderByDescending(subscription => subscription.ExpiresAt) .Select(subscription => subscription.ExpiresAt) .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 GetPlansAsync( PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default) { await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); return await ExecuteSystemAsync("platform plan list", async dbContext => { var plans = dbContext.PlatformSaasPlans.AsNoTracking(); if (!string.IsNullOrWhiteSpace(query.Status)) { plans = plans.Where(plan => plan.Status == ParsePlanStatus(query.Status)); } return new PlatformPlanList(await plans .OrderBy(plan => plan.SortOrder) .ThenBy(plan => plan.Code) .Take(Limit(query.Limit)) .ToArrayAsync(cancellationToken)); }, cancellationToken); } public async Task UpsertSubscriptionAsync( PlatformAdminActor actor, UpsertPlatformSubscriptionCommand command, CancellationToken cancellationToken = default) { await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); return await ExecuteSystemAsync("platform tenant subscription upsert", async dbContext => { await RequireTenantAsync(dbContext, command.TenantId, cancellationToken); if (!await dbContext.PlatformSaasPlans.AnyAsync(plan => plan.Code == NormalizeCode(command.PlanCode), cancellationToken)) { throw new PlatformAdminException("SaaS plan was not found.", "plan_not_found"); } var subscription = await dbContext.TenantSubscriptions.SingleOrDefaultAsync( item => item.TenantId == command.TenantId && item.PlanCode == NormalizeCode(command.PlanCode), cancellationToken); if (subscription is null) { subscription = new TenantSubscription { TenantId = command.TenantId }; dbContext.TenantSubscriptions.Add(subscription); } subscription.PlanCode = NormalizeCode(command.PlanCode); subscription.Status = command.Status; subscription.StartsAt = command.StartsAt; subscription.ExpiresAt = command.ExpiresAt; subscription.BillingCycle = Normalize(command.BillingCycle); subscription.AmountCents = command.AmountCents; subscription.Metadata = JsonObjectOrDefault(command.Metadata); AddAudit(dbContext, actor, "platform.tenant.subscription.updated", command.TenantId, new { subscription.PlanCode, subscription.Status, subscription.ExpiresAt }); await dbContext.SaveChangesAsync(cancellationToken); return ToSubscriptionItem(subscription); }, 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 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 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 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); 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 GetDunningChannelsAsync( 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.PlatformDunningNotificationChannels.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 PlatformDunningChannelList(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 UpsertDunningChannelAsync( PlatformAdminActor actor, UpsertPlatformDunningChannelCommand 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.PlatformDunningNotificationChannels.SingleOrDefaultAsync(item => item.Id == command.ChannelId.Value, cancellationToken) : await dbContext.PlatformDunningNotificationChannels.SingleOrDefaultAsync(item => item.ChannelCode == code, cancellationToken); if (channel is null) { channel = new PlatformDunningNotificationChannel { ChannelCode = code }; dbContext.PlatformDunningNotificationChannels.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.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 DisableDunningChannelAsync( PlatformAdminActor actor, DisablePlatformDunningChannelCommand command, CancellationToken cancellationToken = default) { await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken); return await ExecuteSystemAsync("platform dunning channel disable", async dbContext => { var channel = await dbContext.PlatformDunningNotificationChannels.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.dunning_channel.disabled", channel.Id, new { channel.ChannelCode, command.Reason }); await dbContext.SaveChangesAsync(cancellationToken); return ToDunningChannelItem(channel); }, cancellationToken); } public async Task GetDunningEventsAsync( 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.PlatformDunningNotificationEvents.AsNoTracking(); if (!string.IsNullOrWhiteSpace(query.Status)) { events = events.Where(item => item.Status == ParseDunningEventStatus(query.Status)); } return new PlatformDunningEventList(await events .OrderByDescending(item => item.CreatedAt) .Take(Limit(query.Limit)) .Select(item => ToDunningEventItem(item)) .ToArrayAsync(cancellationToken)); }, cancellationToken); } public async Task GetDunningEventDetailAsync( 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.PlatformDunningNotificationEvents.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 RetryDunningEventAsync( PlatformAdminActor actor, RetryPlatformDunningEventCommand command, CancellationToken cancellationToken = default) { await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken); return await ExecuteSystemAsync("platform dunning event retry", async dbContext => { var item = await dbContext.PlatformDunningNotificationEvents.SingleOrDefaultAsync(item => item.Id == command.EventId, cancellationToken) ?? throw new PlatformAdminException("Platform dunning event was not found.", "dunning_event_not_found"); item.Status = PlatformDunningNotificationStatus.Pending; item.NextAttemptAt = DateTimeOffset.UtcNow; item.LastError = null; AddAudit(dbContext, actor, "platform.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); } 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 Task ExecuteSystemAsync( string reason, Func> operation, CancellationToken cancellationToken) { return tenantExecutionScope.ExecuteAsync( null, reason, async (provider, _) => await operation(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); private static PlatformTenantSubscriptionItem ToSubscriptionItem(TenantSubscription subscription) => new( subscription.Id, subscription.TenantId, subscription.PlanCode, subscription.Status, subscription.StartsAt, subscription.ExpiresAt, subscription.BillingCycle, subscription.AmountCents, 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 PlatformDunningChannelItem ToDunningChannelItem(PlatformDunningNotificationChannel 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 PlatformDunningEventItem ToDunningEventItem(PlatformDunningNotificationEvent 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 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 PlatformSaasPlanStatus ParsePlanStatus(string status) => Enum.TryParse(status, true, out var value) ? value : throw InvalidStatus(status); private static PlatformAuditAlertStatus ParseAuditAlertStatus(string status) => Enum.TryParse(status, true, out var value) ? value : throw InvalidStatus(status); private static PlatformDunningNotificationStatus 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"); }