forked from gongxuegit/tiku-backend.net
feat(saas): implement marketplace and tenant onboarding
This commit is contained in:
962
Tiku.IntegrationTests/Api/SaasBillingLifecycleTests.cs
Normal file
962
Tiku.IntegrationTests/Api/SaasBillingLifecycleTests.cs
Normal file
@@ -0,0 +1,962 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.PlatformBilling;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class SaasBillingLifecycleTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Platform_can_publish_feature_limit_and_immutable_offering_version()
|
||||
{
|
||||
await using var factory = PlatformFactory();
|
||||
var platform = await SeedPlatformAdminAsync(factory);
|
||||
using var client = factory.CreateClient();
|
||||
client.UseAccessToken(await client.LoginAsPlatformAsync(platform.Email));
|
||||
|
||||
var feature = await client.PutAsJsonAsync(
|
||||
"/api/platform-admin/saas/features",
|
||||
new UpsertSaasFeatureDto(
|
||||
null,
|
||||
SaasFeatureCatalog.PrivateQuestionBank,
|
||||
"私有题库",
|
||||
"content",
|
||||
"租户私有题库",
|
||||
20_000,
|
||||
"CNY",
|
||||
SaasFeatureStatus.Active,
|
||||
10));
|
||||
var limit = await client.PutAsJsonAsync(
|
||||
"/api/platform-admin/saas/feature-limits",
|
||||
new UpsertSaasFeatureLimitDto(
|
||||
null,
|
||||
SaasQuotaMetricCatalog.PrivateQuestionCount,
|
||||
SaasFeatureCatalog.PrivateQuestionBank,
|
||||
"私有题目数",
|
||||
"count",
|
||||
SaasFeatureLimitKind.Current,
|
||||
80,
|
||||
true));
|
||||
var offering = await client.PutAsJsonAsync(
|
||||
"/api/platform-admin/saas/offerings",
|
||||
new UpsertSaasOfferingDto(
|
||||
null,
|
||||
$"private-bank-{Guid.NewGuid():N}",
|
||||
"题库基础套餐",
|
||||
SaasOfferingType.BasePlan,
|
||||
SaasOfferingStatus.Draft,
|
||||
null,
|
||||
10));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, feature.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, limit.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, offering.StatusCode);
|
||||
var offeringId = await ReadGuidAsync(offering, "id");
|
||||
var versionRequest = new UpsertSaasOfferingVersionDto(
|
||||
null,
|
||||
offeringId,
|
||||
PlatformBillingCycle.Yearly,
|
||||
24_000,
|
||||
20_000,
|
||||
"CNY",
|
||||
null,
|
||||
[SaasFeatureCatalog.PrivateQuestionBank],
|
||||
new Dictionary<string, long>
|
||||
{
|
||||
[SaasQuotaMetricCatalog.PrivateQuestionCount] = 10_000
|
||||
},
|
||||
JsonDefaults.Object());
|
||||
var draft = await client.PutAsJsonAsync("/api/platform-admin/saas/offering-versions", versionRequest);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, draft.StatusCode);
|
||||
var versionId = await ReadGuidAsync(draft, "id");
|
||||
var publish = await client.PostAsync($"/api/platform-admin/saas/offering-versions/{versionId}/publish", null);
|
||||
var mutatePublished = await client.PutAsJsonAsync(
|
||||
"/api/platform-admin/saas/offering-versions",
|
||||
versionRequest with { Id = versionId, AmountCents = 19_000 });
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, publish.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Conflict, mutatePublished.StatusCode);
|
||||
Assert.Equal("saas_offering_version_immutable", await ReadCodeAsync(mutatePublished));
|
||||
|
||||
using var scope = factory.CreateSystemScope("Verify immutable SaaS catalog version");
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var stored = await dbContext.SaasOfferingVersions.AsNoTracking().SingleAsync(value => value.Id == versionId);
|
||||
Assert.Equal(SaasOfferingVersionStatus.Published, stored.Status);
|
||||
Assert.Equal(20_000, stored.AmountCents);
|
||||
Assert.True(await dbContext.SaasOfferingVersionLimits.AnyAsync(value =>
|
||||
value.OfferingVersionId == versionId &&
|
||||
value.MetricCode == SaasQuotaMetricCatalog.PrivateQuestionCount &&
|
||||
value.LimitValue == 10_000));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_can_purchase_manual_plan_and_other_tenant_cannot_read_order()
|
||||
{
|
||||
await using var factory = PlatformFactory();
|
||||
var platform = await SeedPlatformAdminAsync(factory);
|
||||
var platformTenantId = Guid.NewGuid();
|
||||
var tenantA = await SeedTenantAdminWithoutSubscriptionAsync(factory, "billing-a");
|
||||
await factory.SeedAsync(new Tenant
|
||||
{
|
||||
Id = platformTenantId,
|
||||
Slug = $"platform-{platformTenantId:N}",
|
||||
Name = "Platform Owner",
|
||||
Mode = TenantMode.PlatformOwned,
|
||||
Status = TenantStatus.Active
|
||||
});
|
||||
var catalog = await SeedPublishedPlanAsync(factory, SaasFeatureCatalog.Exam, limit: null);
|
||||
|
||||
using var tenantClient = factory.CreateClient();
|
||||
tenantClient.UseAccessToken(await tenantClient.LoginAsTenantAsync(tenantA.TenantId, tenantA.Phone));
|
||||
var catalogResponse = await tenantClient.GetAsync("/api/tenant-billing/catalog");
|
||||
var quoteIdempotencyKey = $"quote-{Guid.NewGuid():N}";
|
||||
var quoteRequest = new CreatePlatformBillingQuoteDto(
|
||||
catalog.VersionId,
|
||||
[],
|
||||
PlatformBillingOrderPurpose.NewSubscription,
|
||||
quoteIdempotencyKey);
|
||||
var quoteResponse = await tenantClient.PostAsJsonAsync(
|
||||
"/api/tenant-billing/quotes",
|
||||
quoteRequest);
|
||||
var repeatedQuoteResponse = await tenantClient.PostAsJsonAsync(
|
||||
"/api/tenant-billing/quotes",
|
||||
quoteRequest);
|
||||
Assert.Equal(HttpStatusCode.OK, catalogResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, quoteResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, repeatedQuoteResponse.StatusCode);
|
||||
var quoteId = await ReadGuidAsync(quoteResponse, "id");
|
||||
Assert.Equal(quoteId, await ReadGuidAsync(repeatedQuoteResponse, "id"));
|
||||
|
||||
var idempotencyKey = $"order-{Guid.NewGuid():N}";
|
||||
var orderResponse = await tenantClient.PostAsJsonAsync(
|
||||
"/api/tenant-billing/orders",
|
||||
new CreatePlatformBillingOrderDto(quoteId, idempotencyKey));
|
||||
var repeatedOrder = await tenantClient.PostAsJsonAsync(
|
||||
"/api/tenant-billing/orders",
|
||||
new CreatePlatformBillingOrderDto(quoteId, idempotencyKey));
|
||||
Assert.Equal(HttpStatusCode.OK, orderResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, repeatedOrder.StatusCode);
|
||||
var orderNo = await ReadStringAsync(orderResponse, "orderNo");
|
||||
Assert.Equal(orderNo, await ReadStringAsync(repeatedOrder, "orderNo"));
|
||||
|
||||
var paymentResponse = await tenantClient.PostAsJsonAsync(
|
||||
$"/api/tenant-billing/orders/{orderNo}/payments",
|
||||
new CreatePlatformBillingPaymentDto(
|
||||
"manual",
|
||||
"bank_transfer",
|
||||
$"payment-{Guid.NewGuid():N}",
|
||||
null,
|
||||
null,
|
||||
null));
|
||||
Assert.Equal(HttpStatusCode.OK, paymentResponse.StatusCode);
|
||||
var paymentId = await ReadGuidAsync(paymentResponse, "id");
|
||||
|
||||
using var platformClient = factory.CreateClient();
|
||||
platformClient.UseAccessToken(await platformClient.LoginAsPlatformAsync(platform.Email));
|
||||
var confirmation = new ConfirmManualPlatformPaymentDto(
|
||||
paymentId,
|
||||
$"manual-{Guid.NewGuid():N}",
|
||||
DateTimeOffset.UtcNow,
|
||||
"integration test receipt");
|
||||
var confirm = await platformClient.PostAsJsonAsync(
|
||||
"/api/platform-admin/saas/payments/manual/confirm",
|
||||
confirmation);
|
||||
var repeatedConfirm = await platformClient.PostAsJsonAsync(
|
||||
"/api/platform-admin/saas/payments/manual/confirm",
|
||||
confirmation);
|
||||
Assert.Equal(HttpStatusCode.OK, confirm.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, repeatedConfirm.StatusCode);
|
||||
|
||||
var subscription = await tenantClient.GetAsync("/api/tenant-billing/subscription");
|
||||
Assert.Equal(HttpStatusCode.OK, subscription.StatusCode);
|
||||
Assert.Equal("Active", await ReadStringAsync(subscription, "status"));
|
||||
|
||||
var tenantB = await SeedTenantAdminWithoutSubscriptionAsync(factory, "billing-b");
|
||||
using var tenantBClient = factory.CreateClient();
|
||||
tenantBClient.UseAccessToken(await tenantBClient.LoginAsTenantAsync(tenantB.TenantId, tenantB.Phone));
|
||||
var crossTenantOrder = await tenantBClient.GetAsync($"/api/tenant-billing/orders/{orderNo}");
|
||||
Assert.Equal(HttpStatusCode.NotFound, crossTenantOrder.StatusCode);
|
||||
|
||||
using var scope = factory.CreateSystemScope("Verify SaaS billing settlement idempotency");
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.Single(await dbContext.TenantSaasSubscriptions.AsNoTracking()
|
||||
.Where(value => value.TenantId == tenantA.TenantId)
|
||||
.ToArrayAsync());
|
||||
Assert.Single(await dbContext.PlatformBillingInvoices.AsNoTracking()
|
||||
.Where(value => value.TenantId == tenantA.TenantId)
|
||||
.ToArrayAsync());
|
||||
Assert.Single(await dbContext.PlatformBillingPaymentEvents.AsNoTracking()
|
||||
.Where(value => value.TenantId == tenantA.TenantId && value.PaymentId == paymentId)
|
||||
.ToArrayAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_role_cannot_bind_permission_for_unpurchased_feature()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var tenant = await SeedTenantAdminWithoutSubscriptionAsync(factory, "role-feature");
|
||||
using var client = factory.CreateClient();
|
||||
client.UseAccessToken(await client.LoginAsTenantAsync(tenant.TenantId, tenant.Phone));
|
||||
var roleResponse = await client.PostAsJsonAsync(
|
||||
"/api/backoffice/tenant/roles",
|
||||
new UpsertBackofficeRoleDto
|
||||
{
|
||||
Code = $"content_editor_{Guid.NewGuid():N}",
|
||||
Name = "内容编辑",
|
||||
Status = BackendRoleStatus.Active,
|
||||
DataScope = JsonDefaults.Object()
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.OK, roleResponse.StatusCode);
|
||||
var roleId = await ReadGuidAsync(roleResponse, "id");
|
||||
|
||||
var bind = await client.PutAsJsonAsync(
|
||||
$"/api/backoffice/tenant/roles/{roleId}/bindings",
|
||||
new ReplaceRoleBindingsDto
|
||||
{
|
||||
PermissionCodes = [BackendPermissions.TenantContentManage],
|
||||
MenuCodes = []
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, bind.StatusCode);
|
||||
Assert.Equal("feature_not_available", await ReadCodeAsync(bind));
|
||||
using var scope = factory.CreateSystemScope("Verify rejected tenant role binding");
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.False(await dbContext.TenantBackendRolePermissions.AnyAsync(value =>
|
||||
value.TenantId == tenant.TenantId &&
|
||||
value.RoleId == roleId &&
|
||||
value.PermissionCode == BackendPermissions.TenantContentManage));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Video_only_subscription_can_bind_video_permission_without_private_question_permission()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var tenant = await SeedTenantAdminWithoutSubscriptionAsync(factory, "video-role-feature");
|
||||
var graph = CreatePublishedPlan(SaasFeatureCatalog.Video);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
await factory.SeedAsync(
|
||||
graph.Offering,
|
||||
graph.Version,
|
||||
graph.Entitlement,
|
||||
new TenantSaasSubscription
|
||||
{
|
||||
Id = graph.SubscriptionId,
|
||||
TenantId = tenant.TenantId,
|
||||
BaseOfferingVersionId = graph.Version.Id,
|
||||
Status = TenantSaasSubscriptionStatus.Active,
|
||||
StartsAt = now.AddDays(-1),
|
||||
CurrentPeriodStart = now.AddDays(-1),
|
||||
CurrentPeriodEnd = now.AddMonths(1)
|
||||
},
|
||||
new TenantSaasSubscriptionItem
|
||||
{
|
||||
TenantId = tenant.TenantId,
|
||||
SubscriptionId = graph.SubscriptionId,
|
||||
OfferingVersionId = graph.Version.Id,
|
||||
ItemType = TenantSaasSubscriptionItemType.BasePlan,
|
||||
Status = TenantSaasSubscriptionItemStatus.Active,
|
||||
StartsAt = now.AddDays(-1),
|
||||
EndsAt = now.AddMonths(1)
|
||||
});
|
||||
await PublishFixtureVersionAsync(factory, graph.Version.Id);
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
client.UseAccessToken(await client.LoginAsTenantAsync(tenant.TenantId, tenant.Phone));
|
||||
var roleResponse = await client.PostAsJsonAsync(
|
||||
"/api/backoffice/tenant/roles",
|
||||
new UpsertBackofficeRoleDto
|
||||
{
|
||||
Code = $"video_editor_{Guid.NewGuid():N}",
|
||||
Name = "视频编辑",
|
||||
Status = BackendRoleStatus.Active,
|
||||
DataScope = JsonDefaults.Object()
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.OK, roleResponse.StatusCode);
|
||||
var roleId = await ReadGuidAsync(roleResponse, "id");
|
||||
|
||||
var videoBinding = await client.PutAsJsonAsync(
|
||||
$"/api/backoffice/tenant/roles/{roleId}/bindings",
|
||||
new ReplaceRoleBindingsDto
|
||||
{
|
||||
PermissionCodes = [BackendPermissions.TenantVideoManage],
|
||||
MenuCodes = []
|
||||
});
|
||||
var questionBinding = await client.PutAsJsonAsync(
|
||||
$"/api/backoffice/tenant/roles/{roleId}/bindings",
|
||||
new ReplaceRoleBindingsDto
|
||||
{
|
||||
PermissionCodes = [BackendPermissions.TenantContentManage],
|
||||
MenuCodes = []
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, videoBinding.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, questionBinding.StatusCode);
|
||||
Assert.Equal("feature_not_available", await ReadCodeAsync(questionBinding));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Quota_warns_at_eighty_percent_and_concurrent_consumers_cannot_exceed_limit()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var tenantId = Guid.NewGuid();
|
||||
await SeedQuotaSubscriptionAsync(factory, tenantId, limit: 10);
|
||||
|
||||
using (var scope = factory.CreateSystemScope("Seed SaaS quota usage"))
|
||||
{
|
||||
var featureAccess = scope.ServiceProvider.GetRequiredService<IFeatureAccessService>();
|
||||
Assert.True(await featureAccess.TryConsumeQuotaAsync(
|
||||
tenantId,
|
||||
SaasQuotaMetricCatalog.ExportCount,
|
||||
8));
|
||||
var atWarning = Assert.Single(await featureAccess.GetQuotaSummaryAsync(tenantId));
|
||||
Assert.Equal(80, atWarning.UsedPercent);
|
||||
Assert.True(atWarning.Warning);
|
||||
Assert.False(atWarning.Exceeded);
|
||||
}
|
||||
|
||||
async Task<bool> ConsumeFinalCapacityAsync()
|
||||
{
|
||||
using var scope = factory.CreateSystemScope("Race SaaS quota consumption");
|
||||
return await scope.ServiceProvider.GetRequiredService<IFeatureAccessService>()
|
||||
.TryConsumeQuotaAsync(tenantId, SaasQuotaMetricCatalog.ExportCount, 2);
|
||||
}
|
||||
|
||||
var results = await Task.WhenAll(ConsumeFinalCapacityAsync(), ConsumeFinalCapacityAsync());
|
||||
Assert.Equal(1, results.Count(value => value));
|
||||
|
||||
using var verificationScope = factory.CreateSystemScope("Verify SaaS quota atomicity");
|
||||
var finalUsage = Assert.Single(await verificationScope.ServiceProvider
|
||||
.GetRequiredService<IFeatureAccessService>()
|
||||
.GetQuotaSummaryAsync(tenantId));
|
||||
Assert.Equal(10, finalUsage.UsedValue);
|
||||
Assert.True(finalUsage.Warning);
|
||||
Assert.True(finalUsage.Exceeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Onboarding_is_ready_when_required_delivery_steps_are_complete()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var tenantId = Guid.NewGuid();
|
||||
var ownerId = Guid.NewGuid();
|
||||
var phone = $"139{Random.Shared.Next(10_000_000, 99_999_999)}";
|
||||
var plan = CreatePublishedPlan(SaasFeatureCatalog.Exam);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
await factory.SeedAsync(
|
||||
new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = tenantId.ToString("N"),
|
||||
Name = "Onboarding Tenant",
|
||||
OwnerUserId = ownerId,
|
||||
Status = TenantStatus.Active,
|
||||
BillingStatus = BillingStatus.Active
|
||||
},
|
||||
new User
|
||||
{
|
||||
Id = ownerId,
|
||||
Phone = phone,
|
||||
Name = "Tenant Owner",
|
||||
ForcePasswordChange = false
|
||||
}.WithTestPassword(),
|
||||
new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = ownerId,
|
||||
Role = TenantRole.TenantOwner,
|
||||
Status = MembershipStatus.Active
|
||||
},
|
||||
plan.Feature,
|
||||
plan.Offering,
|
||||
plan.Version,
|
||||
plan.Entitlement,
|
||||
new TenantSaasSubscription
|
||||
{
|
||||
Id = plan.SubscriptionId,
|
||||
TenantId = tenantId,
|
||||
BaseOfferingVersionId = plan.Version.Id,
|
||||
Status = TenantSaasSubscriptionStatus.Active,
|
||||
StartsAt = now.AddDays(-1),
|
||||
CurrentPeriodStart = now.AddDays(-1),
|
||||
CurrentPeriodEnd = now.AddMonths(1)
|
||||
},
|
||||
new TenantSaasSubscriptionItem
|
||||
{
|
||||
TenantId = tenantId,
|
||||
SubscriptionId = plan.SubscriptionId,
|
||||
OfferingVersionId = plan.Version.Id,
|
||||
ItemType = TenantSaasSubscriptionItemType.BasePlan,
|
||||
Status = TenantSaasSubscriptionItemStatus.Active,
|
||||
StartsAt = now.AddDays(-1),
|
||||
EndsAt = now.AddMonths(1)
|
||||
},
|
||||
new TenantDomain
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Host = $"{tenantId:N}.example.test",
|
||||
Status = TenantDomainStatus.Active,
|
||||
IsPrimary = true,
|
||||
VerifiedAt = now,
|
||||
DnsVerifiedAt = now,
|
||||
TlsReadyAt = now
|
||||
},
|
||||
new TenantFrontendConfig
|
||||
{
|
||||
TenantId = tenantId,
|
||||
PublishedAt = now,
|
||||
ConfigVersion = 1
|
||||
},
|
||||
new TenantAuthPolicy
|
||||
{
|
||||
TenantId = tenantId,
|
||||
AllowedStudentLoginMethods = ["password"]
|
||||
});
|
||||
await PublishFixtureVersionAsync(factory, plan.Version.Id);
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
client.UseAccessToken(await client.LoginAsTenantAsync(tenantId, phone));
|
||||
var response = await client.GetAsync("/api/tenant-onboarding/status");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
using var payload = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||
Assert.True(payload.RootElement.GetProperty("readyForStudentTraffic").GetBoolean());
|
||||
Assert.Equal(
|
||||
payload.RootElement.GetProperty("requiredSteps").GetInt32(),
|
||||
payload.RootElement.GetProperty("completedRequiredSteps").GetInt32());
|
||||
var requiredIncomplete = payload.RootElement.GetProperty("steps").EnumerateArray()
|
||||
.Where(value => value.GetProperty("required").GetBoolean() && !value.GetProperty("completed").GetBoolean())
|
||||
.ToArray();
|
||||
Assert.Empty(requiredIncomplete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscription_lifecycle_activates_scheduled_plan_once_at_period_boundary()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var tenantId = Guid.NewGuid();
|
||||
var subscriptionId = Guid.NewGuid();
|
||||
var periodEnd = DateTimeOffset.UtcNow.AddMinutes(-5);
|
||||
var oldFeature = new SaasFeature
|
||||
{
|
||||
Code = $"lifecycle.old.{Guid.NewGuid():N}",
|
||||
Name = "Old plan feature",
|
||||
Category = "integration",
|
||||
Status = SaasFeatureStatus.Active
|
||||
};
|
||||
var newFeature = new SaasFeature
|
||||
{
|
||||
Code = $"lifecycle.new.{Guid.NewGuid():N}",
|
||||
Name = "New plan feature",
|
||||
Category = "integration",
|
||||
Status = SaasFeatureStatus.Active
|
||||
};
|
||||
var oldOffering = new SaasOffering
|
||||
{
|
||||
Code = $"lifecycle-old-{Guid.NewGuid():N}",
|
||||
Name = "Old lifecycle plan",
|
||||
Type = SaasOfferingType.BasePlan,
|
||||
Status = SaasOfferingStatus.Active
|
||||
};
|
||||
var newOffering = new SaasOffering
|
||||
{
|
||||
Code = $"lifecycle-new-{Guid.NewGuid():N}",
|
||||
Name = "New lifecycle plan",
|
||||
Type = SaasOfferingType.BasePlan,
|
||||
Status = SaasOfferingStatus.Active
|
||||
};
|
||||
var oldVersion = CreatePublishedVersion(oldOffering.Id, 1);
|
||||
var newVersion = CreatePublishedVersion(newOffering.Id, 1);
|
||||
var nextPeriodEnd = periodEnd.AddMonths(1);
|
||||
await factory.SeedAsync(
|
||||
new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = $"lifecycle-{tenantId:N}",
|
||||
Name = "Lifecycle Tenant",
|
||||
Status = TenantStatus.Active,
|
||||
BillingStatus = BillingStatus.Active
|
||||
},
|
||||
oldFeature,
|
||||
newFeature,
|
||||
oldOffering,
|
||||
newOffering,
|
||||
oldVersion,
|
||||
newVersion,
|
||||
new SaasOfferingVersionFeature
|
||||
{
|
||||
OfferingVersionId = oldVersion.Id,
|
||||
FeatureCode = oldFeature.Code
|
||||
},
|
||||
new SaasOfferingVersionFeature
|
||||
{
|
||||
OfferingVersionId = newVersion.Id,
|
||||
FeatureCode = newFeature.Code
|
||||
},
|
||||
new TenantSaasSubscription
|
||||
{
|
||||
Id = subscriptionId,
|
||||
TenantId = tenantId,
|
||||
BaseOfferingVersionId = oldVersion.Id,
|
||||
ScheduledBaseOfferingVersionId = newVersion.Id,
|
||||
Status = TenantSaasSubscriptionStatus.Active,
|
||||
StartsAt = periodEnd.AddMonths(-1),
|
||||
CurrentPeriodStart = periodEnd.AddMonths(-1),
|
||||
CurrentPeriodEnd = periodEnd
|
||||
},
|
||||
new TenantSaasSubscriptionItem
|
||||
{
|
||||
TenantId = tenantId,
|
||||
SubscriptionId = subscriptionId,
|
||||
OfferingVersionId = oldVersion.Id,
|
||||
ItemType = TenantSaasSubscriptionItemType.BasePlan,
|
||||
Status = TenantSaasSubscriptionItemStatus.Active,
|
||||
StartsAt = periodEnd.AddMonths(-1),
|
||||
EndsAt = periodEnd
|
||||
},
|
||||
new TenantSaasSubscriptionItem
|
||||
{
|
||||
TenantId = tenantId,
|
||||
SubscriptionId = subscriptionId,
|
||||
OfferingVersionId = newVersion.Id,
|
||||
ItemType = TenantSaasSubscriptionItemType.BasePlan,
|
||||
Status = TenantSaasSubscriptionItemStatus.Scheduled,
|
||||
StartsAt = periodEnd,
|
||||
EndsAt = nextPeriodEnd
|
||||
});
|
||||
|
||||
async Task<int> RunLifecycleAsync(string reason)
|
||||
{
|
||||
using var lifecycleScope = factory.CreateSystemScope(reason);
|
||||
return await lifecycleScope.ServiceProvider
|
||||
.GetRequiredService<ISaasSubscriptionLifecycleService>()
|
||||
.ProcessDueAsync(periodEnd.AddMinutes(1));
|
||||
}
|
||||
|
||||
var concurrentResults = await Task.WhenAll(
|
||||
RunLifecycleAsync("Run first concurrent SaaS subscription lifecycle worker"),
|
||||
RunLifecycleAsync("Run second concurrent SaaS subscription lifecycle worker"));
|
||||
Assert.Equal(1, concurrentResults.Count(value => value == 1));
|
||||
Assert.Equal(1, concurrentResults.Count(value => value == 0));
|
||||
Assert.Equal(0, await RunLifecycleAsync("Repeat completed SaaS subscription lifecycle worker"));
|
||||
|
||||
using var verificationScope = factory.CreateSystemScope("Verify scheduled SaaS subscription activation");
|
||||
var dbContext = verificationScope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var subscription = await dbContext.TenantSaasSubscriptions.AsNoTracking()
|
||||
.SingleAsync(value => value.Id == subscriptionId);
|
||||
var items = await dbContext.TenantSaasSubscriptionItems.AsNoTracking()
|
||||
.Where(value => value.SubscriptionId == subscriptionId)
|
||||
.OrderBy(value => value.StartsAt)
|
||||
.ToArrayAsync();
|
||||
Assert.Equal(newVersion.Id, subscription.BaseOfferingVersionId);
|
||||
Assert.Null(subscription.ScheduledBaseOfferingVersionId);
|
||||
Assert.Equal(TenantSaasSubscriptionStatus.Active, subscription.Status);
|
||||
Assert.Equal(periodEnd, subscription.CurrentPeriodStart);
|
||||
Assert.Equal(nextPeriodEnd, subscription.CurrentPeriodEnd);
|
||||
Assert.Equal(1, subscription.LifecycleVersion);
|
||||
Assert.Equal(TenantSaasSubscriptionItemStatus.Expired, items[0].Status);
|
||||
Assert.Equal(TenantSaasSubscriptionItemStatus.Active, items[1].Status);
|
||||
Assert.Single(await dbContext.AuditLogs.AsNoTracking().Where(value =>
|
||||
value.TenantId == tenantId &&
|
||||
value.Action == "platform_billing.subscription.scheduled_plan_activated").ToArrayAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscription_lifecycle_applies_cancel_trial_past_due_and_expired_transitions_idempotently()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var asOf = DateTimeOffset.UtcNow;
|
||||
var feature = new SaasFeature
|
||||
{
|
||||
Code = $"lifecycle.status.{Guid.NewGuid():N}",
|
||||
Name = "Lifecycle status feature",
|
||||
Category = "integration",
|
||||
Status = SaasFeatureStatus.Active
|
||||
};
|
||||
var offering = new SaasOffering
|
||||
{
|
||||
Code = $"lifecycle-status-{Guid.NewGuid():N}",
|
||||
Name = "Lifecycle status plan",
|
||||
Type = SaasOfferingType.BasePlan,
|
||||
Status = SaasOfferingStatus.Active
|
||||
};
|
||||
var version = CreatePublishedVersion(offering.Id, 1);
|
||||
var fixtures = new[]
|
||||
{
|
||||
CreateLifecycleFixture(version.Id, TenantSaasSubscriptionStatus.Active, asOf.AddDays(-1), cancelAtPeriodEnd: true),
|
||||
CreateLifecycleFixture(version.Id, TenantSaasSubscriptionStatus.Trial, asOf.AddDays(-1)),
|
||||
CreateLifecycleFixture(version.Id, TenantSaasSubscriptionStatus.Active, asOf.AddDays(-8)),
|
||||
CreateLifecycleFixture(version.Id, TenantSaasSubscriptionStatus.PastDue, asOf.AddDays(-8))
|
||||
};
|
||||
await factory.SeedAsync(
|
||||
[
|
||||
feature,
|
||||
offering,
|
||||
version,
|
||||
new SaasOfferingVersionFeature
|
||||
{
|
||||
OfferingVersionId = version.Id,
|
||||
FeatureCode = feature.Code
|
||||
},
|
||||
..fixtures.SelectMany(value => new object[] { value.Tenant, value.Subscription, value.Item })
|
||||
]);
|
||||
|
||||
using var lifecycleScope = factory.CreateSystemScope("Run SaaS subscription terminal lifecycle transitions");
|
||||
var lifecycle = lifecycleScope.ServiceProvider.GetRequiredService<ISaasSubscriptionLifecycleService>();
|
||||
Assert.Equal(4, await lifecycle.ProcessDueAsync(asOf));
|
||||
Assert.Equal(1, await lifecycle.ProcessDueAsync(asOf));
|
||||
Assert.Equal(0, await lifecycle.ProcessDueAsync(asOf));
|
||||
|
||||
using var verificationScope = factory.CreateSystemScope("Verify SaaS subscription terminal lifecycle transitions");
|
||||
var dbContext = verificationScope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var subscriptions = await dbContext.TenantSaasSubscriptions.AsNoTracking()
|
||||
.Where(value => fixtures.Select(fixture => fixture.Subscription.Id).Contains(value.Id))
|
||||
.ToDictionaryAsync(value => value.Id);
|
||||
Assert.Equal(TenantSaasSubscriptionStatus.Cancelled, subscriptions[fixtures[0].Subscription.Id].Status);
|
||||
Assert.Equal(TenantSaasSubscriptionStatus.Expired, subscriptions[fixtures[1].Subscription.Id].Status);
|
||||
Assert.Equal(TenantSaasSubscriptionStatus.Expired, subscriptions[fixtures[2].Subscription.Id].Status);
|
||||
Assert.Equal(TenantSaasSubscriptionStatus.Expired, subscriptions[fixtures[3].Subscription.Id].Status);
|
||||
Assert.Equal(1, subscriptions[fixtures[0].Subscription.Id].LifecycleVersion);
|
||||
Assert.Equal(1, subscriptions[fixtures[1].Subscription.Id].LifecycleVersion);
|
||||
Assert.Equal(2, subscriptions[fixtures[2].Subscription.Id].LifecycleVersion);
|
||||
Assert.Equal(1, subscriptions[fixtures[3].Subscription.Id].LifecycleVersion);
|
||||
}
|
||||
|
||||
private static ApiTestFactory PlatformFactory() => new(configurationOverrides: new Dictionary<string, string?>
|
||||
{
|
||||
["Tenancy:Resolution:PlatformHosts:0"] = "localhost"
|
||||
});
|
||||
|
||||
private static async Task<(Guid UserId, string Email)> SeedPlatformAdminAsync(ApiTestFactory factory)
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var roleId = Guid.NewGuid();
|
||||
var email = $"saas-platform-{Guid.NewGuid():N}@example.test";
|
||||
var modules = BackendPermissions.Platform
|
||||
.Select(PermissionModuleCatalog.ResolvePermissionModuleCode)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Select(code => new PermissionModule
|
||||
{
|
||||
Code = code,
|
||||
Name = code,
|
||||
Area = BackendPermissionArea.Platform,
|
||||
RequiredFeatureCode = PermissionModuleCatalog.RequiredFeatures[code]
|
||||
})
|
||||
.Cast<object>();
|
||||
var permissions = BackendPermissions.Platform.Select(code => new BackendPermission
|
||||
{
|
||||
Code = code,
|
||||
Name = code,
|
||||
Area = BackendPermissionArea.Platform,
|
||||
PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(code),
|
||||
IsSystem = true
|
||||
}).Cast<object>();
|
||||
await factory.SeedAsync(
|
||||
[
|
||||
..modules,
|
||||
..permissions,
|
||||
new User
|
||||
{
|
||||
Id = userId,
|
||||
Email = email,
|
||||
NormalizedEmail = email.ToUpperInvariant(),
|
||||
UserName = email,
|
||||
NormalizedUserName = email.ToUpperInvariant(),
|
||||
Name = "SaaS Platform Admin",
|
||||
PrimaryRole = "platform_admin",
|
||||
RawProfile = JsonDefaults.Object()
|
||||
}.WithTestPassword(),
|
||||
new PlatformBackendRole
|
||||
{
|
||||
Id = roleId,
|
||||
Code = $"saas_platform_admin_{Guid.NewGuid():N}",
|
||||
Name = "SaaS Platform Admin",
|
||||
Status = BackendRoleStatus.Active,
|
||||
IsSystem = true
|
||||
},
|
||||
..BackendPermissions.Platform.Select(code => new PlatformBackendRolePermission
|
||||
{
|
||||
RoleId = roleId,
|
||||
PermissionCode = code
|
||||
}),
|
||||
new PlatformBackendUserRole
|
||||
{
|
||||
UserId = userId,
|
||||
RoleId = roleId
|
||||
}
|
||||
]);
|
||||
return (userId, email);
|
||||
}
|
||||
|
||||
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedTenantAdminWithoutSubscriptionAsync(
|
||||
ApiTestFactory factory,
|
||||
string slugPrefix)
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
var phone = $"138{Random.Shared.Next(10_000_000, 99_999_999)}";
|
||||
await factory.SeedAsync(
|
||||
new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = tenantId.ToString("N"),
|
||||
Name = $"{slugPrefix} tenant",
|
||||
Status = TenantStatus.Active,
|
||||
BillingStatus = BillingStatus.Trial
|
||||
},
|
||||
new User
|
||||
{
|
||||
Id = userId,
|
||||
Phone = phone,
|
||||
Name = $"{slugPrefix} admin"
|
||||
}.WithTestPassword(),
|
||||
new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Role = TenantRole.TenantAdmin,
|
||||
Status = MembershipStatus.Active
|
||||
},
|
||||
new SaasOffering
|
||||
{
|
||||
Code = $"fixture-suppress-default-{Guid.NewGuid():N}",
|
||||
Name = "Fixture only",
|
||||
Type = SaasOfferingType.AddOn,
|
||||
Status = SaasOfferingStatus.Draft
|
||||
});
|
||||
return (tenantId, userId, phone);
|
||||
}
|
||||
|
||||
private static async Task<(Guid OfferingId, Guid VersionId)> SeedPublishedPlanAsync(
|
||||
ApiTestFactory factory,
|
||||
string featureCode,
|
||||
(string MetricCode, long Value)? limit)
|
||||
{
|
||||
var graph = CreatePublishedPlan(featureCode);
|
||||
var entities = new List<object> { graph.Feature, graph.Offering, graph.Version, graph.Entitlement };
|
||||
if (limit is { } quota)
|
||||
{
|
||||
entities.Add(new SaasFeatureLimitDefinition
|
||||
{
|
||||
MetricCode = quota.MetricCode,
|
||||
FeatureCode = featureCode,
|
||||
Name = quota.MetricCode,
|
||||
Unit = "count",
|
||||
Kind = SaasFeatureLimitKind.Period,
|
||||
WarningPercent = 80,
|
||||
IsHardLimit = true
|
||||
});
|
||||
entities.Add(new SaasOfferingVersionLimit
|
||||
{
|
||||
OfferingVersionId = graph.Version.Id,
|
||||
MetricCode = quota.MetricCode,
|
||||
LimitValue = quota.Value
|
||||
});
|
||||
}
|
||||
await factory.SeedAsync([.. entities]);
|
||||
await PublishFixtureVersionAsync(factory, graph.Version.Id);
|
||||
return (graph.Offering.Id, graph.Version.Id);
|
||||
}
|
||||
|
||||
private static async Task SeedQuotaSubscriptionAsync(ApiTestFactory factory, Guid tenantId, long limit)
|
||||
{
|
||||
var graph = CreatePublishedPlan(SaasFeatureCatalog.PrivateQuestionBank);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
await factory.SeedAsync(
|
||||
new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = $"quota-{tenantId:N}",
|
||||
Name = "Quota Tenant",
|
||||
Status = TenantStatus.Active,
|
||||
BillingStatus = BillingStatus.Active
|
||||
},
|
||||
graph.Feature,
|
||||
graph.Offering,
|
||||
graph.Version,
|
||||
graph.Entitlement,
|
||||
new SaasFeatureLimitDefinition
|
||||
{
|
||||
MetricCode = SaasQuotaMetricCatalog.ExportCount,
|
||||
FeatureCode = SaasFeatureCatalog.PrivateQuestionBank,
|
||||
Name = "Export count",
|
||||
Unit = "count",
|
||||
Kind = SaasFeatureLimitKind.Period,
|
||||
WarningPercent = 80,
|
||||
IsHardLimit = true
|
||||
},
|
||||
new SaasOfferingVersionLimit
|
||||
{
|
||||
OfferingVersionId = graph.Version.Id,
|
||||
MetricCode = SaasQuotaMetricCatalog.ExportCount,
|
||||
LimitValue = limit
|
||||
},
|
||||
new TenantSaasSubscription
|
||||
{
|
||||
Id = graph.SubscriptionId,
|
||||
TenantId = tenantId,
|
||||
BaseOfferingVersionId = graph.Version.Id,
|
||||
Status = TenantSaasSubscriptionStatus.Active,
|
||||
StartsAt = now.AddDays(-1),
|
||||
CurrentPeriodStart = now.AddDays(-1),
|
||||
CurrentPeriodEnd = now.AddMonths(1)
|
||||
},
|
||||
new TenantSaasSubscriptionItem
|
||||
{
|
||||
TenantId = tenantId,
|
||||
SubscriptionId = graph.SubscriptionId,
|
||||
OfferingVersionId = graph.Version.Id,
|
||||
ItemType = TenantSaasSubscriptionItemType.BasePlan,
|
||||
Status = TenantSaasSubscriptionItemStatus.Active,
|
||||
StartsAt = now.AddDays(-1),
|
||||
EndsAt = now.AddMonths(1)
|
||||
});
|
||||
await PublishFixtureVersionAsync(factory, graph.Version.Id);
|
||||
}
|
||||
|
||||
private static PublishedPlanGraph CreatePublishedPlan(string featureCode)
|
||||
{
|
||||
var offering = new SaasOffering
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = $"plan-{Guid.NewGuid():N}",
|
||||
Name = "Integration SaaS Plan",
|
||||
Type = SaasOfferingType.BasePlan,
|
||||
Status = SaasOfferingStatus.Active
|
||||
};
|
||||
var version = new SaasOfferingVersion
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OfferingId = offering.Id,
|
||||
Version = 1,
|
||||
Status = SaasOfferingVersionStatus.Draft,
|
||||
BillingCycle = PlatformBillingCycle.Monthly,
|
||||
OriginalAmountCents = 1_000,
|
||||
AmountCents = 1_000,
|
||||
Currency = "CNY",
|
||||
EffectiveAt = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
PublishedAt = null
|
||||
};
|
||||
return new PublishedPlanGraph(
|
||||
new SaasFeature
|
||||
{
|
||||
Code = featureCode,
|
||||
Name = featureCode,
|
||||
Category = "integration",
|
||||
Status = SaasFeatureStatus.Active
|
||||
},
|
||||
offering,
|
||||
version,
|
||||
new SaasOfferingVersionFeature
|
||||
{
|
||||
OfferingVersionId = version.Id,
|
||||
FeatureCode = featureCode
|
||||
},
|
||||
Guid.NewGuid());
|
||||
}
|
||||
|
||||
private static SaasOfferingVersion CreatePublishedVersion(Guid offeringId, int version) => new()
|
||||
{
|
||||
OfferingId = offeringId,
|
||||
Version = version,
|
||||
Status = SaasOfferingVersionStatus.Published,
|
||||
BillingCycle = PlatformBillingCycle.Monthly,
|
||||
OriginalAmountCents = 1_000,
|
||||
AmountCents = 1_000,
|
||||
Currency = "CNY",
|
||||
EffectiveAt = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
PublishedAt = DateTimeOffset.UtcNow.AddDays(-1)
|
||||
};
|
||||
|
||||
private static LifecycleFixture CreateLifecycleFixture(
|
||||
Guid offeringVersionId,
|
||||
TenantSaasSubscriptionStatus status,
|
||||
DateTimeOffset periodEnd,
|
||||
bool cancelAtPeriodEnd = false)
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var subscriptionId = Guid.NewGuid();
|
||||
var periodStart = periodEnd.AddMonths(-1);
|
||||
return new(
|
||||
new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = $"lifecycle-status-{tenantId:N}",
|
||||
Name = "Lifecycle Status Tenant",
|
||||
Status = TenantStatus.Active,
|
||||
BillingStatus = status == TenantSaasSubscriptionStatus.PastDue
|
||||
? BillingStatus.PastDue
|
||||
: BillingStatus.Active
|
||||
},
|
||||
new TenantSaasSubscription
|
||||
{
|
||||
Id = subscriptionId,
|
||||
TenantId = tenantId,
|
||||
BaseOfferingVersionId = offeringVersionId,
|
||||
Status = status,
|
||||
StartsAt = periodStart,
|
||||
CurrentPeriodStart = periodStart,
|
||||
CurrentPeriodEnd = periodEnd,
|
||||
CancelAtPeriodEnd = cancelAtPeriodEnd
|
||||
},
|
||||
new TenantSaasSubscriptionItem
|
||||
{
|
||||
TenantId = tenantId,
|
||||
SubscriptionId = subscriptionId,
|
||||
OfferingVersionId = offeringVersionId,
|
||||
ItemType = TenantSaasSubscriptionItemType.BasePlan,
|
||||
Status = TenantSaasSubscriptionItemStatus.Active,
|
||||
StartsAt = periodStart,
|
||||
EndsAt = periodEnd
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task PublishFixtureVersionAsync(ApiTestFactory factory, Guid versionId)
|
||||
{
|
||||
using var scope = factory.CreateSystemScope("Publish integration SaaS plan fixture");
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var version = await dbContext.SaasOfferingVersions.SingleAsync(value => value.Id == versionId);
|
||||
version.Status = SaasOfferingVersionStatus.Published;
|
||||
version.PublishedAt = DateTimeOffset.UtcNow;
|
||||
version.EffectiveAt ??= version.PublishedAt;
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static async Task<Guid> ReadGuidAsync(HttpResponseMessage response, string propertyName)
|
||||
{
|
||||
using var payload = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||
return payload.RootElement.GetProperty(propertyName).GetGuid();
|
||||
}
|
||||
|
||||
private static async Task<string> ReadStringAsync(HttpResponseMessage response, string propertyName)
|
||||
{
|
||||
using var payload = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||
return payload.RootElement.GetProperty(propertyName).GetString()
|
||||
?? throw new InvalidOperationException($"Response property '{propertyName}' was null.");
|
||||
}
|
||||
|
||||
private static Task<string> ReadCodeAsync(HttpResponseMessage response) => ReadStringAsync(response, "code");
|
||||
|
||||
private sealed record PublishedPlanGraph(
|
||||
SaasFeature Feature,
|
||||
SaasOffering Offering,
|
||||
SaasOfferingVersion Version,
|
||||
SaasOfferingVersionFeature Entitlement,
|
||||
Guid SubscriptionId);
|
||||
|
||||
private sealed record LifecycleFixture(
|
||||
Tenant Tenant,
|
||||
TenantSaasSubscription Subscription,
|
||||
TenantSaasSubscriptionItem Item);
|
||||
}
|
||||
Reference in New Issue
Block a user