609 lines
32 KiB
C#
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);
|
|
}
|
|
} |