using System.Net; using System.Net.Http.Json; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Tiku.Application.Commerce; using Tiku.Application.PlatformBilling; using Tiku.Domain.Common; using Tiku.Domain.Platform; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; public sealed class PlatformBillingCallbackTests { [Theory] [InlineData("wechat_pay", "application/json", "{\"code\":\"SUCCESS\"")] [InlineData("alipay", "text/plain", "success")] public async Task Valid_provider_callback_settles_once( string provider, string expectedMediaType, string expectedBody) { var fixture = CreateFixture(provider); var gateway = new FakePlatformBillingPaymentGateway(fixture.Notification); await using var factory = new ApiTestFactory(platformBillingPaymentGateway: gateway); await factory.SeedAsync(fixture.Entities); await PublishVersionAsync(factory, fixture.VersionId); using var client = factory.CreateClient(); var first = await client.PostAsJsonAsync($"/api/platform-billing/callbacks/{provider}", new { eventId = fixture.Notification.EventId }); var repeated = await client.PostAsJsonAsync($"/api/platform-billing/callbacks/{provider}", new { eventId = fixture.Notification.EventId }); Assert.Equal(HttpStatusCode.OK, first.StatusCode); Assert.Equal(HttpStatusCode.OK, repeated.StatusCode); Assert.Equal(expectedMediaType, first.Content.Headers.ContentType?.MediaType); Assert.Contains(expectedBody, await first.Content.ReadAsStringAsync(), StringComparison.Ordinal); Assert.All(gateway.ParsedProviders, value => Assert.Equal(provider, value)); using var scope = factory.CreateSystemScope("Verify platform payment callback idempotency"); var db = scope.ServiceProvider.GetRequiredService(); Assert.Equal(PlatformBillingPaymentStatus.Succeeded, (await db.PlatformBillingPayments.AsNoTracking().SingleAsync(value => value.Id == fixture.PaymentId)).Status); Assert.Single(await db.PlatformBillingPaymentEvents.AsNoTracking() .Where(value => value.PaymentId == fixture.PaymentId) .ToArrayAsync()); Assert.Single(await db.PlatformBillingInvoices.AsNoTracking() .Where(value => value.OrderId == fixture.OrderId) .ToArrayAsync()); Assert.Single(await db.TenantSaasSubscriptions.AsNoTracking() .Where(value => value.TenantId == fixture.TenantId) .ToArrayAsync()); } [Fact] public async Task Invalid_signature_is_rejected_without_settlement() { var fixture = CreateFixture(PaymentProviders.WechatPay); var invalid = fixture.Notification with { SignatureValid = false }; await using var factory = new ApiTestFactory( platformBillingPaymentGateway: new FakePlatformBillingPaymentGateway(invalid)); await factory.SeedAsync(fixture.Entities); await PublishVersionAsync(factory, fixture.VersionId); using var client = factory.CreateClient(); var response = await client.PostAsJsonAsync( "/api/platform-billing/callbacks/wechat_pay", new { eventId = invalid.EventId }); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Equal("platform_billing_notification_invalid", await ReadCodeAsync(response)); await AssertPaymentRemainsPendingAsync(factory, fixture.PaymentId); } [Fact] public async Task Callback_amount_mismatch_is_rejected_without_settlement() { var fixture = CreateFixture(PaymentProviders.Alipay); var mismatched = fixture.Notification with { AmountCents = fixture.Notification.AmountCents + 1 }; await using var factory = new ApiTestFactory( platformBillingPaymentGateway: new FakePlatformBillingPaymentGateway(mismatched)); await factory.SeedAsync(fixture.Entities); await PublishVersionAsync(factory, fixture.VersionId); using var client = factory.CreateClient(); var response = await client.PostAsJsonAsync( "/api/platform-billing/callbacks/alipay", new { eventId = mismatched.EventId }); Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); Assert.Equal("platform_billing_payment_amount_mismatch", await ReadCodeAsync(response)); await AssertPaymentRemainsPendingAsync(factory, fixture.PaymentId); } private static CallbackFixture CreateFixture(string provider) { const int amountCents = 8_800; var tenantId = Guid.NewGuid(); var feature = new SaasFeature { Code = $"callback.feature.{Guid.NewGuid():N}", Name = "Callback feature", Category = "integration", Status = SaasFeatureStatus.Active }; var offering = new SaasOffering { Id = Guid.NewGuid(), Code = $"callback-offering-{Guid.NewGuid():N}", Name = "Callback offering", 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 = amountCents, AmountCents = amountCents, Currency = "CNY", EffectiveAt = DateTimeOffset.UtcNow.AddDays(-1) }; var order = new PlatformBillingOrder { Id = Guid.NewGuid(), TenantId = tenantId, QuoteId = Guid.NewGuid(), OrderNo = $"SO{Guid.NewGuid():N}", IdempotencyKey = $"order-{Guid.NewGuid():N}", Purpose = PlatformBillingOrderPurpose.NewSubscription, Status = PlatformBillingOrderStatus.PendingPayment, OriginalAmountCents = amountCents, TotalAmountCents = amountCents, Currency = "CNY", ExpiresAt = DateTimeOffset.UtcNow.AddHours(1), Snapshot = JsonDefaults.Object() }; var quote = new PlatformBillingQuote { Id = order.QuoteId, TenantId = tenantId, QuoteNo = $"SQ{Guid.NewGuid():N}", IdempotencyKey = $"quote-{Guid.NewGuid():N}", Purpose = PlatformBillingOrderPurpose.NewSubscription, Status = PlatformBillingQuoteStatus.Converted, OriginalAmountCents = amountCents, TotalAmountCents = amountCents, Currency = "CNY", ExpiresAt = DateTimeOffset.UtcNow.AddHours(1), FeatureSnapshot = JsonDefaults.Array(), LimitSnapshot = JsonDefaults.Object() }; var orderItem = new PlatformBillingOrderItem { Id = Guid.NewGuid(), TenantId = tenantId, OrderId = order.Id, OfferingVersionId = version.Id, ItemType = PlatformBillingItemType.BasePlan, UnitAmountCents = amountCents, AmountCents = amountCents, Snapshot = JsonDefaults.Object() }; var payment = new PlatformBillingPayment { Id = Guid.NewGuid(), TenantId = tenantId, OrderId = order.Id, PaymentNo = $"SP{Guid.NewGuid():N}", IdempotencyKey = $"payment-{Guid.NewGuid():N}", Provider = provider, Method = provider == PaymentProviders.Alipay ? "web" : "jsapi", AmountCents = amountCents, Status = PlatformBillingPaymentStatus.Pending }; var notification = new PaymentNotificationResult( provider, "payment.succeeded", $"event-{Guid.NewGuid():N}", order.OrderNo, $"trade-{Guid.NewGuid():N}", amountCents, true, true, DateTimeOffset.UtcNow, JsonDefaults.Object()); object[] entities = [ new Tenant { Id = tenantId, Slug = $"callback-{tenantId:N}", Name = "Callback tenant", Status = TenantStatus.Active, BillingStatus = BillingStatus.Trial }, feature, offering, version, new SaasOfferingVersionFeature { OfferingVersionId = version.Id, FeatureCode = feature.Code }, quote, order, orderItem, payment ]; return new CallbackFixture(tenantId, version.Id, order.Id, payment.Id, notification, entities); } private static async Task PublishVersionAsync(ApiTestFactory factory, Guid versionId) { using var scope = factory.CreateSystemScope("Publish callback plan fixture"); var db = scope.ServiceProvider.GetRequiredService(); var version = await db.SaasOfferingVersions.SingleAsync(value => value.Id == versionId); version.Status = SaasOfferingVersionStatus.Published; version.PublishedAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(); } private static async Task AssertPaymentRemainsPendingAsync(ApiTestFactory factory, Guid paymentId) { using var scope = factory.CreateSystemScope("Verify rejected platform callback"); var db = scope.ServiceProvider.GetRequiredService(); Assert.Equal(PlatformBillingPaymentStatus.Pending, (await db.PlatformBillingPayments.AsNoTracking().SingleAsync(value => value.Id == paymentId)).Status); Assert.False(await db.PlatformBillingPaymentEvents.AnyAsync(value => value.PaymentId == paymentId)); } private static async Task ReadCodeAsync(HttpResponseMessage response) { using var payload = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); return payload.RootElement.GetProperty("code").GetString() ?? string.Empty; } private sealed record CallbackFixture( Guid TenantId, Guid VersionId, Guid OrderId, Guid PaymentId, PaymentNotificationResult Notification, object[] Entities); private sealed class FakePlatformBillingPaymentGateway(PaymentNotificationResult notification) : IPlatformBillingPaymentGateway { public List ParsedProviders { get; } = []; public Task CreatePaymentAsync( string provider, CreatePaymentProviderRequest request, CancellationToken cancellationToken = default) => Task.FromResult(new CreatePaymentProviderResult( provider, request.Method, "pending", null, JsonDefaults.Object(), JsonDefaults.Object())); public Task ParseNotificationAsync( string provider, PaymentNotificationRequest request, CancellationToken cancellationToken = default) { ParsedProviders.Add(provider); return Task.FromResult(notification); } } }