Files
tiku-backend.net/Tiku.Infrastructure/PlatformAdmin/TenantProvisioning/TenantProvisioningAdministrationService.cs

589 lines
33 KiB
C#

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<PlatformTenantList> GetTenantsAsync(
PlatformAdminActor actor,
PlatformAdminQuery query,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
return await ExecuteSystemAsync("platform tenant list", async dbContext =>
{
var tenants = dbContext.Tenants.AsNoTracking()
.Where(tenant => tenant.Mode != TenantMode.PlatformOwned);
if (!string.IsNullOrWhiteSpace(query.Search))
{
var search = query.Search.Trim();
tenants = tenants.Where(tenant =>
tenant.Slug.Contains(search) ||
tenant.Name.Contains(search) ||
(tenant.LegalName != null && tenant.LegalName.Contains(search)));
}
if (!string.IsNullOrWhiteSpace(query.Status))
tenants = tenants.Where(tenant => tenant.Status == ParseTenantStatus(query.Status));
var rows = await tenants
.OrderByDescending(tenant => tenant.CreatedAt)
.Take(Limit(query.Limit))
.Select(tenant => new
{
Tenant = tenant,
DomainCount = dbContext.TenantDomains.Count(domain => domain.TenantId == tenant.Id),
SubscriptionExpiresAt = dbContext.TenantSaasSubscriptions
.Where(subscription => subscription.TenantId == tenant.Id)
.OrderByDescending(subscription => subscription.CurrentPeriodEnd)
.Select(subscription => (DateTimeOffset?)subscription.CurrentPeriodEnd)
.FirstOrDefault()
})
.ToArrayAsync(cancellationToken);
return new PlatformTenantList(rows
.Select(row => ToTenantItem(row.Tenant, row.DomainCount, row.SubscriptionExpiresAt)).ToArray());
}, cancellationToken);
}
public async Task<PlatformTenantDetail> GetTenantDetailAsync(
PlatformAdminActor actor,
Guid tenantId,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
return await ExecuteSystemAsync("platform tenant detail", async dbContext =>
{
var tenant = await dbContext.Tenants.AsNoTracking()
.SingleOrDefaultAsync(item => item.Id == tenantId && item.Mode != TenantMode.PlatformOwned,
cancellationToken)
?? throw new PlatformAdminException("Tenant was not found.", "tenant_not_found");
var domains = await dbContext.TenantDomains.AsNoTracking()
.Where(domain => domain.TenantId == tenantId)
.OrderByDescending(domain => domain.IsPrimary)
.ThenBy(domain => domain.Host)
.ToArrayAsync(cancellationToken);
var subscriptions = await dbContext.TenantSaasSubscriptions.AsNoTracking()
.Where(subscription => subscription.TenantId == tenantId)
.OrderByDescending(subscription => subscription.CreatedAt)
.ToArrayAsync(cancellationToken);
var billingProfile = await dbContext.TenantBillingProfiles.AsNoTracking()
.SingleOrDefaultAsync(profile => profile.TenantId == tenantId, cancellationToken);
var billingPolicy = await dbContext.TenantBillingPolicies.AsNoTracking()
.SingleOrDefaultAsync(policy => policy.TenantId == tenantId, cancellationToken);
return new PlatformTenantDetail(
ToTenantItem(tenant, domains.Length, subscriptions.FirstOrDefault()?.CurrentPeriodEnd),
domains.Select(ToDomainItemWithInstructions).ToArray(),
subscriptions.Select(ToSubscriptionItem).ToArray(),
billingProfile is null ? null : ToBillingProfileItem(billingProfile),
billingPolicy is null ? null : ToBillingPolicyItem(billingPolicy),
await OwnerActivationStatusAsync(dbContext, tenant, cancellationToken));
}, cancellationToken);
}
public async Task<PlatformTenantProvisioningResult> CreateTenantAsync(
PlatformAdminActor actor,
CreatePlatformTenantCommand command,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
var idempotencyKey = Required(command.IdempotencyKey, "Idempotency-Key");
var requestHash = ProvisioningRequestHash(command);
try
{
return await ExecuteSystemAsync("platform tenant create", async (provider, dbContext) =>
{
var existingRequest = await dbContext.PlatformOperationIdempotencies.AsNoTracking()
.SingleOrDefaultAsync(value =>
value.ActorUserId == actor.UserId &&
value.Scope == "platform.tenant.create" &&
value.IdempotencyKey == idempotencyKey,
cancellationToken);
if (existingRequest is not null)
{
if (!string.Equals(existingRequest.RequestHash, requestHash, StringComparison.Ordinal))
throw new PlatformAdminException(
"Idempotency key was already used with a different request.",
"idempotency_conflict");
return await ProvisioningReplayResultAsync(dbContext, existingRequest.ResourceId,
cancellationToken);
}
var tenantId = Guid.NewGuid();
dbContext.PlatformOperationIdempotencies.Add(new PlatformOperationIdempotency
{
ActorUserId = actor.UserId,
Scope = "platform.tenant.create",
IdempotencyKey = idempotencyKey,
RequestHash = requestHash,
ResourceId = tenantId
});
await dbContext.SaveChangesAsync(cancellationToken);
var slug = NormalizeCode(command.Slug);
if (await dbContext.Tenants.AnyAsync(tenant => tenant.Slug == slug, cancellationToken))
throw new PlatformAdminException("Tenant slug already exists.", "tenant_slug_exists");
var ownerEmail = Normalize(command.OwnerEmail);
var ownerPhone = Normalize(command.OwnerPhone);
var ownerIdentifier = ownerEmail ?? ownerPhone;
if (ownerIdentifier is null)
throw new PlatformAdminException("Owner email or phone is required.",
"tenant_owner_identifier_required");
if (await dbContext.Users.AnyAsync(user =>
(ownerEmail != null && user.NormalizedEmail == ownerEmail.ToUpperInvariant()) ||
(ownerPhone != null && user.Phone == ownerPhone), cancellationToken))
throw new PlatformAdminException("Tenant owner already exists.", "tenant_owner_exists");
SaasOfferingVersion? initialVersion = null;
if (command.InitialOfferingVersionId.HasValue)
{
initialVersion = await dbContext.SaasOfferingVersions.AsNoTracking()
.SingleOrDefaultAsync(value =>
value.Id == command.InitialOfferingVersionId &&
value.Status == SaasOfferingVersionStatus.Published,
cancellationToken)
?? throw new PlatformAdminException(
"Initial offering version was not found or published.",
"saas_offering_version_not_found");
var offeringType = await dbContext.SaasOfferings.AsNoTracking()
.Where(value => value.Id == initialVersion.OfferingId)
.Select(value => value.Type)
.SingleAsync(cancellationToken);
if (offeringType != SaasOfferingType.BasePlan)
throw new PlatformAdminException("Initial offering must be a base plan.",
"saas_base_offering_required");
}
else
{
var now = DateTimeOffset.UtcNow;
initialVersion = await (
from offering in dbContext.SaasOfferings.AsNoTracking()
join version in dbContext.SaasOfferingVersions.AsNoTracking() on offering.Id
equals version.OfferingId
where offering.Code == NormalizeCode(provisioning.DefaultBaseOfferingCode) &&
offering.Type == SaasOfferingType.BasePlan &&
offering.Status == SaasOfferingStatus.Active &&
version.Status == SaasOfferingVersionStatus.Published &&
(version.EffectiveAt == null || version.EffectiveAt <= now)
orderby version.Version descending
select version).FirstOrDefaultAsync(cancellationToken)
?? throw new PlatformAdminException(
"The default base offering does not have an effective published version.",
"default_offering_unavailable");
}
var tenant = new Tenant
{
Id = tenantId,
Slug = slug,
Name = command.Name.Trim(),
LegalName = Normalize(command.LegalName),
Status = command.Status,
Mode = TenantMode.Saas,
BillingStatus = initialVersion is null ? command.BillingStatus : BillingStatus.Trial,
Metadata = JsonObjectOrDefault(command.Metadata)
};
dbContext.Tenants.Add(tenant);
dbContext.AuthorizationScopeVersions.Add(new AuthorizationScopeVersion
{
Realm = AuthRealm.Tenant,
TenantId = tenant.Id
});
var owner = new User
{
Email = ownerEmail,
NormalizedEmail = ownerEmail?.ToUpperInvariant(),
UserName = ownerIdentifier,
NormalizedUserName = ownerIdentifier.ToUpperInvariant(),
Phone = ownerPhone,
PhoneNumber = ownerPhone,
Name = command.OwnerName.Trim(),
PrimaryRole = "tenant_owner",
Status = UserStatus.Active,
ForcePasswordChange = true,
EmailConfirmed = ownerEmail is not null,
PhoneNumberConfirmed = ownerPhone is not null
};
var userManager = provider.GetRequiredService<UserManager<User>>();
var createOwner = await userManager.CreateAsync(owner);
if (!createOwner.Succeeded)
throw new PlatformAdminException(
string.Join("; ", createOwner.Errors.Select(error => error.Description)),
"tenant_owner_password_invalid");
tenant.OwnerUserId = owner.Id;
dbContext.TenantMemberships.Add(new TenantMembership
{
TenantId = tenant.Id,
UserId = owner.Id,
Role = TenantRole.TenantOwner,
Status = MembershipStatus.Active
});
dbContext.TenantAuthPolicies.Add(new TenantAuthPolicy
{
TenantId = tenant.Id,
AllowExternalStudentSelfRegistration = false
});
var primaryDomain = CreatePrimaryDomain(tenant.Id, command.PrimaryDomainHost);
if (await dbContext.TenantDomains.AnyAsync(value => value.Host == primaryDomain.Host,
cancellationToken))
throw new PlatformAdminException("Primary domain is already assigned.", "tenant_domain_exists");
dbContext.TenantDomains.Add(primaryDomain);
dbContext.TenantFrontendConfigs.Add(TenantFrontendConfigDefaults.Create(tenant.Id, tenant.Name));
await EnsureTenantOwnerRoleAsync(dbContext, tenant.Id, owner.Id, cancellationToken);
var policy = new TenantBillingPolicy
{
TenantId = tenant.Id,
CollectionMode = command.CollectionMode,
DefaultPaymentProvider = NormalizeCode(command.DefaultPaymentProvider),
AutoGenerateRenewal = command.AutoGenerateRenewal,
RenewalLeadDays = Math.Clamp(command.RenewalLeadDays, 1, 90)
};
dbContext.TenantBillingPolicies.Add(policy);
DateTimeOffset? subscriptionExpiresAt = null;
{
var now = DateTimeOffset.UtcNow;
var trialDays = command.TrialDays ?? provisioning.DefaultTrialDays;
subscriptionExpiresAt = now.AddDays(Math.Clamp(trialDays, 1, 365));
var subscription = new TenantSaasSubscription
{
TenantId = tenant.Id,
BaseOfferingVersionId = initialVersion!.Id,
Status = TenantSaasSubscriptionStatus.Trial,
StartsAt = now,
CurrentPeriodStart = now,
CurrentPeriodEnd = subscriptionExpiresAt.Value,
LifecycleVersion = 1
};
dbContext.TenantSaasSubscriptions.Add(subscription);
dbContext.TenantSaasSubscriptionItems.Add(new TenantSaasSubscriptionItem
{
TenantId = tenant.Id,
SubscriptionId = subscription.Id,
OfferingVersionId = initialVersion.Id,
ItemType = TenantSaasSubscriptionItemType.BasePlan,
Status = TenantSaasSubscriptionItemStatus.Active,
StartsAt = now,
EndsAt = subscriptionExpiresAt.Value
});
}
AddAudit(dbContext, actor, "platform.tenant.created", tenant.Id,
new { tenant.Slug, tenant.Name, tenant.Status, tenant.BillingStatus });
await dbContext.SaveChangesAsync(cancellationToken);
return new PlatformTenantProvisioningResult(
ToTenantItem(tenant, 1, subscriptionExpiresAt),
owner.Id,
ownerIdentifier,
owner.ForcePasswordChange,
ToDomainItemWithInstructions(primaryDomain),
new PlatformOwnerActivationStatus("domain_pending", null, null),
false);
}, cancellationToken);
}
catch (DbUpdateException exception) when (IsPlatformOperationIdempotencyConflict(exception))
{
return await ExecuteSystemAsync("platform tenant create idempotency replay", async dbContext =>
{
var existingRequest = await dbContext.PlatformOperationIdempotencies.AsNoTracking()
.SingleAsync(value => value.ActorUserId == actor.UserId &&
value.Scope == "platform.tenant.create" &&
value.IdempotencyKey == idempotencyKey,
cancellationToken);
if (!string.Equals(existingRequest.RequestHash, requestHash, StringComparison.Ordinal))
throw new PlatformAdminException(
"Idempotency key was already used with a different request.",
"idempotency_conflict");
return await ProvisioningReplayResultAsync(dbContext, existingRequest.ResourceId, cancellationToken);
}, cancellationToken);
}
}
public async Task<PlatformTenantDomainItem> ReplacePrimaryDomainAsync(
PlatformAdminActor actor,
ReplacePlatformPrimaryDomainCommand command,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
if (string.IsNullOrWhiteSpace(command.Reason))
throw new PlatformAdminException("Primary domain replacement reason is required.",
"domain_change_reason_required");
return await ExecuteSystemAsync("platform primary domain replace", async dbContext =>
{
var tenant = await dbContext.Tenants.SingleOrDefaultAsync(value =>
value.Id == command.TenantId && value.Mode != TenantMode.PlatformOwned, cancellationToken)
?? throw new PlatformAdminException("Tenant was not found.", "tenant_not_found");
var existing = await dbContext.TenantDomains
.Where(value => value.TenantId == tenant.Id && value.IsPrimary)
.ToArrayAsync(cancellationToken);
foreach (var domain in existing)
{
domain.IsPrimary = false;
domain.Status = TenantDomainStatus.Disabled;
}
var now = DateTimeOffset.UtcNow;
await dbContext.TenantOwnerActivationGrants
.Where(value => value.TenantId == tenant.Id && value.ConsumedAt == null && value.RevokedAt == null)
.ExecuteUpdateAsync(setters => setters
.SetProperty(value => value.RevokedAt, now)
.SetProperty(value => value.RevokedBy, actor.UserId)
.SetProperty(value => value.RevocationReason, "Primary domain replaced: " + command.Reason),
cancellationToken);
var next = CreatePrimaryDomain(tenant.Id, command.Host);
if (await dbContext.TenantDomains.AnyAsync(value => value.Host == next.Host, cancellationToken))
throw new PlatformAdminException("Primary domain is already assigned.", "tenant_domain_exists");
dbContext.TenantDomains.Add(next);
AddAudit(dbContext, actor, "platform.tenant_primary_domain.replaced", tenant.Id,
new { next.Id, next.Host, command.Reason });
await dbContext.SaveChangesAsync(cancellationToken);
return ToDomainItemWithInstructions(next);
}, cancellationToken);
}
public async Task<PlatformOwnerActivationLinkResult> IssueOwnerActivationLinkAsync(
PlatformAdminActor actor,
IssuePlatformOwnerActivationLinkCommand command,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
var idempotencyKey = Required(command.IdempotencyKey, "Idempotency-Key");
if (string.IsNullOrWhiteSpace(command.Reason))
throw new PlatformAdminException("Owner activation issuance reason is required.",
"owner_activation_reason_required");
return await ExecuteSystemAsync("platform owner activation link issue", async dbContext =>
{
var lockKey = $"owner-activation:{command.TenantId:N}";
await dbContext.Database.ExecuteSqlInterpolatedAsync(
$"select pg_advisory_xact_lock(hashtextextended({lockKey}, 0))",
cancellationToken);
var existingRequest = await dbContext.PlatformOperationIdempotencies.AsNoTracking()
.SingleOrDefaultAsync(value => value.ActorUserId == actor.UserId &&
value.Scope == "platform.tenant.owner_activation.issue" &&
value.IdempotencyKey == idempotencyKey, cancellationToken);
var requestHash = OwnerActivationRequestHash(command);
if (existingRequest is not null)
{
if (!string.Equals(existingRequest.RequestHash, requestHash, StringComparison.Ordinal))
throw new PlatformAdminException(
"Idempotency key was already used with a different request.", "idempotency_conflict");
return await OwnerActivationReplayResultAsync(dbContext, existingRequest.ResourceId, cancellationToken);
}
var tenant = await dbContext.Tenants.SingleOrDefaultAsync(value =>
value.Id == command.TenantId && value.Status == TenantStatus.Active &&
value.Mode != TenantMode.PlatformOwned, cancellationToken)
?? throw new PlatformAdminException("An active tenant was not found.", "tenant_not_active");
var ownerId = tenant.OwnerUserId
?? throw new PlatformAdminException("Tenant owner was not found.", "tenant_owner_not_found");
var owner = await dbContext.Users.SingleAsync(value => value.Id == ownerId, cancellationToken);
if (owner.PasswordHash is not null || !owner.ForcePasswordChange)
throw new PlatformAdminException("Tenant owner is already activated.", "owner_already_activated");
var primaryDomain = await dbContext.TenantDomains.SingleOrDefaultAsync(value =>
value.TenantId == tenant.Id && value.IsPrimary &&
value.Status == TenantDomainStatus.Active,
cancellationToken)
?? throw new PlatformAdminException("The primary domain is not active.",
"primary_domain_not_active");
var now = DateTimeOffset.UtcNow;
var subscriptionActive = await dbContext.TenantSaasSubscriptions.AsNoTracking().AnyAsync(value =>
value.TenantId == tenant.Id &&
(value.Status == TenantSaasSubscriptionStatus.Trial ||
value.Status == TenantSaasSubscriptionStatus.Active) &&
value.StartsAt <= now && value.CurrentPeriodEnd > now, cancellationToken);
if (!subscriptionActive)
throw new PlatformAdminException("An active trial or subscription is required.",
"subscription_inactive");
var current = await dbContext.TenantOwnerActivationGrants
.Where(value => value.TenantId == tenant.Id && value.UserId == ownerId &&
value.ConsumedAt == null && value.RevokedAt == null)
.OrderByDescending(value => value.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
if (current is not null && current.ExpiresAt > now && !command.ReplaceExisting)
throw new PlatformAdminException("An owner activation link is already active.",
"owner_activation_already_issued");
if (current is not null)
{
current.RevokedAt = now;
current.RevokedBy = actor.UserId;
current.RevocationReason = command.ReplaceExisting
? command.Reason.Trim()
: "Expired activation link replaced.";
}
var token = Base64Url(RandomNumberGenerator.GetBytes(32));
var grant = new TenantOwnerActivationGrant
{
TenantId = tenant.Id,
UserId = ownerId,
CreatedBy = actor.UserId,
DomainId = primaryDomain.Id,
TokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))).ToLowerInvariant(),
ExpiresAt = now.AddMinutes(provisioning.OwnerActivationMinutes)
};
dbContext.TenantOwnerActivationGrants.Add(grant);
dbContext.PlatformOperationIdempotencies.Add(new PlatformOperationIdempotency
{
ActorUserId = actor.UserId,
Scope = "platform.tenant.owner_activation.issue",
IdempotencyKey = idempotencyKey,
RequestHash = requestHash,
ResourceId = grant.Id
});
AddAudit(dbContext, actor, "platform.tenant_owner_activation.issued", tenant.Id,
new
{
grant.Id, DomainId = primaryDomain.Id, grant.ExpiresAt, command.ReplaceExisting, command.Reason
});
await dbContext.SaveChangesAsync(cancellationToken);
return new PlatformOwnerActivationLinkResult(
grant.Id,
BuildOwnerActivationUrl(primaryDomain.Host, grant.Id, token),
grant.ExpiresAt,
false);
}, cancellationToken);
}
public async Task<PlatformTenantItem> UpdateTenantStatusAsync(
PlatformAdminActor actor,
UpdatePlatformTenantStatusCommand command,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
return await ExecuteSystemAsync("platform tenant status update", async (provider, dbContext) =>
{
var tenant = await dbContext.Tenants
.SingleOrDefaultAsync(
item => item.Id == command.TenantId && item.Mode != TenantMode.PlatformOwned,
cancellationToken)
?? throw new PlatformAdminException("Tenant was not found.", "tenant_not_found");
var fromStatus = tenant.Status;
tenant.Status = command.Status;
AddAudit(dbContext, actor, "platform.tenant.status_changed", tenant.Id, new
{
FromStatus = fromStatus,
ToStatus = tenant.Status,
tenant.BillingStatus,
command.Reason
});
await dbContext.SaveChangesAsync(cancellationToken);
await provider.GetRequiredService<ITenantFeatureCacheInvalidator>()
.InvalidateAsync(tenant.Id, cancellationToken);
await provider.GetRequiredService<IAuthorizationStateInvalidator>()
.InvalidateTenantAsync(tenant.Id, cancellationToken);
var domainCount =
await dbContext.TenantDomains.CountAsync(domain => domain.TenantId == tenant.Id, cancellationToken);
var expiresAt = await dbContext.TenantSaasSubscriptions
.Where(subscription => subscription.TenantId == tenant.Id)
.OrderByDescending(subscription => subscription.CurrentPeriodEnd)
.Select(subscription => (DateTimeOffset?)subscription.CurrentPeriodEnd)
.FirstOrDefaultAsync(cancellationToken);
return ToTenantItem(tenant, domainCount, expiresAt);
}, cancellationToken);
}
public async Task<TenantBillingProfileItem> UpsertTenantBillingProfileAsync(
PlatformAdminActor actor,
UpsertPlatformTenantBillingProfileCommand command,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
return await ExecuteSystemAsync("platform tenant billing profile upsert", async dbContext =>
{
await RequireTenantAsync(dbContext, command.TenantId, cancellationToken);
var profile = await dbContext.TenantBillingProfiles.SingleOrDefaultAsync(
item => item.TenantId == command.TenantId,
cancellationToken);
if (profile is null)
{
profile = new TenantBillingProfile { TenantId = command.TenantId };
dbContext.TenantBillingProfiles.Add(profile);
}
profile.BillingName = Normalize(command.BillingName);
profile.TaxId = Normalize(command.TaxId);
profile.ContactName = Normalize(command.ContactName);
profile.ContactPhone = Normalize(command.ContactPhone);
profile.ContactEmail = Normalize(command.ContactEmail);
profile.BillingAddress = Normalize(command.BillingAddress);
profile.InvoiceTitle = Normalize(command.InvoiceTitle);
profile.InvoiceType = command.InvoiceType;
profile.BankName = Normalize(command.BankName);
profile.BankAccountMasked = MaskBankAccount(command.BankAccountMasked);
profile.Metadata = JsonObjectOrDefault(command.Metadata);
AddAudit(dbContext, actor, "platform.tenant.billing_profile.updated", command.TenantId,
new { profile.BillingName, profile.InvoiceType });
await dbContext.SaveChangesAsync(cancellationToken);
return ToBillingProfileItem(profile);
}, cancellationToken);
}
public async Task<TenantBillingPolicyItem> GetTenantBillingPolicyAsync(
PlatformAdminActor actor,
Guid tenantId,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
return await ExecuteSystemAsync("platform tenant billing policy get", async dbContext =>
{
await RequireTenantAsync(dbContext, tenantId, cancellationToken);
var policy = await dbContext.TenantBillingPolicies.AsNoTracking()
.SingleOrDefaultAsync(value => value.TenantId == tenantId, cancellationToken)
?? new TenantBillingPolicy { TenantId = tenantId };
return ToBillingPolicyItem(policy);
}, cancellationToken);
}
public async Task<TenantBillingPolicyItem> UpsertTenantBillingPolicyAsync(
PlatformAdminActor actor,
UpsertTenantBillingPolicyCommand command,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
return await ExecuteSystemAsync("platform tenant billing policy upsert", async dbContext =>
{
await RequireTenantAsync(dbContext, command.TenantId, cancellationToken);
if (string.IsNullOrWhiteSpace(command.Reason))
throw new PlatformAdminException("Billing policy change reason is required.",
"platform_billing_reason_required");
var policy = await dbContext.TenantBillingPolicies
.SingleOrDefaultAsync(value => value.TenantId == command.TenantId, cancellationToken);
if (policy is null)
{
policy = new TenantBillingPolicy { TenantId = command.TenantId };
dbContext.TenantBillingPolicies.Add(policy);
}
policy.CollectionMode = command.CollectionMode;
policy.DefaultPaymentProvider = NormalizeCode(command.DefaultPaymentProvider);
policy.AutoGenerateRenewal = command.AutoGenerateRenewal;
policy.RenewalLeadDays = Math.Clamp(command.RenewalLeadDays, 1, 90);
AddAudit(dbContext, actor, "platform.tenant.billing_policy.updated", command.TenantId, new
{
policy.CollectionMode,
policy.DefaultPaymentProvider,
policy.AutoGenerateRenewal,
policy.RenewalLeadDays,
command.Reason
});
await dbContext.SaveChangesAsync(cancellationToken);
return ToBillingPolicyItem(policy);
}, cancellationToken);
}
}