feat: complete SaaS commercial delivery workflows
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user