using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Tiku.Application.Commerce; using Tiku.Application.PlatformBilling; using Tiku.Application.Security; using Tiku.Domain.Common; using Tiku.Domain.Platform; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.PlatformBilling; internal sealed class TenantBillingService( TikuDbContext dbContext, IPlatformBillingPaymentGateway paymentGateway, IPlatformBillingSettlementService settlementService, IFeatureAccessService featureAccessService, IConfiguration configuration) : ITenantBillingService { private static readonly TimeSpan QuoteLifetime = TimeSpan.FromMinutes(30); private static readonly TimeSpan OrderLifetime = TimeSpan.FromHours(2); public async Task GetCatalogAsync( TenantBillingActor actor, CancellationToken cancellationToken = default) { var now = DateTimeOffset.UtcNow; var features = await dbContext.SaasFeatures.AsNoTracking() .Where(value => value.Status == SaasFeatureStatus.Active && !value.IsCore) .OrderBy(value => value.SortOrder).ThenBy(value => value.Code) .ToArrayAsync(cancellationToken); var versions = await LoadPublishedVersionsAsync(now, cancellationToken); return new TenantBillingCatalog( features, versions.Where(value => value.OfferingType == SaasOfferingType.BasePlan).ToArray(), versions.Where(value => value.OfferingType == SaasOfferingType.AddOn).ToArray()); } public async Task CreateQuoteAsync( TenantBillingActor actor, CreatePlatformBillingQuoteCommand command, CancellationToken cancellationToken = default) { var idempotencyKey = Required(command.IdempotencyKey, "idempotencyKey"); var existingQuote = await dbContext.PlatformBillingQuotes.AsNoTracking() .SingleOrDefaultAsync(value => value.TenantId == actor.TenantId && value.IdempotencyKey == idempotencyKey, cancellationToken); if (existingQuote is not null) { return await LoadQuoteAsync(actor.TenantId, existingQuote.Id, cancellationToken); } var requestedIds = new[] { command.BaseOfferingVersionId } .Concat(command.AddOnOfferingVersionIds) .Distinct() .ToArray(); var now = DateTimeOffset.UtcNow; var versions = await ( from version in dbContext.SaasOfferingVersions.AsNoTracking() join offering in dbContext.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id where requestedIds.Contains(version.Id) && version.Status == SaasOfferingVersionStatus.Published && offering.Status == SaasOfferingStatus.Active && (version.EffectiveAt == null || version.EffectiveAt <= now) select new { Version = version, Offering = offering }) .ToArrayAsync(cancellationToken); if (versions.Length != requestedIds.Length || versions.SingleOrDefault(value => value.Version.Id == command.BaseOfferingVersionId)?.Offering.Type != SaasOfferingType.BasePlan || versions.Any(value => value.Version.Id != command.BaseOfferingVersionId && value.Offering.Type != SaasOfferingType.AddOn)) { throw Error("One or more offering versions are unavailable.", "saas_offering_version_unavailable"); } if (versions.Select(value => value.Version.Currency).Distinct(StringComparer.Ordinal).Count() != 1) { throw Error("All quote items must use the same currency.", "platform_billing_currency_mismatch"); } var featureRows = await dbContext.SaasOfferingVersionFeatures.AsNoTracking() .Where(value => requestedIds.Contains(value.OfferingVersionId)) .ToArrayAsync(cancellationToken); var limitRows = await dbContext.SaasOfferingVersionLimits.AsNoTracking() .Where(value => requestedIds.Contains(value.OfferingVersionId)) .ToArrayAsync(cancellationToken); var featureCodes = featureRows.Select(value => value.FeatureCode).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray(); var limits = limitRows.GroupBy(value => value.MetricCode, StringComparer.Ordinal) .ToDictionary(group => group.Key, group => group.Sum(value => value.LimitValue), StringComparer.Ordinal); var originalAmount = versions.Sum(value => value.Version.OriginalAmountCents); var totalAmount = versions.Sum(value => value.Version.AmountCents); var quote = new PlatformBillingQuote { TenantId = actor.TenantId, QuoteNo = Number("SQ"), IdempotencyKey = idempotencyKey, Purpose = command.Purpose, OriginalAmountCents = originalAmount, DiscountAmountCents = originalAmount - totalAmount, TotalAmountCents = totalAmount, Currency = versions[0].Version.Currency, ExpiresAt = now.Add(QuoteLifetime), FeatureSnapshot = JsonSerializer.SerializeToElement(featureCodes), LimitSnapshot = JsonSerializer.SerializeToElement(limits) }; dbContext.PlatformBillingQuotes.Add(quote); dbContext.PlatformBillingQuoteItems.AddRange(versions.Select(value => new PlatformBillingQuoteItem { TenantId = actor.TenantId, QuoteId = quote.Id, OfferingVersionId = value.Version.Id, ItemType = value.Offering.Type == SaasOfferingType.BasePlan ? PlatformBillingItemType.BasePlan : PlatformBillingItemType.AddOn, Quantity = 1, UnitAmountCents = value.Version.AmountCents, AmountCents = value.Version.AmountCents, Snapshot = JsonSerializer.SerializeToElement(new { offeringId = value.Offering.Id, offeringCode = value.Offering.Code, offeringName = value.Offering.Name, offeringType = value.Offering.Type, version = value.Version.Version, value.Version.BillingCycle, featureCodes = featureRows.Where(feature => feature.OfferingVersionId == value.Version.Id).Select(feature => feature.FeatureCode).Order(StringComparer.Ordinal), limits = limitRows.Where(limit => limit.OfferingVersionId == value.Version.Id).ToDictionary(limit => limit.MetricCode, limit => limit.LimitValue) }) })); await dbContext.SaveChangesAsync(cancellationToken); return await LoadQuoteAsync(actor.TenantId, quote.Id, cancellationToken); } public async Task CreateOrderAsync( TenantBillingActor actor, CreatePlatformBillingOrderCommand command, CancellationToken cancellationToken = default) { var key = Required(command.IdempotencyKey, "idempotencyKey"); var existing = await dbContext.PlatformBillingOrders.AsNoTracking() .SingleOrDefaultAsync(value => value.TenantId == actor.TenantId && value.IdempotencyKey == key, cancellationToken); if (existing is not null) { return await LoadOrderAsync(actor.TenantId, existing.OrderNo, cancellationToken); } var now = DateTimeOffset.UtcNow; var quote = await dbContext.PlatformBillingQuotes.SingleOrDefaultAsync(value => value.TenantId == actor.TenantId && value.Id == command.QuoteId, cancellationToken) ?? throw Error("Quote was not found.", "platform_billing_quote_not_found"); if (quote.Status != PlatformBillingQuoteStatus.Active || quote.ExpiresAt <= now) { quote.Status = quote.ExpiresAt <= now ? PlatformBillingQuoteStatus.Expired : quote.Status; await dbContext.SaveChangesAsync(cancellationToken); throw Error("Quote is no longer active.", "platform_billing_quote_expired"); } var quoteItems = await dbContext.PlatformBillingQuoteItems.AsNoTracking() .Where(value => value.TenantId == actor.TenantId && value.QuoteId == quote.Id) .ToArrayAsync(cancellationToken); var order = new PlatformBillingOrder { TenantId = actor.TenantId, QuoteId = quote.Id, OrderNo = Number("SO"), IdempotencyKey = key, Purpose = quote.Purpose, OriginalAmountCents = quote.OriginalAmountCents, DiscountAmountCents = quote.DiscountAmountCents, TotalAmountCents = quote.TotalAmountCents, Currency = quote.Currency, ExpiresAt = now.Add(OrderLifetime), Snapshot = JsonSerializer.SerializeToElement(new { quote.QuoteNo, quote.FeatureSnapshot, quote.LimitSnapshot }) }; dbContext.PlatformBillingOrders.Add(order); dbContext.PlatformBillingOrderItems.AddRange(quoteItems.Select(value => new PlatformBillingOrderItem { TenantId = actor.TenantId, OrderId = order.Id, OfferingVersionId = value.OfferingVersionId, ItemType = value.ItemType, Quantity = value.Quantity, UnitAmountCents = value.UnitAmountCents, AmountCents = value.AmountCents, Snapshot = value.Snapshot })); quote.Status = PlatformBillingQuoteStatus.Converted; await dbContext.SaveChangesAsync(cancellationToken); return await LoadOrderAsync(actor.TenantId, order.OrderNo, cancellationToken); } public async Task CreatePaymentAsync( TenantBillingActor actor, CreatePlatformBillingPaymentCommand command, CancellationToken cancellationToken = default) { var key = Required(command.IdempotencyKey, "idempotencyKey"); var existing = await dbContext.PlatformBillingPayments.AsNoTracking() .SingleOrDefaultAsync(value => value.TenantId == actor.TenantId && value.IdempotencyKey == key, cancellationToken); if (existing is not null) { return ToPaymentView(existing); } var order = await dbContext.PlatformBillingOrders.SingleOrDefaultAsync(value => value.TenantId == actor.TenantId && value.OrderNo == command.OrderNo, cancellationToken) ?? throw Error("Order was not found.", "platform_billing_order_not_found"); if (order.Status != PlatformBillingOrderStatus.PendingPayment || order.ExpiresAt <= DateTimeOffset.UtcNow) { if (order.ExpiresAt <= DateTimeOffset.UtcNow && order.Status == PlatformBillingOrderStatus.PendingPayment) { order.Status = PlatformBillingOrderStatus.Expired; await dbContext.SaveChangesAsync(cancellationToken); } throw Error("Order does not allow a new payment.", "platform_billing_order_status_invalid"); } var provider = NormalizeProvider(command.Provider); var payment = new PlatformBillingPayment { TenantId = actor.TenantId, OrderId = order.Id, PaymentNo = Number("SP"), IdempotencyKey = key, Provider = provider, Method = Required(command.Method, "method"), AmountCents = order.TotalAmountCents }; dbContext.PlatformBillingPayments.Add(payment); await dbContext.SaveChangesAsync(cancellationToken); if (order.TotalAmountCents == 0) { await settlementService.MarkPaidAsync( payment.Id, $"zero-{payment.Id:N}", "zero_amount_settled", null, DateTimeOffset.UtcNow, JsonDefaults.Object(), actor.UserId, cancellationToken); return ToPaymentView(payment); } try { var result = await paymentGateway.CreatePaymentAsync( provider, new CreatePaymentProviderRequest( actor.TenantId, order.OrderNo, $"SaaS subscription {order.OrderNo}", order.TotalAmountCents, payment.Method, command.OpenId, command.ReturnUrl, command.QuitUrl, provider == PaymentProviders.Manual ? string.Empty : BuildNotifyUrl(provider), JsonSerializer.SerializeToElement(new { payment.PaymentNo })), cancellationToken); payment.ProviderTradeNo = result.ProviderTradeNo; payment.ClientPayload = result.ClientPayload; await dbContext.SaveChangesAsync(cancellationToken); return ToPaymentView(payment); } catch { payment.Status = PlatformBillingPaymentStatus.Failed; await dbContext.SaveChangesAsync(CancellationToken.None); throw; } } public async Task> GetOrdersAsync( TenantBillingActor actor, int limit, CancellationToken cancellationToken = default) { var orderNos = await dbContext.PlatformBillingOrders.AsNoTracking() .Where(value => value.TenantId == actor.TenantId) .OrderByDescending(value => value.CreatedAt) .Take(Math.Clamp(limit, 1, 200)) .Select(value => value.OrderNo) .ToArrayAsync(cancellationToken); var result = new List(); foreach (var orderNo in orderNos) { result.Add(await LoadOrderAsync(actor.TenantId, orderNo, cancellationToken)); } return result; } public Task GetOrderAsync(TenantBillingActor actor, string orderNo, CancellationToken cancellationToken = default) => LoadOrderAsync(actor.TenantId, orderNo, cancellationToken); public async Task GetSubscriptionAsync( TenantBillingActor actor, CancellationToken cancellationToken = default) { var subscription = await dbContext.TenantSaasSubscriptions.AsNoTracking() .Where(value => value.TenantId == actor.TenantId) .OrderByDescending(value => value.UpdatedAt) .FirstOrDefaultAsync(cancellationToken); return subscription is null ? null : await ToSubscriptionViewAsync(subscription, cancellationToken); } public async Task ChangeSubscriptionAsync( TenantBillingActor actor, ChangeTenantSubscriptionCommand command, string idempotencyKey, CancellationToken cancellationToken = default) { var current = await dbContext.TenantSaasSubscriptions.AsNoTracking() .Where(value => value.TenantId == actor.TenantId) .OrderByDescending(value => value.UpdatedAt) .FirstOrDefaultAsync(cancellationToken); var currentAmount = current is null ? 0 : await dbContext.SaasOfferingVersions.AsNoTracking().Where(value => value.Id == current.BaseOfferingVersionId).Select(value => value.AmountCents).SingleAsync(cancellationToken); var requestedAmount = await dbContext.SaasOfferingVersions.AsNoTracking().Where(value => value.Id == command.BaseOfferingVersionId).Select(value => (int?)value.AmountCents).SingleOrDefaultAsync(cancellationToken) ?? throw Error("Offering version was not found.", "saas_offering_version_not_found"); var purpose = current is null ? PlatformBillingOrderPurpose.NewSubscription : requestedAmount >= currentAmount ? PlatformBillingOrderPurpose.Upgrade : PlatformBillingOrderPurpose.Downgrade; var quote = await CreateQuoteAsync(actor, new CreatePlatformBillingQuoteCommand( command.BaseOfferingVersionId, command.AddOnOfferingVersionIds, purpose, $"{Required(idempotencyKey, "idempotencyKey")}:quote"), cancellationToken); return await CreateOrderAsync(actor, new CreatePlatformBillingOrderCommand(quote.Id, idempotencyKey), cancellationToken); } public async Task RenewSubscriptionAsync( TenantBillingActor actor, string idempotencyKey, CancellationToken cancellationToken = default) { var subscription = await dbContext.TenantSaasSubscriptions.AsNoTracking() .Where(value => value.TenantId == actor.TenantId) .OrderByDescending(value => value.UpdatedAt) .FirstOrDefaultAsync(cancellationToken) ?? throw Error("Subscription was not found.", "tenant_saas_subscription_not_found"); var addOns = await dbContext.TenantSaasSubscriptionItems.AsNoTracking() .Where(value => value.TenantId == actor.TenantId && value.SubscriptionId == subscription.Id && value.ItemType == TenantSaasSubscriptionItemType.AddOn && value.Status == TenantSaasSubscriptionItemStatus.Active) .Select(value => value.OfferingVersionId) .ToArrayAsync(cancellationToken); var quote = await CreateQuoteAsync(actor, new CreatePlatformBillingQuoteCommand( subscription.BaseOfferingVersionId, addOns, PlatformBillingOrderPurpose.Renewal, $"{Required(idempotencyKey, "idempotencyKey")}:quote"), cancellationToken); return await CreateOrderAsync(actor, new CreatePlatformBillingOrderCommand(quote.Id, idempotencyKey), cancellationToken); } public async Task CancelSubscriptionAsync( TenantBillingActor actor, CancellationToken cancellationToken = default) { var subscription = await dbContext.TenantSaasSubscriptions .Where(value => value.TenantId == actor.TenantId) .OrderByDescending(value => value.UpdatedAt) .FirstOrDefaultAsync(cancellationToken) ?? throw Error("Subscription was not found.", "tenant_saas_subscription_not_found"); subscription.CancelAtPeriodEnd = true; subscription.CancelledAt = DateTimeOffset.UtcNow; await dbContext.SaveChangesAsync(cancellationToken); return await ToSubscriptionViewAsync(subscription, cancellationToken); } public Task> GetUsageAsync(TenantBillingActor actor, CancellationToken cancellationToken = default) => featureAccessService.GetQuotaSummaryAsync(actor.TenantId, cancellationToken); public async Task> GetInvoicesAsync( TenantBillingActor actor, int limit, CancellationToken cancellationToken = default) => await dbContext.PlatformBillingInvoices.AsNoTracking() .Where(value => value.TenantId == actor.TenantId) .OrderByDescending(value => value.CreatedAt) .Take(Math.Clamp(limit, 1, 200)) .ToArrayAsync(cancellationToken); private async Task LoadQuoteAsync(Guid tenantId, Guid quoteId, CancellationToken cancellationToken) { var quote = await dbContext.PlatformBillingQuotes.AsNoTracking().SingleAsync(value => value.TenantId == tenantId && value.Id == quoteId, cancellationToken); var items = await LoadQuoteItemsAsync(tenantId, quote.Id, cancellationToken); return new PlatformBillingQuoteView( quote.Id, quote.QuoteNo, quote.Status, quote.Purpose, quote.OriginalAmountCents, quote.DiscountAmountCents, quote.TotalAmountCents, quote.Currency, quote.ExpiresAt, items, ReadStringArray(quote.FeatureSnapshot), ReadLongDictionary(quote.LimitSnapshot)); } private async Task LoadOrderAsync(Guid tenantId, string orderNo, CancellationToken cancellationToken) { var normalized = Required(orderNo, "orderNo"); var order = await dbContext.PlatformBillingOrders.AsNoTracking().SingleOrDefaultAsync(value => value.TenantId == tenantId && value.OrderNo == normalized, cancellationToken) ?? throw Error("Order was not found.", "platform_billing_order_not_found"); var items = await ( from item in dbContext.PlatformBillingOrderItems.AsNoTracking() join version in dbContext.SaasOfferingVersions.AsNoTracking() on item.OfferingVersionId equals version.Id join offering in dbContext.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id where item.TenantId == tenantId && item.OrderId == order.Id orderby item.ItemType, offering.Code select new PlatformBillingQuoteItemView(item.OfferingVersionId, offering.Code, offering.Name, item.ItemType, item.UnitAmountCents, item.AmountCents)) .ToArrayAsync(cancellationToken); return new PlatformBillingOrderView(order.Id, order.OrderNo, order.Purpose, order.Status, order.TotalAmountCents, order.Currency, order.ExpiresAt, order.PaidAt, items); } private async Task LoadQuoteItemsAsync(Guid tenantId, Guid quoteId, CancellationToken cancellationToken) => await ( from item in dbContext.PlatformBillingQuoteItems.AsNoTracking() join version in dbContext.SaasOfferingVersions.AsNoTracking() on item.OfferingVersionId equals version.Id join offering in dbContext.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id where item.TenantId == tenantId && item.QuoteId == quoteId orderby item.ItemType, offering.Code select new PlatformBillingQuoteItemView(item.OfferingVersionId, offering.Code, offering.Name, item.ItemType, item.UnitAmountCents, item.AmountCents)) .ToArrayAsync(cancellationToken); private async Task ToSubscriptionViewAsync(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]; var features = await dbContext.SaasOfferingVersionFeatures.AsNoTracking() .Where(value => versionIds.Contains(value.OfferingVersionId)) .Select(value => value.FeatureCode) .Distinct() .OrderBy(value => value) .ToArrayAsync(cancellationToken); return new TenantSubscriptionView( subscription.Id, subscription.Status, subscription.StartsAt, subscription.CurrentPeriodStart, subscription.CurrentPeriodEnd, subscription.CancelAtPeriodEnd, subscription.BaseOfferingVersionId, subscription.ScheduledBaseOfferingVersionId, features); } private async Task LoadPublishedVersionsAsync(DateTimeOffset now, CancellationToken cancellationToken) { var rows = await ( from version in dbContext.SaasOfferingVersions.AsNoTracking() join offering in dbContext.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id where version.Status == SaasOfferingVersionStatus.Published && offering.Status == SaasOfferingStatus.Active && (version.EffectiveAt == null || version.EffectiveAt <= now) orderby offering.SortOrder, offering.Code, version.Version descending select new { Version = version, Offering = offering }) .ToArrayAsync(cancellationToken); var latest = rows.GroupBy(value => value.Offering.Id).Select(group => group.First()).ToArray(); var ids = latest.Select(value => value.Version.Id).ToArray(); var features = await dbContext.SaasOfferingVersionFeatures.AsNoTracking().Where(value => ids.Contains(value.OfferingVersionId)).ToArrayAsync(cancellationToken); var limits = await dbContext.SaasOfferingVersionLimits.AsNoTracking().Where(value => ids.Contains(value.OfferingVersionId)).ToArrayAsync(cancellationToken); return latest.Select(row => new SaasOfferingVersionItem( row.Version.Id, row.Offering.Id, row.Offering.Code, row.Offering.Name, row.Offering.Type, row.Version.Version, row.Version.Status, row.Version.BillingCycle, row.Version.OriginalAmountCents, row.Version.AmountCents, row.Version.Currency, row.Version.EffectiveAt, row.Version.PublishedAt, features.Where(value => value.OfferingVersionId == row.Version.Id).Select(value => value.FeatureCode).Order(StringComparer.Ordinal).ToArray(), limits.Where(value => value.OfferingVersionId == row.Version.Id).ToDictionary(value => value.MetricCode, value => value.LimitValue, StringComparer.Ordinal))) .ToArray(); } private string BuildNotifyUrl(string provider) { var baseUrl = configuration["PlatformBilling:PublicBaseUrl"]?.TrimEnd('/'); if (string.IsNullOrWhiteSpace(baseUrl)) { throw Error("Platform billing public base URL is not configured.", "platform_billing_public_url_missing"); } return $"{baseUrl}/api/platform-billing/callbacks/{provider}"; } private static PlatformBillingPaymentView ToPaymentView(PlatformBillingPayment value) => new(value.Id, value.PaymentNo, value.Provider, value.Method, value.Status, value.AmountCents, value.ClientPayload); private static string Number(string prefix) => $"{prefix}{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Guid.NewGuid():N}"[..32]; private static string Required(string? value, string field) => string.IsNullOrWhiteSpace(value) ? throw Error($"{field} is required.", "required_field") : value.Trim(); private static string NormalizeProvider(string value) => Required(value, "provider").ToLowerInvariant().Replace('-', '_'); private static PlatformBillingException Error(string message, string code) => new(message, code); private static string[] ReadStringArray(JsonElement value) => value.ValueKind == JsonValueKind.Array ? value.EnumerateArray().Where(item => item.ValueKind == JsonValueKind.String).Select(item => item.GetString()!).ToArray() : []; private static IReadOnlyDictionary ReadLongDictionary(JsonElement value) { if (value.ValueKind != JsonValueKind.Object) return new Dictionary(StringComparer.Ordinal); return value.EnumerateObject().Where(property => property.Value.TryGetInt64(out _)) .ToDictionary(property => property.Name, property => property.Value.GetInt64(), StringComparer.Ordinal); } }