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

@@ -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)