using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.PlatformBilling; using Tiku.Application.Security; using Tiku.Domain.Operations; using Tiku.Domain.Platform; using Tiku.Domain.Common; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; using Tiku.Infrastructure.Messaging; namespace Tiku.Infrastructure.PlatformBilling; internal sealed class PlatformBillingSettlementService( TikuDbContext dbContext, ISecurityEventPublisher securityEventPublisher, ITenantFeatureCacheInvalidator featureCacheInvalidator) : IPlatformBillingSettlementService { public async Task MarkPaidAsync( Guid paymentId, string providerEventId, string eventType, string? providerTradeNo, DateTimeOffset paidAt, JsonElement payload, Guid? actorUserId, CancellationToken cancellationToken = default) { var payment = await dbContext.PlatformBillingPayments.SingleOrDefaultAsync(value => value.Id == paymentId, cancellationToken) ?? throw Error("Platform billing payment was not found.", "platform_billing_payment_not_found"); if (await dbContext.PlatformBillingPaymentEvents.AnyAsync(value => value.TenantId == payment.TenantId && value.Provider == payment.Provider && value.ProviderEventId == providerEventId, cancellationToken)) { return payment; } var order = await dbContext.PlatformBillingOrders.SingleAsync(value => value.TenantId == payment.TenantId && value.Id == payment.OrderId, cancellationToken); if (payment.AmountCents != order.TotalAmountCents) { throw Error("Payment amount does not match the order.", "platform_billing_payment_amount_mismatch"); } dbContext.PlatformBillingPaymentEvents.Add(new PlatformBillingPaymentEvent { TenantId = payment.TenantId, PaymentId = payment.Id, Provider = payment.Provider, ProviderEventId = providerEventId, EventType = eventType, Payload = payload.ValueKind == JsonValueKind.Undefined ? JsonDefaults.Object() : payload.Clone() }); if (payment.Status == PlatformBillingPaymentStatus.Succeeded) { await dbContext.SaveChangesAsync(cancellationToken); return payment; } if (payment.Status != PlatformBillingPaymentStatus.Pending || order.Status != PlatformBillingOrderStatus.PendingPayment) { throw Error("Payment or order status does not allow settlement.", "platform_billing_payment_status_invalid"); } payment.Status = PlatformBillingPaymentStatus.Succeeded; payment.ProviderTradeNo = providerTradeNo; payment.PaidAt = paidAt; order.Status = PlatformBillingOrderStatus.Paid; order.PaidAt = paidAt; var tenant = await dbContext.Tenants.SingleAsync(value => value.Id == order.TenantId, cancellationToken); tenant.BillingStatus = BillingStatus.Active; var orderItems = await dbContext.PlatformBillingOrderItems.AsNoTracking() .Where(value => value.TenantId == order.TenantId && value.OrderId == order.Id) .ToArrayAsync(cancellationToken); var baseItem = orderItems.SingleOrDefault(value => value.ItemType == PlatformBillingItemType.BasePlan) ?? throw Error("Order base plan item is missing.", "platform_billing_base_plan_missing"); var baseVersion = await dbContext.SaasOfferingVersions.AsNoTracking() .SingleAsync(value => value.Id == baseItem.OfferingVersionId, cancellationToken); var subscription = await dbContext.TenantSaasSubscriptions .OrderByDescending(value => value.UpdatedAt) .FirstOrDefaultAsync(value => value.TenantId == order.TenantId, cancellationToken); var oldFeatures = subscription is null ? [] : await LoadSubscriptionFeaturesAsync(subscription, cancellationToken); var now = paidAt; if (subscription is null) { subscription = new TenantSaasSubscription { TenantId = order.TenantId, BaseOfferingVersionId = baseVersion.Id, Status = TenantSaasSubscriptionStatus.Active, StartsAt = now, CurrentPeriodStart = now, CurrentPeriodEnd = AddCycle(now, baseVersion.BillingCycle) }; dbContext.TenantSaasSubscriptions.Add(subscription); } else if (order.Purpose == PlatformBillingOrderPurpose.Renewal) { var periodStart = subscription.CurrentPeriodEnd > now ? subscription.CurrentPeriodEnd : now; subscription.Status = TenantSaasSubscriptionStatus.Active; subscription.CurrentPeriodStart = subscription.CurrentPeriodEnd > now ? subscription.CurrentPeriodStart : now; subscription.CurrentPeriodEnd = AddCycle(periodStart, baseVersion.BillingCycle); subscription.CancelAtPeriodEnd = false; subscription.CancelledAt = null; } else if (order.Purpose == PlatformBillingOrderPurpose.Downgrade) { subscription.ScheduledBaseOfferingVersionId = baseVersion.Id; } else { subscription.BaseOfferingVersionId = baseVersion.Id; subscription.ScheduledBaseOfferingVersionId = null; subscription.Status = TenantSaasSubscriptionStatus.Active; subscription.CancelAtPeriodEnd = false; subscription.CancelledAt = null; if (subscription.CurrentPeriodEnd <= now) { subscription.CurrentPeriodStart = now; subscription.CurrentPeriodEnd = AddCycle(now, baseVersion.BillingCycle); } } if (order.Purpose != PlatformBillingOrderPurpose.Downgrade) { var existingItems = await dbContext.TenantSaasSubscriptionItems .Where(value => value.TenantId == order.TenantId && value.SubscriptionId == subscription.Id && value.Status == TenantSaasSubscriptionItemStatus.Active) .ToArrayAsync(cancellationToken); foreach (var existing in existingItems) { existing.Status = TenantSaasSubscriptionItemStatus.Cancelled; existing.EndsAt = now > existing.StartsAt ? now : existing.StartsAt.AddTicks(1); } dbContext.TenantSaasSubscriptionItems.AddRange(orderItems.Select(item => new TenantSaasSubscriptionItem { TenantId = order.TenantId, SubscriptionId = subscription.Id, OfferingVersionId = item.OfferingVersionId, SourceOrderItemId = item.Id, ItemType = item.ItemType == PlatformBillingItemType.BasePlan ? TenantSaasSubscriptionItemType.BasePlan : TenantSaasSubscriptionItemType.AddOn, Status = TenantSaasSubscriptionItemStatus.Active, StartsAt = now, EndsAt = subscription.CurrentPeriodEnd })); } else { var existingScheduledItems = await dbContext.TenantSaasSubscriptionItems .Where(value => value.TenantId == order.TenantId && value.SubscriptionId == subscription.Id && value.Status == TenantSaasSubscriptionItemStatus.Scheduled) .ToArrayAsync(cancellationToken); foreach (var existing in existingScheduledItems) { existing.Status = TenantSaasSubscriptionItemStatus.Cancelled; existing.EndsAt = now > existing.StartsAt ? now : existing.StartsAt.AddTicks(1); } dbContext.TenantSaasSubscriptionItems.AddRange(orderItems.Select(item => new TenantSaasSubscriptionItem { TenantId = order.TenantId, SubscriptionId = subscription.Id, OfferingVersionId = item.OfferingVersionId, SourceOrderItemId = item.Id, ItemType = item.ItemType == PlatformBillingItemType.BasePlan ? TenantSaasSubscriptionItemType.BasePlan : TenantSaasSubscriptionItemType.AddOn, Status = TenantSaasSubscriptionItemStatus.Scheduled, StartsAt = subscription.CurrentPeriodEnd, EndsAt = AddCycle(subscription.CurrentPeriodEnd, baseVersion.BillingCycle) })); } var invoice = new PlatformBillingInvoice { TenantId = order.TenantId, OrderId = order.Id, InvoiceNo = $"SI{now:yyyyMMddHHmmss}{Random.Shared.Next(1000, 9999)}", Status = PlatformBillingInvoiceStatus.Paid, TotalAmountCents = order.TotalAmountCents, Currency = order.Currency, IssuedAt = now, PaidAt = now, BillingProfileSnapshot = await LoadBillingProfileSnapshotAsync(order.TenantId, cancellationToken) }; dbContext.PlatformBillingInvoices.Add(invoice); dbContext.AuditLogs.Add(new AuditLog { TenantId = order.TenantId, ActorUserId = actorUserId, Action = "platform_billing.payment.settled", TargetType = "platform_billing_orders", TargetId = order.Id.ToString(), Details = JsonSerializer.SerializeToElement(new { order.OrderNo, payment.PaymentNo, payment.Provider, order.Purpose }) }); await dbContext.SaveChangesAsync(cancellationToken); await featureCacheInvalidator.InvalidateAsync(order.TenantId, cancellationToken); var newFeatures = await LoadSubscriptionFeaturesAsync(subscription, cancellationToken); var changedFeatures = oldFeatures.Concat(newFeatures).Distinct(StringComparer.Ordinal).ToArray(); var version = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); foreach (var featureCode in changedFeatures) { await securityEventPublisher.CapabilityChangedAsync( order.TenantId, featureCode, "saas_subscription_changed", version, $"platform-billing-order-{order.Id:N}", cancellationToken); } return payment; } private async Task LoadSubscriptionFeaturesAsync(TenantSaasSubscription subscription, CancellationToken cancellationToken) { var versionIds = await dbContext.TenantSaasSubscriptionItems.AsNoTracking() .Where(value => value.TenantId == subscription.TenantId && value.SubscriptionId == subscription.Id && value.Status == TenantSaasSubscriptionItemStatus.Active) .Select(value => value.OfferingVersionId) .ToArrayAsync(cancellationToken); if (!versionIds.Contains(subscription.BaseOfferingVersionId)) { versionIds = [.. versionIds, subscription.BaseOfferingVersionId]; } return await dbContext.SaasOfferingVersionFeatures.AsNoTracking() .Where(value => versionIds.Contains(value.OfferingVersionId)) .Select(value => value.FeatureCode) .Distinct() .ToArrayAsync(cancellationToken); } private async Task LoadBillingProfileSnapshotAsync(Guid tenantId, CancellationToken cancellationToken) { var profile = await dbContext.TenantBillingProfiles.AsNoTracking() .SingleOrDefaultAsync(value => value.TenantId == tenantId, cancellationToken); return profile is null ? JsonDefaults.Object() : JsonSerializer.SerializeToElement(new { profile.BillingName, profile.TaxId, profile.ContactName, profile.ContactPhone, profile.ContactEmail, profile.InvoiceTitle, profile.InvoiceType }); } private static DateTimeOffset AddCycle(DateTimeOffset start, PlatformBillingCycle cycle) => cycle switch { PlatformBillingCycle.Monthly => start.AddMonths(1), PlatformBillingCycle.Quarterly => start.AddMonths(3), PlatformBillingCycle.Yearly => start.AddYears(1), PlatformBillingCycle.OneTime => start.AddYears(100), _ => throw new ArgumentOutOfRangeException(nameof(cycle)) }; private static PlatformBillingException Error(string message, string code) => new(message, code); }