using System.Security.Cryptography; using System.Text; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Tiku.Application.PlatformAdmin; using Tiku.Application.Security; using Tiku.Domain.Identity; using Tiku.Domain.Operations; using Tiku.Domain.Platform; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Tenancy; namespace Tiku.Infrastructure.PlatformAdmin; internal sealed class TenantProvisioningAdministrationService(PlatformAdministrationDependencies dependencies) : PlatformAdministrationServiceBase(dependencies), ITenantProvisioningAdministrationService { 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, 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); } }