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.PlatformAdmin; 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 Commercial_processor_generates_one_renewal_receivable_and_due_reminder_on_replay() { await using var factory = new ApiTestFactory(); var tenant = await SeedTenantAdminWithoutSubscriptionAsync(factory, "renewal-worker"); var plan = await SeedPublishedPlanAsync(factory, SaasFeatureCatalog.Exam, null); var subscriptionId = Guid.NewGuid(); var periodEnd = new DateTimeOffset(DateTime.UtcNow.Date.AddDays(7).AddHours(12), TimeSpan.Zero); using (var scope = factory.CreateSystemScope("Set renewal fixture owner")) { var dbContext = scope.ServiceProvider.GetRequiredService(); var storedTenant = await dbContext.Tenants.SingleAsync(value => value.Id == tenant.TenantId); storedTenant.OwnerUserId = tenant.UserId; await dbContext.SaveChangesAsync(); } await factory.SeedAsync( new TenantBillingPolicy { TenantId = tenant.TenantId, CollectionMode = TenantBillingCollectionMode.Manual, DefaultPaymentProvider = "manual", AutoGenerateRenewal = true, RenewalLeadDays = 14 }, new TenantSaasSubscription { Id = subscriptionId, TenantId = tenant.TenantId, BaseOfferingVersionId = plan.VersionId, Status = TenantSaasSubscriptionStatus.Active, StartsAt = periodEnd.AddMonths(-1), CurrentPeriodStart = periodEnd.AddMonths(-1), CurrentPeriodEnd = periodEnd }, new TenantSaasSubscriptionItem { TenantId = tenant.TenantId, SubscriptionId = subscriptionId, OfferingVersionId = plan.VersionId, ItemType = TenantSaasSubscriptionItemType.BasePlan, Status = TenantSaasSubscriptionItemStatus.Active, StartsAt = periodEnd.AddMonths(-1), EndsAt = periodEnd }); for (var iteration = 0; iteration < 2; iteration++) { using var scope = factory.CreateSystemScope("Process replayed commercial renewal cycle"); await scope.ServiceProvider.GetRequiredService().ProcessDueAsync(); } using var verificationScope = factory.CreateSystemScope("Verify renewal receivable idempotency"); var verificationDb = verificationScope.ServiceProvider.GetRequiredService(); var order = Assert.Single(await verificationDb.PlatformBillingOrders.AsNoTracking() .Where(value => value.TenantId == tenant.TenantId && value.IdempotencyKey.StartsWith("auto-renewal:")) .ToArrayAsync()); var invoice = Assert.Single(await verificationDb.PlatformBillingInvoices.AsNoTracking() .Where(value => value.TenantId == tenant.TenantId && value.OrderId == order.Id) .ToArrayAsync()); Assert.Equal(PlatformBillingInvoiceStatus.Issued, invoice.Status); Assert.Single(await verificationDb.PlatformBillingInvoiceReminders.AsNoTracking() .Where(value => value.TenantId == tenant.TenantId && value.InvoiceId == invoice.Id && value.Channel == PlatformBillingInvoiceReminderChannel.Internal) .ToArrayAsync()); } [Fact] public async Task Platform_trial_and_subscription_operations_project_tenant_billing_status() { await using var factory = PlatformFactory(); var platform = await SeedPlatformAdminAsync(factory); var tenant = await SeedTenantAdminWithoutSubscriptionAsync(factory, "trial-state"); var plan = await SeedPublishedPlanAsync(factory, SaasFeatureCatalog.Exam, null); using var client = factory.CreateClient(); client.UseAccessToken(await client.LoginAsPlatformAsync(platform.Email)); var trialRequest = new GrantTenantTrialDto( tenant.TenantId, plan.VersionId, 14, $"trial-{Guid.NewGuid():N}", "integration trial grant"); var trial = await client.PostAsJsonAsync("/api/platform/saas/subscriptions/trial", trialRequest); var repeatedTrial = await client.PostAsJsonAsync("/api/platform/saas/subscriptions/trial", trialRequest); Assert.Equal(HttpStatusCode.OK, trial.StatusCode); Assert.Equal(HttpStatusCode.OK, repeatedTrial.StatusCode); var subscriptionId = await ReadGuidAsync(trial, "id"); Assert.Equal(subscriptionId, await ReadGuidAsync(repeatedTrial, "id")); var duplicateTrial = await client.PostAsJsonAsync( "/api/platform/saas/subscriptions/trial", trialRequest with { IdempotencyKey = $"trial-duplicate-{Guid.NewGuid():N}" }); Assert.Equal(HttpStatusCode.Conflict, duplicateTrial.StatusCode); Assert.Equal("tenant_saas_subscription_exists", await ReadCodeAsync(duplicateTrial)); var suspended = await client.PostAsJsonAsync( $"/api/platform/saas/subscriptions/{subscriptionId}/suspend", new ChangePlatformSubscriptionDto("integration suspension")); Assert.Equal(HttpStatusCode.OK, suspended.StatusCode); await AssertSubscriptionProjectionAsync( factory, tenant.TenantId, subscriptionId, TenantSaasSubscriptionStatus.Suspended, BillingStatus.Suspended); var resumed = await client.PostAsJsonAsync( $"/api/platform/saas/subscriptions/{subscriptionId}/resume", new ChangePlatformSubscriptionDto("integration resume")); Assert.Equal(HttpStatusCode.OK, resumed.StatusCode); await AssertSubscriptionProjectionAsync( factory, tenant.TenantId, subscriptionId, TenantSaasSubscriptionStatus.Active, BillingStatus.Active); DateTimeOffset beforeExtend; using (var scope = factory.CreateSystemScope("Read subscription before extension")) { beforeExtend = await scope.ServiceProvider.GetRequiredService().TenantSaasSubscriptions .Where(value => value.Id == subscriptionId) .Select(value => value.CurrentPeriodEnd) .SingleAsync(); } var extended = await client.PostAsJsonAsync( $"/api/platform/saas/subscriptions/{subscriptionId}/extend", new ChangePlatformSubscriptionDto("integration extension", 10)); Assert.Equal(HttpStatusCode.OK, extended.StatusCode); using (var scope = factory.CreateSystemScope("Verify subscription extension")) { var periodEnd = await scope.ServiceProvider.GetRequiredService().TenantSaasSubscriptions .Where(value => value.Id == subscriptionId) .Select(value => value.CurrentPeriodEnd) .SingleAsync(); Assert.Equal(beforeExtend.AddDays(10), periodEnd); } var cancelled = await client.PostAsJsonAsync( $"/api/platform/saas/subscriptions/{subscriptionId}/cancel", new ChangePlatformSubscriptionDto("integration cancellation")); Assert.Equal(HttpStatusCode.OK, cancelled.StatusCode); await AssertSubscriptionProjectionAsync( factory, tenant.TenantId, subscriptionId, TenantSaasSubscriptionStatus.Cancelled, BillingStatus.Cancelled); Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/platform/saas/metrics")).StatusCode); } [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/saas/features", new UpsertSaasFeatureDto( null, SaasFeatureCatalog.PrivateQuestionBank, "私有题库", "content", "租户私有题库", 20_000, "CNY", SaasFeatureStatus.Active, 10)); var limit = await client.PutAsJsonAsync( "/api/platform/saas/feature-limits", new UpsertSaasFeatureLimitDto( null, SaasQuotaMetricCatalog.PrivateQuestionCount, SaasFeatureCatalog.PrivateQuestionBank, "私有题目数", "count", SaasFeatureLimitKind.Current, 80, true)); var offering = await client.PutAsJsonAsync( "/api/platform/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 { [SaasQuotaMetricCatalog.PrivateQuestionCount] = 10_000 }, JsonDefaults.Object()); var draft = await client.PutAsJsonAsync("/api/platform/saas/offering-versions", versionRequest); Assert.Equal(HttpStatusCode.OK, draft.StatusCode); var versionId = await ReadGuidAsync(draft, "id"); var publish = await client.PostAsync($"/api/platform/saas/offering-versions/{versionId}/publish", null); var mutatePublished = await client.PutAsJsonAsync( "/api/platform/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(); 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 }, new PlatformApprovalPolicy { Code = PlatformApprovalPolicyCodes.FinancialAdjustment, Name = "Financial adjustment", RequiredPermission = BackendPermissions.PlatformSaasBillingManage, AmountThresholdCents = 5_000_000 }); var catalog = await SeedPublishedPlanAsync(factory, SaasFeatureCatalog.Exam, 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); using var paymentPayload = await JsonDocument.ParseAsync(await paymentResponse.Content.ReadAsStreamAsync()); var paymentId = paymentPayload.RootElement.GetProperty("id").GetGuid(); var paymentAmountCents = paymentPayload.RootElement.GetProperty("amountCents").GetInt32(); using var platformClient = factory.CreateClient(); platformClient.UseAccessToken(await platformClient.LoginAsPlatformAsync(platform.Email)); var confirmationIdempotencyKey = $"manual-confirm-{Guid.NewGuid():N}"; var confirmation = new ConfirmManualPlatformPaymentDto( paymentId, $"manual-{Guid.NewGuid():N}", DateTimeOffset.UtcNow, "integration test receipt"); platformClient.DefaultRequestHeaders.Add("Idempotency-Key", confirmationIdempotencyKey); var confirm = await platformClient.PostAsJsonAsync( "/api/platform/saas/payments/manual/confirm", confirmation); var repeatedConfirm = await platformClient.PostAsJsonAsync( "/api/platform/saas/payments/manual/confirm", confirmation); platformClient.DefaultRequestHeaders.Remove("Idempotency-Key"); Assert.True(confirm.StatusCode == HttpStatusCode.OK, await confirm.Content.ReadAsStringAsync()); Assert.True(repeatedConfirm.StatusCode == HttpStatusCode.OK, await repeatedConfirm.Content.ReadAsStringAsync()); 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); var refundKey = $"refund-{Guid.NewGuid():N}"; var refundRequest = new RequestPlatformRefundDto( paymentId, paymentAmountCents / 2, "integration test partial refund", refundKey, PlatformBillingRefundSubscriptionEffect.KeepService); var refundResponse = await platformClient.PostAsJsonAsync("/api/platform/saas/refunds", refundRequest); var repeatedRefund = await platformClient.PostAsJsonAsync("/api/platform/saas/refunds", refundRequest); Assert.Equal(HttpStatusCode.OK, refundResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, repeatedRefund.StatusCode); var refundId = await ReadSubmissionResultGuidAsync(refundResponse, "id"); Assert.Equal(refundId, await ReadSubmissionResultGuidAsync(repeatedRefund, "id")); var approved = await platformClient.PostAsJsonAsync( $"/api/platform/saas/refunds/{refundId}/approve", new ReviewPlatformRefundDto("integration test approval")); Assert.Equal(HttpStatusCode.OK, approved.StatusCode); using (var processorScope = factory.CreateSystemScope("Execute approved SaaS refund")) { var processed = await processorScope.ServiceProvider.GetRequiredService() .ProcessDueAsync(); Assert.True(processed >= 1); } using (var partialScope = factory.CreateSystemScope("Verify partial SaaS refund")) { var partialDb = partialScope.ServiceProvider.GetRequiredService(); Assert.Equal(PlatformBillingOrderStatus.Paid, await partialDb.PlatformBillingOrders.AsNoTracking() .Where(value => value.OrderNo == orderNo) .Select(value => value.Status) .SingleAsync()); } var excessiveRefund = await platformClient.PostAsJsonAsync( "/api/platform/saas/refunds", refundRequest with { AmountCents = paymentAmountCents, IdempotencyKey = $"refund-excessive-{Guid.NewGuid():N}" }); Assert.Equal(HttpStatusCode.Conflict, excessiveRefund.StatusCode); Assert.Equal("platform_billing_refund_amount_invalid", await ReadCodeAsync(excessiveRefund)); var finalRefund = await platformClient.PostAsJsonAsync( "/api/platform/saas/refunds", refundRequest with { AmountCents = paymentAmountCents - paymentAmountCents / 2, Reason = "integration test remaining refund", IdempotencyKey = $"refund-final-{Guid.NewGuid():N}" }); Assert.Equal(HttpStatusCode.OK, finalRefund.StatusCode); var finalRefundId = await ReadSubmissionResultGuidAsync(finalRefund, "id"); var finalApproved = await platformClient.PostAsJsonAsync( $"/api/platform/saas/refunds/{finalRefundId}/approve", new ReviewPlatformRefundDto("integration test final approval")); Assert.Equal(HttpStatusCode.OK, finalApproved.StatusCode); using (var processorScope = factory.CreateSystemScope("Execute remaining SaaS refund")) { var processed = await processorScope.ServiceProvider.GetRequiredService() .ProcessDueAsync(); Assert.True(processed >= 1); } var tenantRefunds = await tenantClient.GetAsync("/api/tenant/billing/refunds"); Assert.Equal(HttpStatusCode.OK, tenantRefunds.StatusCode); using var scope = factory.CreateSystemScope("Verify SaaS billing settlement idempotency"); var dbContext = scope.ServiceProvider.GetRequiredService(); 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()); Assert.Equal(PlatformBillingRefundStatus.Succeeded, await dbContext.PlatformBillingRefunds.AsNoTracking() .Where(value => value.Id == refundId) .Select(value => value.Status) .SingleAsync()); Assert.Equal(PlatformBillingRefundStatus.Succeeded, await dbContext.PlatformBillingRefunds.AsNoTracking() .Where(value => value.Id == finalRefundId) .Select(value => value.Status) .SingleAsync()); Assert.Equal(PlatformBillingOrderStatus.Refunded, await dbContext.PlatformBillingOrders.AsNoTracking() .Where(value => value.OrderNo == orderNo) .Select(value => value.Status) .SingleAsync()); } [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/tenant/access/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/tenant/access/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(); 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/tenant/access/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/tenant/access/roles/{roleId}/bindings", new ReplaceRoleBindingsDto { PermissionCodes = [BackendPermissions.TenantVideoManage], MenuCodes = [] }); var questionBinding = await client.PutAsJsonAsync( $"/api/tenant/access/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, 10); using (var scope = factory.CreateSystemScope("Seed SaaS quota usage")) { var featureAccess = scope.ServiceProvider.GetRequiredService(); 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 ConsumeFinalCapacityAsync() { using var scope = factory.CreateSystemScope("Race SaaS quota consumption"); return await scope.ServiceProvider.GetRequiredService() .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() .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"] }, new TenantBillingPolicy { TenantId = tenantId, CollectionMode = TenantBillingCollectionMode.Manual, DefaultPaymentProvider = "manual", AutoGenerateRenewal = true, RenewalLeadDays = 14 }); 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 RunLifecycleAsync(string reason) { using var lifecycleScope = factory.CreateSystemScope(reason); return await lifecycleScope.ServiceProvider .GetRequiredService() .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(); 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), 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(); 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(); 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() { return new ApiTestFactory(configurationOverrides: new Dictionary { ["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(); var permissions = BackendPermissions.Platform.Select(code => new BackendPermission { Code = code, Name = code, Area = BackendPermissionArea.Platform, PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(code), IsSystem = true }).Cast(); 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 { 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) { return new SaasOfferingVersion { 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 LifecycleFixture( 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(); 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 AssertSubscriptionProjectionAsync( ApiTestFactory factory, Guid tenantId, Guid subscriptionId, TenantSaasSubscriptionStatus expectedSubscriptionStatus, BillingStatus expectedBillingStatus) { using var scope = factory.CreateSystemScope("Verify subscription billing projection"); var dbContext = scope.ServiceProvider.GetRequiredService(); Assert.Equal(expectedSubscriptionStatus, await dbContext.TenantSaasSubscriptions.AsNoTracking() .Where(value => value.Id == subscriptionId) .Select(value => value.Status) .SingleAsync()); Assert.Equal(expectedBillingStatus, await dbContext.Tenants.AsNoTracking() .Where(value => value.Id == tenantId) .Select(value => value.BillingStatus) .SingleAsync()); } private static async Task 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 ReadSubmissionResultGuidAsync(HttpResponseMessage response, string propertyName) { using var payload = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); return payload.RootElement.GetProperty("result").GetProperty(propertyName).GetGuid(); } private static async Task 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 ReadCodeAsync(HttpResponseMessage response) { return 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); }