feat: complete SaaS commercial delivery workflows

This commit is contained in:
2026-08-01 15:26:21 +08:00
parent 46abf4d62f
commit d58bcd97e9
56 changed files with 27505 additions and 181 deletions

View File

@@ -14,8 +14,8 @@ namespace Tiku.IntegrationTests.Api;
public sealed class AuthorizationManifestTests
{
private const int ExpectedActionCount = 426;
private const string ExpectedSha256 = "e45f159b7285342e44f256b63c483c575f9b624f01e5ad9f96c4bfea71603bb7";
private const int ExpectedActionCount = 444;
private const string ExpectedSha256 = "614535eb777886e4ba124563a385ffc221c03356d0f966ab2ef6efa58e16d5a8";
[Fact]
public void Controller_authorization_surface_matches_reviewed_manifest()

View File

@@ -228,7 +228,6 @@ public sealed class PlatformAdminEndpointTests
{
TenantId = tenantId,
Status = TenantStatus.Suspended,
BillingStatus = BillingStatus.PastDue,
Reason = "integration test suspension"
});
@@ -441,6 +440,9 @@ public sealed class PlatformAdminEndpointTests
var tenantId = Guid.NewGuid();
var invoiceId = Guid.NewGuid();
var reminderId = Guid.NewGuid();
var ignoredReminderId = Guid.NewGuid();
var eventId = Guid.NewGuid();
var ignoredEventId = Guid.NewGuid();
await factory.SeedAsync(
new Tenant
{
@@ -468,6 +470,17 @@ public sealed class PlatformAdminEndpointTests
Status = PlatformBillingInvoiceReminderStatus.Failed,
ReminderDate = DateOnly.FromDateTime(DateTime.UtcNow.Date),
BalanceCentsSnapshot = 10_000
},
new PlatformBillingInvoiceReminder
{
Id = ignoredReminderId,
TenantId = tenantId,
InvoiceId = invoiceId,
ReminderType = PlatformBillingInvoiceReminderType.FinalNotice,
Channel = PlatformBillingInvoiceReminderChannel.Wechat,
Status = PlatformBillingInvoiceReminderStatus.Failed,
ReminderDate = DateOnly.FromDateTime(DateTime.UtcNow.Date),
BalanceCentsSnapshot = 10_000
});
using var client = factory.CreateClient();
client.UseAccessToken(await client.LoginAsPlatformAsync(platform.Email));
@@ -489,25 +502,38 @@ public sealed class PlatformAdminEndpointTests
var upsertBody = await upsertResponse.Content.ReadAsStringAsync();
var channelJson = JsonDocument.Parse(upsertBody);
var channelId = channelJson.RootElement.GetProperty("id").GetGuid();
await factory.SeedAsync(new PlatformBillingDunningNotificationEvent
{
TenantId = tenantId,
ChannelId = channelId,
ReminderId = reminderId,
InvoiceId = invoiceId,
Provider = PlatformBillingDunningProvider.Wecom,
Status = PlatformBillingDunningNotificationStatus.Failed,
Attempts = 2,
LastError = "timeout",
LastHttpCode = 500,
LastResponseSummary = "server error",
RequestPayload = JsonSerializer.SerializeToElement(new { phone = "13800001111", amount = 10000 })
});
await factory.SeedAsync(
new PlatformBillingDunningNotificationEvent
{
Id = eventId,
TenantId = tenantId,
ChannelId = channelId,
ReminderId = ignoredReminderId,
InvoiceId = invoiceId,
Provider = PlatformBillingDunningProvider.Wecom,
Status = PlatformBillingDunningNotificationStatus.Failed,
Attempts = 2,
LastError = "timeout",
LastHttpCode = 500,
LastResponseSummary = "server error",
RequestPayload = JsonSerializer.SerializeToElement(new { phone = "13800001111", amount = 10000 })
},
new PlatformBillingDunningNotificationEvent
{
Id = ignoredEventId,
TenantId = tenantId,
ChannelId = channelId,
ReminderId = reminderId,
InvoiceId = invoiceId,
Provider = PlatformBillingDunningProvider.Wecom,
Status = PlatformBillingDunningNotificationStatus.Failed,
Attempts = 5,
LastError = "permanent failure",
RequestPayload = JsonSerializer.SerializeToElement(new { phone = "13800001111", amount = 10000 })
});
var channelsResponse = await client.GetAsync("/api/platform-admin/saas/dunning/channels?search=wecom");
var eventsResponse = await client.GetAsync("/api/platform-admin/saas/dunning/events?status=failed");
var eventsJson = await JsonDocument.ParseAsync(await eventsResponse.Content.ReadAsStreamAsync());
var eventId = eventsJson.RootElement.GetProperty("items")[0].GetProperty("id").GetGuid();
var detailResponse = await client.GetAsync($"/api/platform-admin/saas/dunning/events/detail?eventId={eventId}");
var retryResponse = await client.PostAsJsonAsync(
"/api/platform-admin/saas/dunning/events/retry",
@@ -516,6 +542,12 @@ public sealed class PlatformAdminEndpointTests
EventId = eventId,
Reason = "manual retry"
});
var acknowledgeResponse = await client.PostAsJsonAsync(
"/api/platform-admin/saas/dunning/events/acknowledge",
new ResolvePlatformBillingDunningEventDto { EventId = eventId, Reason = "delivery confirmed manually" });
var ignoreResponse = await client.PostAsJsonAsync(
"/api/platform-admin/saas/dunning/events/ignore",
new ResolvePlatformBillingDunningEventDto { EventId = ignoredEventId, Reason = "tenant requested no further delivery" });
var disableResponse = await client.PostAsJsonAsync(
"/api/platform-admin/saas/dunning/channels/disable",
new DisablePlatformBillingDunningChannelDto
@@ -531,14 +563,20 @@ public sealed class PlatformAdminEndpointTests
Assert.Equal(HttpStatusCode.OK, eventsResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, retryResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, acknowledgeResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, ignoreResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, disableResponse.StatusCode);
using var scope = factory.CreateSystemScope("Verify platform dunning side effects");
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var storedEvent = await dbContext.PlatformBillingDunningNotificationEvents.AsNoTracking().SingleAsync(item => item.Id == eventId);
var storedChannel = await dbContext.PlatformBillingDunningNotificationChannels.AsNoTracking().SingleAsync(item => item.Id == channelId);
Assert.Equal(PlatformBillingDunningNotificationStatus.Pending, storedEvent.Status);
Assert.Equal(PlatformBillingDunningNotificationStatus.Acknowledged, storedEvent.Status);
Assert.Null(storedEvent.LastError);
Assert.Equal(PlatformBillingDunningNotificationStatus.Ignored, await dbContext.PlatformBillingDunningNotificationEvents.AsNoTracking()
.Where(item => item.Id == ignoredEventId)
.Select(item => item.Status)
.SingleAsync());
Assert.False(storedChannel.Enabled);
Assert.True(await dbContext.AuditLogs.AnyAsync(log =>
log.ActorUserId == platform.UserId &&
@@ -546,6 +584,113 @@ public sealed class PlatformAdminEndpointTests
Assert.True(await dbContext.AuditLogs.AnyAsync(log =>
log.ActorUserId == platform.UserId &&
log.Action == "platform.billing_dunning_channel.disabled"));
Assert.True(await dbContext.AuditLogs.AnyAsync(log =>
log.ActorUserId == platform.UserId &&
log.Action == "platform.billing_dunning_event.acknowledged"));
Assert.True(await dbContext.AuditLogs.AnyAsync(log =>
log.ActorUserId == platform.UserId &&
log.Action == "platform.billing_dunning_event.ignored"));
}
[Fact]
public async Task Tenant_provisioning_is_concurrently_idempotent_and_owner_activation_is_single_use()
{
await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary<string, string?>
{
["Tenancy:Resolution:PlatformHosts:0"] = "localhost"
});
var platform = await SeedPlatformAdminAsync(factory);
using var client = factory.CreateClient();
client.UseAccessToken(await client.LoginAsPlatformAsync(platform.Email));
var key = $"tenant-create-{Guid.NewGuid():N}";
client.DefaultRequestHeaders.Add("Idempotency-Key", key);
var slug = $"activation-{Guid.NewGuid():N}";
var phone = $"137{Random.Shared.Next(10_000_000, 99_999_999)}";
var request = new CreatePlatformTenantDto
{
Slug = slug,
Name = "Activation Tenant",
OwnerPhone = phone,
OwnerName = "Activation Owner",
DefaultPaymentProvider = "manual",
CollectionMode = TenantBillingCollectionMode.Manual,
RenewalLeadDays = 21
};
var responses = await Task.WhenAll(
client.PostAsJsonAsync("/api/platform-admin/tenants", request),
client.PostAsJsonAsync("/api/platform-admin/tenants", request));
var failedProvisioning = await Task.WhenAll(responses
.Where(response => response.StatusCode != HttpStatusCode.OK)
.Select(async response => $"{(int)response.StatusCode}: {await response.Content.ReadAsStringAsync()}"));
Assert.True(failedProvisioning.Length == 0, string.Join(Environment.NewLine, failedProvisioning));
var payloads = await Task.WhenAll(responses.Select(async response =>
await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync())));
var tenantIds = payloads.Select(payload => payload.RootElement.GetProperty("tenant").GetProperty("id").GetGuid()).Distinct().ToArray();
Assert.Single(tenantIds);
Assert.Single(payloads, payload => payload.RootElement.GetProperty("activationToken").ValueKind == JsonValueKind.String);
Assert.Single(payloads, payload => payload.RootElement.GetProperty("isReplay").GetBoolean());
var conflictingRequest = new CreatePlatformTenantDto
{
Slug = slug,
Name = "Different Tenant Name",
OwnerPhone = phone,
OwnerName = request.OwnerName,
DefaultPaymentProvider = request.DefaultPaymentProvider,
CollectionMode = request.CollectionMode,
RenewalLeadDays = request.RenewalLeadDays
};
var conflict = await client.PostAsJsonAsync("/api/platform-admin/tenants", conflictingRequest);
Assert.Equal(HttpStatusCode.Conflict, conflict.StatusCode);
Assert.Equal("idempotency_conflict", await ReadProblemCodeAsync(conflict));
var firstPayload = payloads.Single(payload => payload.RootElement.GetProperty("activationToken").ValueKind == JsonValueKind.String);
var activationId = firstPayload.RootElement.GetProperty("activationId").GetGuid();
var activationToken = firstPayload.RootElement.GetProperty("activationToken").GetString()!;
var wrong = await client.PostAsJsonAsync("/api/auth/activation/complete", new CompleteOwnerActivationDto
{
ActivationId = activationId,
Token = new string('x', activationToken.Length),
NewPassword = "ActivatedOwner2026"
});
Assert.Equal(HttpStatusCode.BadRequest, wrong.StatusCode);
Assert.Equal("owner_activation_invalid", await ReadProblemCodeAsync(wrong));
var activationRequest = new CompleteOwnerActivationDto
{
ActivationId = activationId,
Token = activationToken,
NewPassword = "ActivatedOwner2026"
};
var activated = await client.PostAsJsonAsync("/api/auth/activation/complete", activationRequest);
var replay = await client.PostAsJsonAsync("/api/auth/activation/complete", activationRequest);
Assert.Equal(HttpStatusCode.NoContent, activated.StatusCode);
Assert.Equal(HttpStatusCode.Conflict, replay.StatusCode);
Assert.Equal("owner_activation_consumed", await ReadProblemCodeAsync(replay));
var policy = await client.GetAsync($"/api/platform-admin/tenants/{tenantIds[0]}/billing-policy");
Assert.Equal(HttpStatusCode.OK, policy.StatusCode);
using var policyPayload = await JsonDocument.ParseAsync(await policy.Content.ReadAsStreamAsync());
Assert.Equal(21, policyPayload.RootElement.GetProperty("renewalLeadDays").GetInt32());
using var scope = factory.CreateSystemScope("Verify tenant provisioning and owner activation");
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var grant = await dbContext.TenantOwnerActivationGrants.AsNoTracking().SingleAsync(value => value.Id == activationId);
Assert.NotNull(grant.ConsumedAt);
Assert.DoesNotContain(activationToken, grant.TokenHash, StringComparison.Ordinal);
Assert.True(await dbContext.AuditLogs.AnyAsync(value =>
value.TenantId == tenantIds[0] && value.Action == "tenant.owner.activated"));
foreach (var payload in payloads)
{
payload.Dispose();
}
}
private static async Task<string?> ReadProblemCodeAsync(HttpResponseMessage response)
{
using var payload = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
return payload.RootElement.TryGetProperty("code", out var code) ? code.GetString() : null;
}
private static async Task<(Guid UserId, string Email)> SeedPlatformAdminAsync(ApiTestFactory factory)

View File

@@ -17,6 +17,156 @@ 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, limit: 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<TikuDbContext>();
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<ICommercialBillingProcessor>().ProcessDueAsync();
}
using var verificationScope = factory.CreateSystemScope("Verify renewal receivable idempotency");
var verificationDb = verificationScope.ServiceProvider.GetRequiredService<TikuDbContext>();
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, limit: 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-admin/saas/subscriptions/trial", trialRequest);
var repeatedTrial = await client.PostAsJsonAsync("/api/platform-admin/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-admin/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-admin/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-admin/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<TikuDbContext>().TenantSaasSubscriptions
.Where(value => value.Id == subscriptionId)
.Select(value => value.CurrentPeriodEnd)
.SingleAsync();
}
var extended = await client.PostAsJsonAsync(
$"/api/platform-admin/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<TikuDbContext>().TenantSaasSubscriptions
.Where(value => value.Id == subscriptionId)
.Select(value => value.CurrentPeriodEnd)
.SingleAsync();
Assert.Equal(beforeExtend.AddDays(10), periodEnd);
}
var cancelled = await client.PostAsJsonAsync(
$"/api/platform-admin/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-admin/saas/metrics")).StatusCode);
}
[Fact]
public async Task Platform_can_publish_feature_limit_and_immutable_offering_version()
{
@@ -161,7 +311,9 @@ public sealed class SaasBillingLifecycleTests
null,
null));
Assert.Equal(HttpStatusCode.OK, paymentResponse.StatusCode);
var paymentId = await ReadGuidAsync(paymentResponse, "id");
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));
@@ -189,6 +341,70 @@ public sealed class SaasBillingLifecycleTests
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-admin/saas/refunds", refundRequest);
var repeatedRefund = await platformClient.PostAsJsonAsync("/api/platform-admin/saas/refunds", refundRequest);
Assert.Equal(HttpStatusCode.OK, refundResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, repeatedRefund.StatusCode);
var refundId = await ReadGuidAsync(refundResponse, "id");
Assert.Equal(refundId, await ReadGuidAsync(repeatedRefund, "id"));
var approved = await platformClient.PostAsJsonAsync(
$"/api/platform-admin/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<ICommercialBillingProcessor>()
.ProcessDueAsync();
Assert.True(processed >= 1);
}
using (var partialScope = factory.CreateSystemScope("Verify partial SaaS refund"))
{
var partialDb = partialScope.ServiceProvider.GetRequiredService<TikuDbContext>();
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-admin/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-admin/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 ReadGuidAsync(finalRefund, "id");
var finalApproved = await platformClient.PostAsJsonAsync(
$"/api/platform-admin/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<ICommercialBillingProcessor>()
.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<TikuDbContext>();
Assert.Single(await dbContext.TenantSaasSubscriptions.AsNoTracking()
@@ -200,6 +416,18 @@ public sealed class SaasBillingLifecycleTests
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]
@@ -422,6 +650,14 @@ public sealed class SaasBillingLifecycleTests
{
TenantId = tenantId,
AllowedStudentLoginMethods = ["password"]
},
new TenantBillingPolicy
{
TenantId = tenantId,
CollectionMode = TenantBillingCollectionMode.Manual,
DefaultPaymentProvider = "manual",
AutoGenerateRenewal = true,
RenewalLeadDays = 14
});
await PublishFixtureVersionAsync(factory, plan.Version.Id);
@@ -933,6 +1169,25 @@ public sealed class SaasBillingLifecycleTests
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<TikuDbContext>();
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<Guid> ReadGuidAsync(HttpResponseMessage response, string propertyName)
{
using var payload = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());