feat: complete SaaS commercial delivery workflows
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
using System.Text.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Npgsql;
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Commerce;
|
||||
@@ -128,12 +131,15 @@ internal sealed class PlatformAdminService(
|
||||
.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(ToDomainItem).ToArray(),
|
||||
subscriptions.Select(ToSubscriptionItem).ToArray(),
|
||||
billingProfile is null ? null : ToBillingProfileItem(billingProfile));
|
||||
billingProfile is null ? null : ToBillingProfileItem(billingProfile),
|
||||
billingPolicy is null ? null : ToBillingPolicyItem(billingPolicy));
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -143,90 +149,225 @@ internal sealed class PlatformAdminService(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform tenant create", async (provider, dbContext) =>
|
||||
var idempotencyKey = Required(command.IdempotencyKey, "Idempotency-Key");
|
||||
var requestHash = ProvisioningRequestHash(command);
|
||||
try
|
||||
{
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
var slug = NormalizeCode(command.Slug);
|
||||
if (await dbContext.Tenants.AnyAsync(tenant => tenant.Slug == slug, cancellationToken))
|
||||
return await ExecuteSystemAsync("platform tenant create", async (provider, dbContext) =>
|
||||
{
|
||||
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");
|
||||
}
|
||||
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);
|
||||
}
|
||||
if (!command.AllowTemporaryPassword && !string.IsNullOrWhiteSpace(command.TemporaryPassword))
|
||||
{
|
||||
throw new PlatformAdminException(
|
||||
"Temporary passwords are not allowed outside Development.",
|
||||
"tenant_owner_temporary_password_not_allowed");
|
||||
}
|
||||
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");
|
||||
}
|
||||
|
||||
var tenant = new Tenant
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
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 = !string.IsNullOrWhiteSpace(command.TemporaryPassword),
|
||||
EmailConfirmed = ownerEmail is not null,
|
||||
PhoneNumberConfirmed = ownerPhone is not null
|
||||
};
|
||||
var userManager = provider.GetRequiredService<UserManager<User>>();
|
||||
var createOwner = string.IsNullOrWhiteSpace(command.TemporaryPassword)
|
||||
? await userManager.CreateAsync(owner)
|
||||
: await userManager.CreateAsync(owner, command.TemporaryPassword);
|
||||
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
|
||||
});
|
||||
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;
|
||||
if (initialVersion is not null)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
subscriptionExpiresAt = now.AddDays(Math.Clamp(command.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
|
||||
});
|
||||
}
|
||||
|
||||
Guid? activationId = null;
|
||||
string? activationToken = null;
|
||||
DateTimeOffset? activationExpiresAt = null;
|
||||
if (string.IsNullOrWhiteSpace(command.TemporaryPassword))
|
||||
{
|
||||
activationToken = Base64Url(RandomNumberGenerator.GetBytes(32));
|
||||
activationExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30);
|
||||
var grant = new TenantOwnerActivationGrant
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
UserId = owner.Id,
|
||||
CreatedBy = actor.UserId,
|
||||
TokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(activationToken))).ToLowerInvariant(),
|
||||
ExpiresAt = activationExpiresAt.Value
|
||||
};
|
||||
activationId = grant.Id;
|
||||
dbContext.TenantOwnerActivationGrants.Add(grant);
|
||||
}
|
||||
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, 0, subscriptionExpiresAt),
|
||||
owner.Id,
|
||||
ownerIdentifier,
|
||||
owner.ForcePasswordChange,
|
||||
activationId,
|
||||
activationToken,
|
||||
activationExpiresAt,
|
||||
false);
|
||||
}, cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException exception) when (IsPlatformOperationIdempotencyConflict(exception))
|
||||
{
|
||||
return await ExecuteSystemAsync("platform tenant create idempotency replay", async dbContext =>
|
||||
{
|
||||
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);
|
||||
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 createOwner = await provider.GetRequiredService<UserManager<User>>()
|
||||
.CreateAsync(owner, command.TemporaryPassword);
|
||||
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
|
||||
});
|
||||
await EnsureTenantOwnerRoleAsync(dbContext, tenant.Id, owner.Id, cancellationToken);
|
||||
AddAudit(dbContext, actor, "platform.tenant.created", tenant.Id, new { tenant.Slug, tenant.Name, tenant.Status, tenant.BillingStatus });
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return new PlatformTenantProvisioningResult(
|
||||
ToTenantItem(tenant, 0, null),
|
||||
owner.Id,
|
||||
ownerIdentifier,
|
||||
true);
|
||||
}, cancellationToken);
|
||||
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<PlatformTenantItem> UpdateTenantStatusAsync(
|
||||
@@ -241,15 +382,12 @@ internal sealed class PlatformAdminService(
|
||||
.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,
|
||||
BillingStatus = tenant.BillingStatus,
|
||||
command.Reason
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
@@ -302,6 +440,59 @@ internal sealed class PlatformAdminService(
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<TenantBillingPolicyItem> GetTenantBillingPolicyAsync(
|
||||
PlatformAdminActor actor,
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform tenant billing policy get", async dbContext =>
|
||||
{
|
||||
await RequireTenantAsync(dbContext, tenantId, cancellationToken);
|
||||
var policy = await dbContext.TenantBillingPolicies.AsNoTracking()
|
||||
.SingleOrDefaultAsync(value => value.TenantId == tenantId, cancellationToken)
|
||||
?? new TenantBillingPolicy { TenantId = tenantId };
|
||||
return ToBillingPolicyItem(policy);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<TenantBillingPolicyItem> UpsertTenantBillingPolicyAsync(
|
||||
PlatformAdminActor actor,
|
||||
UpsertTenantBillingPolicyCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform tenant billing policy upsert", async dbContext =>
|
||||
{
|
||||
await RequireTenantAsync(dbContext, command.TenantId, cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(command.Reason))
|
||||
{
|
||||
throw new PlatformAdminException("Billing policy change reason is required.", "platform_billing_reason_required");
|
||||
}
|
||||
var policy = await dbContext.TenantBillingPolicies
|
||||
.SingleOrDefaultAsync(value => value.TenantId == command.TenantId, cancellationToken);
|
||||
if (policy is null)
|
||||
{
|
||||
policy = new TenantBillingPolicy { TenantId = command.TenantId };
|
||||
dbContext.TenantBillingPolicies.Add(policy);
|
||||
}
|
||||
policy.CollectionMode = command.CollectionMode;
|
||||
policy.DefaultPaymentProvider = NormalizeCode(command.DefaultPaymentProvider);
|
||||
policy.AutoGenerateRenewal = command.AutoGenerateRenewal;
|
||||
policy.RenewalLeadDays = Math.Clamp(command.RenewalLeadDays, 1, 90);
|
||||
AddAudit(dbContext, actor, "platform.tenant.billing_policy.updated", command.TenantId, new
|
||||
{
|
||||
policy.CollectionMode,
|
||||
policy.DefaultPaymentProvider,
|
||||
policy.AutoGenerateRenewal,
|
||||
policy.RenewalLeadDays,
|
||||
command.Reason
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToBillingPolicyItem(policy);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformDomainList> GetDomainsAsync(
|
||||
PlatformAdminActor actor,
|
||||
PlatformAdminQuery query,
|
||||
@@ -720,6 +911,49 @@ internal sealed class PlatformAdminService(
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<PlatformBillingDunningEventItem> AcknowledgeBillingDunningEventAsync(
|
||||
PlatformAdminActor actor,
|
||||
ResolvePlatformBillingDunningEventCommand command,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
ResolveBillingDunningEventAsync(actor, command, PlatformBillingDunningNotificationStatus.Acknowledged, cancellationToken);
|
||||
|
||||
public Task<PlatformBillingDunningEventItem> IgnoreBillingDunningEventAsync(
|
||||
PlatformAdminActor actor,
|
||||
ResolvePlatformBillingDunningEventCommand command,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
ResolveBillingDunningEventAsync(actor, command, PlatformBillingDunningNotificationStatus.Ignored, cancellationToken);
|
||||
|
||||
private async Task<PlatformBillingDunningEventItem> ResolveBillingDunningEventAsync(
|
||||
PlatformAdminActor actor,
|
||||
ResolvePlatformBillingDunningEventCommand command,
|
||||
PlatformBillingDunningNotificationStatus status,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform dunning event resolve", async dbContext =>
|
||||
{
|
||||
var item = await dbContext.PlatformBillingDunningNotificationEvents
|
||||
.SingleOrDefaultAsync(value => value.Id == command.EventId, cancellationToken)
|
||||
?? throw new PlatformAdminException("Platform dunning event was not found.", "dunning_event_not_found");
|
||||
if (item.Status == PlatformBillingDunningNotificationStatus.Processing ||
|
||||
item.Status is PlatformBillingDunningNotificationStatus.Acknowledged or PlatformBillingDunningNotificationStatus.Ignored)
|
||||
{
|
||||
throw new PlatformAdminException("Platform dunning event cannot be resolved from its current status.", "dunning_event_status_invalid");
|
||||
}
|
||||
var fromStatus = item.Status;
|
||||
item.Status = status;
|
||||
item.NextAttemptAt = null;
|
||||
AddAudit(dbContext, actor,
|
||||
status == PlatformBillingDunningNotificationStatus.Acknowledged
|
||||
? "platform.billing_dunning_event.acknowledged"
|
||||
: "platform.billing_dunning_event.ignored",
|
||||
item.Id,
|
||||
new { item.TenantId, FromStatus = fromStatus, ToStatus = status, command.Reason });
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToDunningEventItem(item);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task AssertPlatformPermissionAsync(
|
||||
PlatformAdminActor actor,
|
||||
string permissionCode,
|
||||
@@ -732,6 +966,45 @@ internal sealed class PlatformAdminService(
|
||||
}
|
||||
}
|
||||
|
||||
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 static async Task<PlatformTenantProvisioningResult> ProvisioningReplayResultAsync(
|
||||
TikuDbContext dbContext,
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenant = await dbContext.Tenants.AsNoTracking()
|
||||
.SingleAsync(value => value.Id == tenantId, cancellationToken);
|
||||
var ownerId = tenant.OwnerUserId
|
||||
?? throw new PlatformAdminException("Tenant owner was not found.", "tenant_owner_not_found");
|
||||
var owner = await dbContext.Users.AsNoTracking().SingleAsync(value => value.Id == ownerId, cancellationToken);
|
||||
var activation = await dbContext.TenantOwnerActivationGrants.AsNoTracking()
|
||||
.Where(value => value.TenantId == tenant.Id && value.UserId == ownerId && value.ConsumedAt == null)
|
||||
.OrderByDescending(value => value.CreatedAt)
|
||||
.FirstOrDefaultAsync(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,
|
||||
activation?.Id,
|
||||
null,
|
||||
activation?.ExpiresAt,
|
||||
true);
|
||||
}
|
||||
|
||||
private Task<TResult> ExecuteSystemAsync<TResult>(
|
||||
string reason,
|
||||
Func<TikuDbContext, Task<TResult>> operation,
|
||||
@@ -833,6 +1106,16 @@ internal sealed class PlatformAdminService(
|
||||
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,
|
||||
@@ -889,6 +1172,37 @@ internal sealed class PlatformAdminService(
|
||||
|
||||
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.OwnerEmail,
|
||||
command.OwnerPhone,
|
||||
command.OwnerName,
|
||||
HasTemporaryPassword = !string.IsNullOrWhiteSpace(command.TemporaryPassword),
|
||||
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 Base64Url(byte[] value) =>
|
||||
Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
|
||||
private static JsonElement JsonObjectOrDefault(JsonElement value) =>
|
||||
value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDocument.Parse("{}").RootElement.Clone();
|
||||
@@ -953,11 +1267,12 @@ internal sealed class PlatformAdminService(
|
||||
Guid ownerUserId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var featureCodes = SaasFeatureCatalog.All.ToArray();
|
||||
var existingFeatureCodes = await dbContext.SaasFeatures
|
||||
.Where(value => SaasFeatureCatalog.All.Contains(value.Code))
|
||||
.Where(value => featureCodes.Contains(value.Code))
|
||||
.Select(value => value.Code)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
dbContext.SaasFeatures.AddRange(SaasFeatureCatalog.All
|
||||
dbContext.SaasFeatures.AddRange(featureCodes
|
||||
.Except(existingFeatureCodes, StringComparer.Ordinal)
|
||||
.Select((code, index) => new SaasFeature
|
||||
{
|
||||
@@ -987,11 +1302,12 @@ internal sealed class PlatformAdminService(
|
||||
RequiredFeatureCode = PermissionModuleCatalog.RequiredFeatures[code]
|
||||
}));
|
||||
|
||||
var tenantPermissionCodes = BackendPermissions.Tenant.ToArray();
|
||||
var existingPermissionCodes = await dbContext.BackendPermissions
|
||||
.Where(value => BackendPermissions.Tenant.Contains(value.Code))
|
||||
.Where(value => tenantPermissionCodes.Contains(value.Code))
|
||||
.Select(value => value.Code)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
dbContext.BackendPermissions.AddRange(BackendPermissions.Tenant
|
||||
dbContext.BackendPermissions.AddRange(tenantPermissionCodes
|
||||
.Except(existingPermissionCodes, StringComparer.Ordinal)
|
||||
.Select(code => new BackendPermission
|
||||
{
|
||||
@@ -1013,7 +1329,7 @@ internal sealed class PlatformAdminService(
|
||||
DataScope = JsonSerializer.SerializeToElement(new { mode = "all" })
|
||||
};
|
||||
dbContext.TenantBackendRoles.Add(role);
|
||||
dbContext.TenantBackendRolePermissions.AddRange(BackendPermissions.Tenant.Select(code => new TenantBackendRolePermission
|
||||
dbContext.TenantBackendRolePermissions.AddRange(tenantPermissionCodes.Select(code => new TenantBackendRolePermission
|
||||
{
|
||||
TenantId = tenantId,
|
||||
RoleId = role.Id,
|
||||
|
||||
Reference in New Issue
Block a user