using System.Security.Cryptography; using System.Text; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Tiku.Application.PlatformBilling; using Tiku.Application.Security; using Tiku.Domain.Operations; using Tiku.Domain.Platform; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.PlatformBilling; internal sealed class PlatformBillingAdminService( ITenantExecutionScope tenantExecutionScope) : IPlatformBillingAdminService { public Task> GetOrdersAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default) { return ExecuteAsync>("list SaaS orders", async (services, token) => { var db = services.GetRequiredService(); var values = db.PlatformBillingOrders.AsNoTracking(); if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); if (!string.IsNullOrWhiteSpace(query.Status)) values = values.Where(value => value.Status == Parse(query.Status)); return await values.OrderByDescending(value => value.CreatedAt).Take(Limit(query.Limit)) .ToArrayAsync(token); }, cancellationToken); } public Task> GetPaymentsAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default) { return ExecuteAsync>("list SaaS payments", async (services, token) => { var db = services.GetRequiredService(); var values = db.PlatformBillingPayments.AsNoTracking(); if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); if (!string.IsNullOrWhiteSpace(query.Status)) values = values.Where(value => value.Status == Parse(query.Status)); return await values.OrderByDescending(value => value.CreatedAt).Take(Limit(query.Limit)) .ToArrayAsync(token); }, cancellationToken); } public Task> GetRefundsAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default) { return ExecuteAsync>("list SaaS refunds", async (services, token) => { var db = services.GetRequiredService(); var values = db.PlatformBillingRefunds.AsNoTracking(); if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); if (!string.IsNullOrWhiteSpace(query.Status)) values = values.Where(value => value.Status == Parse(query.Status)); return await values.OrderByDescending(value => value.CreatedAt).Take(Limit(query.Limit)) .ToArrayAsync(token); }, cancellationToken); } public Task> GetInvoicesAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default) { return ExecuteAsync>("list SaaS invoices", async (services, token) => { var db = services.GetRequiredService(); var values = db.PlatformBillingInvoices.AsNoTracking(); if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); if (!string.IsNullOrWhiteSpace(query.Status)) values = values.Where(value => value.Status == Parse(query.Status)); return await values.OrderByDescending(value => value.CreatedAt).Take(Limit(query.Limit)) .ToArrayAsync(token); }, cancellationToken); } public Task> GetUsageAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default) { return ExecuteAsync>("list SaaS usage", async (services, token) => { var db = services.GetRequiredService(); var values = db.TenantFeatureUsages.AsNoTracking(); if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); return await values.OrderByDescending(value => value.PeriodStart).ThenBy(value => value.MetricCode) .Take(Limit(query.Limit)).ToArrayAsync(token); }, cancellationToken); } public Task> GetInvoiceRemindersAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default) { return ExecuteAsync>("list SaaS invoice reminders", async (services, token) => { var db = services.GetRequiredService(); var values = db.PlatformBillingInvoiceReminders.AsNoTracking(); if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); if (!string.IsNullOrWhiteSpace(query.Status)) values = values.Where(value => value.Status == Parse(query.Status)); return await values.OrderByDescending(value => value.ReminderDate) .ThenByDescending(value => value.CreatedAt).Take(Limit(query.Limit)).ToArrayAsync(token); }, cancellationToken); } public Task> GetSubscriptionsAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default) { return ExecuteAsync>("list SaaS subscriptions", async (services, token) => { var db = services.GetRequiredService(); var values = db.TenantSaasSubscriptions.AsNoTracking(); if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId); if (!string.IsNullOrWhiteSpace(query.Status)) values = values.Where(value => value.Status == Parse(query.Status)); return await values.OrderByDescending(value => value.UpdatedAt).Take(Limit(query.Limit)) .ToArrayAsync(token); }, cancellationToken); } public Task ConfirmManualPaymentAsync( SaasCatalogActor actor, ConfirmManualPaymentCommand command, CancellationToken cancellationToken = default) { return ExecuteAsync("confirm manual SaaS payment", async (services, token) => { var db = services.GetRequiredService(); var payment = await db.PlatformBillingPayments.AsNoTracking() .SingleOrDefaultAsync(value => value.Id == command.PaymentId, token) ?? throw Error("Platform billing payment was not found.", "platform_billing_payment_not_found"); if (payment.Provider != "manual") throw Error("Only a manual payment can be confirmed by an operator.", "platform_billing_manual_payment_required"); if (string.IsNullOrWhiteSpace(command.Reason)) throw Error("Manual payment confirmation reason is required.", "platform_billing_reason_required"); return await services.GetRequiredService().MarkPaidAsync( payment.Id, $"manual-{payment.Id:N}", "manual_payment_confirmed", command.ProviderTradeNo, command.PaidAt ?? DateTimeOffset.UtcNow, JsonSerializer.SerializeToElement(new { command.Reason }), actor.UserId, token); }, cancellationToken); } public Task UpsertFeatureOverrideAsync( SaasCatalogActor actor, UpsertTenantFeatureOverrideCommand command, CancellationToken cancellationToken = default) { return ExecuteAsync("upsert tenant feature override", async (services, token) => { var db = services.GetRequiredService(); var featureCode = command.FeatureCode.Trim().ToLowerInvariant(); if (string.IsNullOrWhiteSpace(command.Reason)) throw Error("Override reason is required.", "platform_billing_reason_required"); if (!await db.Tenants.AnyAsync(value => value.Id == command.TenantId, token)) throw Error("Tenant was not found.", "tenant_not_found"); if (!await db.SaasFeatures.AnyAsync(value => value.Code == featureCode && !value.IsCore, token)) throw Error("SaaS feature was not found.", "saas_feature_not_found"); var item = await db.TenantFeatureOverrides.SingleOrDefaultAsync( value => value.TenantId == command.TenantId && value.FeatureCode == featureCode, token); if (item is null) { item = new TenantFeatureOverride { TenantId = command.TenantId, FeatureCode = featureCode }; db.TenantFeatureOverrides.Add(item); } item.Mode = command.Mode; item.ExpiresAt = command.ExpiresAt; item.Reason = command.Reason.Trim(); db.AuditLogs.Add(new AuditLog { TenantId = command.TenantId, ActorUserId = actor.UserId, Action = "platform.saas.feature_override.updated", TargetType = "tenant_feature_overrides", TargetId = item.Id.ToString(), Details = JsonSerializer.SerializeToElement(new { item.FeatureCode, item.Mode, item.ExpiresAt, item.Reason }) }); await db.SaveChangesAsync(token); await services.GetRequiredService() .InvalidateAsync(command.TenantId, token); return item; }, cancellationToken); } public Task GrantTrialAsync( SaasCatalogActor actor, GrantTenantTrialCommand command, CancellationToken cancellationToken = default) { return ExecuteAsync("grant tenant SaaS trial", async (services, token) => { var db = services.GetRequiredService(); var key = Required(command.IdempotencyKey, "idempotencyKey"); var existingRequest = await db.PlatformOperationIdempotencies.AsNoTracking() .SingleOrDefaultAsync(value => value.ActorUserId == actor.UserId && value.Scope == "platform.saas.trial" && value.IdempotencyKey == key, token); if (existingRequest is not null) { var requestHash = Hash($"{command.TenantId:N}|{command.BaseOfferingVersionId:N}|{command.TrialDays}"); if (!string.Equals(existingRequest.RequestHash, requestHash, StringComparison.Ordinal)) throw Error("Idempotency key was used with a different trial request.", "idempotency_conflict"); return await db.TenantSaasSubscriptions.SingleAsync(value => value.Id == existingRequest.ResourceId, token); } if (await db.TenantSaasSubscriptions.AnyAsync(value => value.TenantId == command.TenantId && value.Status != TenantSaasSubscriptionStatus .Cancelled && value.Status != TenantSaasSubscriptionStatus.Expired, token)) throw Error("Tenant already has an effective subscription.", "tenant_saas_subscription_exists"); var version = await ( from value in db.SaasOfferingVersions.AsNoTracking() join offering in db.SaasOfferings.AsNoTracking() on value.OfferingId equals offering.Id where value.Id == command.BaseOfferingVersionId && value.Status == SaasOfferingVersionStatus.Published && offering.Type == SaasOfferingType.BasePlan select value).SingleOrDefaultAsync(token) ?? throw Error("Published base offering version was not found.", "saas_offering_version_not_found"); var tenant = await db.Tenants.SingleOrDefaultAsync(value => value.Id == command.TenantId, token) ?? throw Error("Tenant was not found.", "tenant_not_found"); var now = DateTimeOffset.UtcNow; var end = now.AddDays(Math.Clamp(command.TrialDays, 1, 365)); var subscription = new TenantSaasSubscription { TenantId = tenant.Id, BaseOfferingVersionId = version.Id, Status = TenantSaasSubscriptionStatus.Trial, StartsAt = now, CurrentPeriodStart = now, CurrentPeriodEnd = end, LifecycleVersion = 1 }; db.TenantSaasSubscriptions.Add(subscription); db.TenantSaasSubscriptionItems.Add(new TenantSaasSubscriptionItem { TenantId = tenant.Id, SubscriptionId = subscription.Id, OfferingVersionId = version.Id, ItemType = TenantSaasSubscriptionItemType.BasePlan, Status = TenantSaasSubscriptionItemStatus.Active, StartsAt = now, EndsAt = end }); tenant.BillingStatus = BillingStatus.Trial; db.PlatformOperationIdempotencies.Add(new PlatformOperationIdempotency { ActorUserId = actor.UserId, Scope = "platform.saas.trial", IdempotencyKey = key, RequestHash = Hash($"{command.TenantId:N}|{command.BaseOfferingVersionId:N}|{command.TrialDays}"), ResourceId = subscription.Id }); AddAudit(db, actor.UserId, tenant.Id, "platform.saas.trial.granted", subscription.Id, command.Reason); await db.SaveChangesAsync(token); await services.GetRequiredService().InvalidateAsync(tenant.Id, token); return subscription; }, cancellationToken); } public Task SuspendSubscriptionAsync(SaasCatalogActor actor, ChangePlatformSubscriptionCommand command, CancellationToken cancellationToken = default) { return ChangeSubscriptionStatusAsync(actor, command, "suspended", cancellationToken); } public Task ResumeSubscriptionAsync(SaasCatalogActor actor, ChangePlatformSubscriptionCommand command, CancellationToken cancellationToken = default) { return ChangeSubscriptionStatusAsync(actor, command, "resumed", cancellationToken); } public Task CancelSubscriptionAsync(SaasCatalogActor actor, ChangePlatformSubscriptionCommand command, CancellationToken cancellationToken = default) { return ChangeSubscriptionStatusAsync(actor, command, "cancelled", cancellationToken); } public Task ExtendSubscriptionAsync(SaasCatalogActor actor, ChangePlatformSubscriptionCommand command, CancellationToken cancellationToken = default) { return ChangeSubscriptionStatusAsync(actor, command, "extended", cancellationToken); } public Task RequestRefundAsync( SaasCatalogActor actor, RequestPlatformRefundCommand command, CancellationToken cancellationToken = default) { return ExecuteAsync("request SaaS refund", async (services, token) => { var db = services.GetRequiredService(); var key = Required(command.IdempotencyKey, "idempotencyKey"); var payment = await db.PlatformBillingPayments.AsNoTracking() .SingleOrDefaultAsync(value => value.Id == command.PaymentId, token) ?? throw Error("Payment was not found.", "platform_billing_payment_not_found"); var reason = Required(command.Reason, "reason"); var existing = await db.PlatformBillingRefunds.SingleOrDefaultAsync(value => value.TenantId == payment.TenantId && value.IdempotencyKey == key, token); if (existing is not null) { if (existing.PaymentId != command.PaymentId || existing.AmountCents != command.AmountCents || existing.SubscriptionEffect != command.SubscriptionEffect || !string.Equals(existing.Reason, reason, StringComparison.Ordinal)) throw Error("Idempotency key was used with a different refund request.", "idempotency_conflict"); return existing; } if (payment.Status != PlatformBillingPaymentStatus.Succeeded) throw Error("Only a succeeded payment can be refunded.", "platform_billing_payment_not_refundable"); var refunded = await db.PlatformBillingRefunds.AsNoTracking() .Where(value => value.TenantId == payment.TenantId && value.PaymentId == payment.Id && value.Status != PlatformBillingRefundStatus.Cancelled && value.Status != PlatformBillingRefundStatus.Failed) .SumAsync(value => (int?)value.AmountCents, token) ?? 0; if (command.AmountCents <= 0 || refunded + command.AmountCents > payment.AmountCents) throw Error("Refund amount exceeds the refundable payment balance.", "platform_billing_refund_amount_invalid"); var refund = new PlatformBillingRefund { TenantId = payment.TenantId, OrderId = payment.OrderId, PaymentId = payment.Id, RefundNo = Number("SR"), Status = PlatformBillingRefundStatus.Requested, AmountCents = command.AmountCents, Reason = reason, IdempotencyKey = key, SubscriptionEffect = command.SubscriptionEffect, RequestedBy = actor.UserId }; db.PlatformBillingRefunds.Add(refund); AddAudit(db, actor.UserId, refund.TenantId, "platform.saas.refund.requested", refund.Id, refund.Reason); await db.SaveChangesAsync(token); return refund; }, cancellationToken); } public Task ApproveRefundAsync(SaasCatalogActor actor, ReviewPlatformRefundCommand command, CancellationToken cancellationToken = default) { return ReviewRefundAsync(actor, command, true, cancellationToken); } public Task RejectRefundAsync(SaasCatalogActor actor, ReviewPlatformRefundCommand command, CancellationToken cancellationToken = default) { return ReviewRefundAsync(actor, command, false, cancellationToken); } public Task RetryRefundAsync(SaasCatalogActor actor, ReviewPlatformRefundCommand command, CancellationToken cancellationToken = default) { return ExecuteAsync("retry SaaS refund", async (services, token) => { var db = services.GetRequiredService(); var refund = await db.PlatformBillingRefunds.SingleOrDefaultAsync(value => value.Id == command.RefundId, token) ?? throw Error("Refund was not found.", "platform_billing_refund_not_found"); if (refund.Status != PlatformBillingRefundStatus.Failed) throw Error("Only a failed refund can be retried.", "platform_billing_refund_not_retryable"); refund.Status = PlatformBillingRefundStatus.Processing; refund.LastError = null; AddAudit(db, actor.UserId, refund.TenantId, "platform.saas.refund.retried", refund.Id, command.Reason); await db.SaveChangesAsync(token); return refund; }, cancellationToken); } public Task GetCommercialMetricsAsync(SaasCatalogActor actor, CancellationToken cancellationToken = default) { return ExecuteAsync("get SaaS commercial metrics", async (services, token) => { var db = services.GetRequiredService(); var now = DateTimeOffset.UtcNow; var periodStart = new DateTimeOffset(now.Year, now.Month, 1, 0, 0, 0, TimeSpan.Zero); var subscriptions = await ( from subscription in db.TenantSaasSubscriptions.AsNoTracking() join version in db.SaasOfferingVersions.AsNoTracking() on subscription.BaseOfferingVersionId equals version.Id select new { subscription.Status, version.AmountCents, version.BillingCycle }).ToArrayAsync(token); var mrr = subscriptions.Where(value => value.Status == TenantSaasSubscriptionStatus.Active) .Sum(value => value.BillingCycle switch { PlatformBillingCycle.Monthly => value.AmountCents, PlatformBillingCycle.Quarterly => value.AmountCents / 3, PlatformBillingCycle.Yearly => value.AmountCents / 12, _ => 0 }); var collected = await db.PlatformBillingPayments.AsNoTracking() .Where(value => value.Status == PlatformBillingPaymentStatus.Succeeded && value.PaidAt >= periodStart) .SumAsync(value => (int?)value.AmountCents, token) ?? 0; var refunded = await db.PlatformBillingRefunds.AsNoTracking() .Where(value => value.Status == PlatformBillingRefundStatus.Succeeded && value.CompletedAt >= periodStart) .SumAsync(value => (int?)value.AmountCents, token) ?? 0; var outstanding = await db.PlatformBillingInvoices.AsNoTracking() .Where(value => value.Status == PlatformBillingInvoiceStatus.Issued || value.Status == PlatformBillingInvoiceStatus.Overdue) .SumAsync(value => (int?)value.TotalAmountCents, token) ?? 0; var overdue = await db.PlatformBillingInvoices.AsNoTracking() .Where(value => value.Status == PlatformBillingInvoiceStatus.Overdue) .SumAsync(value => (int?)value.TotalAmountCents, token) ?? 0; return new CommercialMetrics( subscriptions.Count(value => value.Status == TenantSaasSubscriptionStatus.Active), subscriptions.Count(value => value.Status == TenantSaasSubscriptionStatus.Trial), subscriptions.Count(value => value.Status == TenantSaasSubscriptionStatus.PastDue), mrr, mrr * 12, collected, outstanding, overdue, refunded, subscriptions.Count(value => value.Status is TenantSaasSubscriptionStatus.Cancelled or TenantSaasSubscriptionStatus.Expired)); }, cancellationToken); } private Task ChangeSubscriptionStatusAsync( SaasCatalogActor actor, ChangePlatformSubscriptionCommand command, string action, CancellationToken cancellationToken) { return ExecuteAsync($"{action} SaaS subscription", async (services, token) => { var db = services.GetRequiredService(); var subscription = await db.TenantSaasSubscriptions.SingleOrDefaultAsync(value => value.Id == command.SubscriptionId, token) ?? throw Error("Subscription was not found.", "tenant_saas_subscription_not_found"); var tenant = await db.Tenants.SingleAsync(value => value.Id == subscription.TenantId, token); var items = await db.TenantSaasSubscriptionItems .Where(value => value.TenantId == subscription.TenantId && value.SubscriptionId == subscription.Id) .ToArrayAsync(token); _ = Required(command.Reason, "reason"); switch (action) { case "suspended": if (subscription.Status is TenantSaasSubscriptionStatus.Cancelled or TenantSaasSubscriptionStatus.Expired) throw Error("Subscription cannot be suspended.", "tenant_saas_subscription_status_invalid"); subscription.Status = TenantSaasSubscriptionStatus.Suspended; tenant.BillingStatus = BillingStatus.Suspended; break; case "resumed": if (subscription.Status != TenantSaasSubscriptionStatus.Suspended) throw Error("Only a suspended subscription can be resumed.", "tenant_saas_subscription_status_invalid"); subscription.Status = subscription.CurrentPeriodEnd > DateTimeOffset.UtcNow ? TenantSaasSubscriptionStatus.Active : TenantSaasSubscriptionStatus.PastDue; tenant.BillingStatus = subscription.Status == TenantSaasSubscriptionStatus.Active ? BillingStatus.Active : BillingStatus.PastDue; break; case "cancelled": subscription.Status = TenantSaasSubscriptionStatus.Cancelled; subscription.CancelAtPeriodEnd = false; subscription.CancelledAt = DateTimeOffset.UtcNow; tenant.BillingStatus = BillingStatus.Cancelled; foreach (var item in items.Where(value => value.Status is TenantSaasSubscriptionItemStatus.Active or TenantSaasSubscriptionItemStatus.Scheduled)) { item.Status = TenantSaasSubscriptionItemStatus.Cancelled; item.EndsAt = DateTimeOffset.UtcNow > item.StartsAt ? DateTimeOffset.UtcNow : item.StartsAt.AddTicks(1); } break; case "extended": var days = Math.Clamp(command.ExtendDays ?? 0, 1, 3650); subscription.CurrentPeriodEnd = subscription.CurrentPeriodEnd.AddDays(days); foreach (var item in items.Where(value => value.Status is TenantSaasSubscriptionItemStatus.Active or TenantSaasSubscriptionItemStatus.Scheduled)) item.EndsAt = item.EndsAt.AddDays(days); break; default: throw new ArgumentOutOfRangeException(nameof(action)); } subscription.LifecycleVersion++; AddAudit(db, actor.UserId, subscription.TenantId, $"platform.saas.subscription.{action}", subscription.Id, command.Reason); await db.SaveChangesAsync(token); await services.GetRequiredService() .InvalidateAsync(subscription.TenantId, token); return subscription; }, cancellationToken); } private Task ReviewRefundAsync( SaasCatalogActor actor, ReviewPlatformRefundCommand command, bool approve, CancellationToken cancellationToken) { return ExecuteAsync(approve ? "approve SaaS refund" : "reject SaaS refund", async (services, token) => { var db = services.GetRequiredService(); var refund = await db.PlatformBillingRefunds.SingleOrDefaultAsync(value => value.Id == command.RefundId, token) ?? throw Error("Refund was not found.", "platform_billing_refund_not_found"); if (refund.Status != PlatformBillingRefundStatus.Requested) throw Error("Refund is not awaiting review.", "platform_billing_refund_status_invalid"); refund.Status = approve ? PlatformBillingRefundStatus.Processing : PlatformBillingRefundStatus.Cancelled; refund.ReviewedBy = actor.UserId; refund.ReviewedAt = DateTimeOffset.UtcNow; refund.ReviewReason = Required(command.Reason, "reason"); AddAudit(db, actor.UserId, refund.TenantId, approve ? "platform.saas.refund.approved" : "platform.saas.refund.rejected", refund.Id, refund.ReviewReason); await db.SaveChangesAsync(token); return refund; }, cancellationToken); } private static void AddAudit(TikuDbContext db, Guid actorUserId, Guid tenantId, string action, Guid targetId, string? reason) { db.AuditLogs.Add(new AuditLog { TenantId = tenantId, ActorUserId = actorUserId, Action = action, TargetType = action.Contains("refund", StringComparison.Ordinal) ? "platform_billing_refunds" : "tenant_saas_subscriptions", TargetId = targetId.ToString(), Details = JsonSerializer.SerializeToElement(new { reason }) }); } private static string Required(string? value, string field) { return string.IsNullOrWhiteSpace(value) ? throw Error($"{field} is required.", "required_field") : value.Trim(); } private static string Number(string prefix) { return $"{prefix}{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Guid.NewGuid():N}"[..32]; } private static string Hash(string value) { return Convert .ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))) .ToLowerInvariant(); } private Task ExecuteAsync( string reason, Func> operation, CancellationToken cancellationToken) { return tenantExecutionScope.ExecuteAsync( new SystemScopeRequest(null, SystemScopeCallerType.Platform, nameof(PlatformBillingAdminService), reason, Guid.NewGuid().ToString("N"), true), operation, cancellationToken); } private static T Parse(string value) where T : struct, Enum { return Enum.TryParse(value, true, out var parsed) ? parsed : throw Error("Status is invalid.", "status_invalid"); } private static int Limit(int value) { return Math.Clamp(value, 1, 200); } private static PlatformBillingException Error(string message, string code) { return new PlatformBillingException(message, code); } }