Files
tiku-backend.net/Tiku.Infrastructure/PlatformBilling/PlatformBillingAdminService.cs
xiong 33375a38d7
Some checks failed
ci / release-gate (push) Has been cancelled
refactor(architecture): harden module boundaries
2026-08-04 12:10:36 +08:00

609 lines
32 KiB
C#

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<IReadOnlyCollection<PlatformBillingOrder>> GetOrdersAsync(SaasCatalogActor actor,
PlatformBillingAdminQuery query, CancellationToken cancellationToken = default)
{
return ExecuteAsync<IReadOnlyCollection<PlatformBillingOrder>>("list SaaS orders", async (services, token) =>
{
var db = services.GetRequiredService<IPlatformBillingPersistence>();
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)
{
return ExecuteAsync<IReadOnlyCollection<PlatformBillingPayment>>("list SaaS payments",
async (services, token) =>
{
var db = services.GetRequiredService<IPlatformBillingPersistence>();
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)
{
return ExecuteAsync<IReadOnlyCollection<PlatformBillingRefund>>("list SaaS refunds", async (services, token) =>
{
var db = services.GetRequiredService<IPlatformBillingPersistence>();
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)
{
return ExecuteAsync<IReadOnlyCollection<PlatformBillingInvoice>>("list SaaS invoices",
async (services, token) =>
{
var db = services.GetRequiredService<IPlatformBillingPersistence>();
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<TenantFeatureUsage>> GetUsageAsync(SaasCatalogActor actor,
PlatformBillingAdminQuery query, CancellationToken cancellationToken = default)
{
return ExecuteAsync<IReadOnlyCollection<TenantFeatureUsage>>("list SaaS usage", async (services, token) =>
{
var db = services.GetRequiredService<IPlatformBillingPersistence>();
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<IReadOnlyCollection<PlatformBillingInvoiceReminder>> GetInvoiceRemindersAsync(SaasCatalogActor actor,
PlatformBillingAdminQuery query, CancellationToken cancellationToken = default)
{
return ExecuteAsync<IReadOnlyCollection<PlatformBillingInvoiceReminder>>("list SaaS invoice reminders",
async (services, token) =>
{
var db = services.GetRequiredService<IPlatformBillingPersistence>();
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<PlatformBillingInvoiceReminderStatus>(query.Status));
return await values.OrderByDescending(value => value.ReminderDate)
.ThenByDescending(value => value.CreatedAt).Take(Limit(query.Limit)).ToArrayAsync(token);
}, cancellationToken);
}
public Task<IReadOnlyCollection<TenantSaasSubscription>> GetSubscriptionsAsync(SaasCatalogActor actor,
PlatformBillingAdminQuery query, CancellationToken cancellationToken = default)
{
return ExecuteAsync<IReadOnlyCollection<TenantSaasSubscription>>("list SaaS subscriptions",
async (services, token) =>
{
var db = services.GetRequiredService<IPlatformBillingPersistence>();
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)
{
return ExecuteAsync("confirm manual SaaS payment", async (services, token) =>
{
var db = services.GetRequiredService<IPlatformBillingPersistence>();
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)
{
return ExecuteAsync("upsert tenant feature override", async (services, token) =>
{
var db = services.GetRequiredService<IPlatformBillingPersistence>();
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<ITenantFeatureCacheInvalidator>()
.InvalidateAsync(command.TenantId, token);
return item;
}, cancellationToken);
}
public Task<TenantSaasSubscription> GrantTrialAsync(
SaasCatalogActor actor,
GrantTenantTrialCommand command,
CancellationToken cancellationToken = default)
{
return ExecuteAsync("grant tenant SaaS trial", async (services, token) =>
{
var db = services.GetRequiredService<IPlatformBillingPersistence>();
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<ITenantFeatureCacheInvalidator>().InvalidateAsync(tenant.Id, token);
return subscription;
}, cancellationToken);
}
public Task<TenantSaasSubscription> SuspendSubscriptionAsync(SaasCatalogActor actor,
ChangePlatformSubscriptionCommand command, CancellationToken cancellationToken = default)
{
return ChangeSubscriptionStatusAsync(actor, command, "suspended", cancellationToken);
}
public Task<TenantSaasSubscription> ResumeSubscriptionAsync(SaasCatalogActor actor,
ChangePlatformSubscriptionCommand command, CancellationToken cancellationToken = default)
{
return ChangeSubscriptionStatusAsync(actor, command, "resumed", cancellationToken);
}
public Task<TenantSaasSubscription> CancelSubscriptionAsync(SaasCatalogActor actor,
ChangePlatformSubscriptionCommand command, CancellationToken cancellationToken = default)
{
return ChangeSubscriptionStatusAsync(actor, command, "cancelled", cancellationToken);
}
public Task<TenantSaasSubscription> ExtendSubscriptionAsync(SaasCatalogActor actor,
ChangePlatformSubscriptionCommand command, CancellationToken cancellationToken = default)
{
return ChangeSubscriptionStatusAsync(actor, command, "extended", cancellationToken);
}
public Task<PlatformBillingRefund> RequestRefundAsync(
SaasCatalogActor actor,
RequestPlatformRefundCommand command,
CancellationToken cancellationToken = default)
{
return ExecuteAsync("request SaaS refund", async (services, token) =>
{
var db = services.GetRequiredService<IPlatformBillingPersistence>();
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<PlatformBillingRefund> ApproveRefundAsync(SaasCatalogActor actor, ReviewPlatformRefundCommand command,
CancellationToken cancellationToken = default)
{
return ReviewRefundAsync(actor, command, true, cancellationToken);
}
public Task<PlatformBillingRefund> RejectRefundAsync(SaasCatalogActor actor, ReviewPlatformRefundCommand command,
CancellationToken cancellationToken = default)
{
return ReviewRefundAsync(actor, command, false, cancellationToken);
}
public Task<PlatformBillingRefund> RetryRefundAsync(SaasCatalogActor actor, ReviewPlatformRefundCommand command,
CancellationToken cancellationToken = default)
{
return ExecuteAsync("retry SaaS refund", async (services, token) =>
{
var db = services.GetRequiredService<IPlatformBillingPersistence>();
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<CommercialMetrics> GetCommercialMetricsAsync(SaasCatalogActor actor,
CancellationToken cancellationToken = default)
{
return ExecuteAsync<CommercialMetrics>("get SaaS commercial metrics", async (services, token) =>
{
var db = services.GetRequiredService<IPlatformBillingPersistence>();
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<TenantSaasSubscription> ChangeSubscriptionStatusAsync(
SaasCatalogActor actor,
ChangePlatformSubscriptionCommand command,
string action,
CancellationToken cancellationToken)
{
return ExecuteAsync($"{action} SaaS subscription", async (services, token) =>
{
var db = services.GetRequiredService<IPlatformBillingPersistence>();
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<ITenantFeatureCacheInvalidator>()
.InvalidateAsync(subscription.TenantId, token);
return subscription;
}, cancellationToken);
}
private Task<PlatformBillingRefund> 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<IPlatformBillingPersistence>();
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(IPlatformBillingPersistence 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<TResult> ExecuteAsync<TResult>(
string reason,
Func<IServiceProvider, CancellationToken, Task<TResult>> 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<T>(string value) where T : struct, Enum
{
return Enum.TryParse<T>(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);
}
}