Files
tiku-backend.net/Tiku.Infrastructure/PlatformBilling/PlatformBillingAdminService.cs

148 lines
9.2 KiB
C#

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.Infrastructure.Persistence;
using Tiku.Infrastructure.Messaging;
namespace Tiku.Infrastructure.PlatformBilling;
internal sealed class PlatformBillingAdminService(
ITenantExecutionScope tenantExecutionScope) : IPlatformBillingAdminService
{
public Task<IReadOnlyCollection<PlatformBillingOrder>> GetOrdersAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default) =>
ExecuteAsync<IReadOnlyCollection<PlatformBillingOrder>>("list SaaS orders", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
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<PlatformBillingOrderStatus>(query.Status));
return await values.OrderByDescending(value => value.CreatedAt).Take(Limit(query.Limit)).ToArrayAsync(token);
}, cancellationToken);
public Task<IReadOnlyCollection<PlatformBillingPayment>> GetPaymentsAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default) =>
ExecuteAsync<IReadOnlyCollection<PlatformBillingPayment>>("list SaaS payments", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
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<PlatformBillingPaymentStatus>(query.Status));
return await values.OrderByDescending(value => value.CreatedAt).Take(Limit(query.Limit)).ToArrayAsync(token);
}, cancellationToken);
public Task<IReadOnlyCollection<PlatformBillingRefund>> GetRefundsAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default) =>
ExecuteAsync<IReadOnlyCollection<PlatformBillingRefund>>("list SaaS refunds", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
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<PlatformBillingRefundStatus>(query.Status));
return await values.OrderByDescending(value => value.CreatedAt).Take(Limit(query.Limit)).ToArrayAsync(token);
}, cancellationToken);
public Task<IReadOnlyCollection<PlatformBillingInvoice>> GetInvoicesAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default) =>
ExecuteAsync<IReadOnlyCollection<PlatformBillingInvoice>>("list SaaS invoices", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
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<PlatformBillingInvoiceStatus>(query.Status));
return await values.OrderByDescending(value => value.CreatedAt).Take(Limit(query.Limit)).ToArrayAsync(token);
}, cancellationToken);
public Task<IReadOnlyCollection<TenantSaasSubscription>> GetSubscriptionsAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default) =>
ExecuteAsync<IReadOnlyCollection<TenantSaasSubscription>>("list SaaS subscriptions", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
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<TenantSaasSubscriptionStatus>(query.Status));
return await values.OrderByDescending(value => value.UpdatedAt).Take(Limit(query.Limit)).ToArrayAsync(token);
}, cancellationToken);
public Task<PlatformBillingPayment> ConfirmManualPaymentAsync(
SaasCatalogActor actor,
ConfirmManualPaymentCommand command,
CancellationToken cancellationToken = default) =>
ExecuteAsync("confirm manual SaaS payment", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
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<IPlatformBillingSettlementService>().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<TenantFeatureOverride> UpsertFeatureOverrideAsync(
SaasCatalogActor actor,
UpsertTenantFeatureOverrideCommand command,
CancellationToken cancellationToken = default) =>
ExecuteAsync("upsert tenant feature override", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
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<ISecurityEventPublisher>().CapabilityChangedAsync(
command.TenantId,
featureCode,
"tenant_feature_override_changed",
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
$"tenant-feature-override-{item.Id:N}",
token);
return item;
}, cancellationToken);
private Task<TResult> ExecuteAsync<TResult>(
string reason,
Func<IServiceProvider, CancellationToken, Task<TResult>> operation,
CancellationToken cancellationToken) =>
tenantExecutionScope.ExecuteAsync(
new SystemScopeRequest(null, SystemScopeCallerType.Platform, nameof(PlatformBillingAdminService), reason, Guid.NewGuid().ToString("N"), true),
operation,
cancellationToken);
private static T Parse<T>(string value) where T : struct, Enum =>
Enum.TryParse<T>(value, true, out var parsed) ? parsed : throw Error("Status is invalid.", "status_invalid");
private static int Limit(int value) => Math.Clamp(value, 1, 200);
private static PlatformBillingException Error(string message, string code) => new(message, code);
}