feat: complete SaaS commercial delivery workflows

This commit is contained in:
2026-08-01 15:26:21 +08:00
parent 46abf4d62f
commit d58bcd97e9
56 changed files with 27505 additions and 181 deletions

View File

@@ -5,6 +5,7 @@ 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;
@@ -144,6 +145,315 @@ internal sealed class PlatformBillingAdminService(
return item;
}, cancellationToken);
public Task<TenantSaasSubscription> GrantTrialAsync(
SaasCatalogActor actor,
GrantTenantTrialCommand command,
CancellationToken cancellationToken = default) =>
ExecuteAsync("grant tenant SaaS trial", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
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) =>
ChangeSubscriptionStatusAsync(actor, command, "suspended", cancellationToken);
public Task<TenantSaasSubscription> ResumeSubscriptionAsync(SaasCatalogActor actor, ChangePlatformSubscriptionCommand command, CancellationToken cancellationToken = default) =>
ChangeSubscriptionStatusAsync(actor, command, "resumed", cancellationToken);
public Task<TenantSaasSubscription> CancelSubscriptionAsync(SaasCatalogActor actor, ChangePlatformSubscriptionCommand command, CancellationToken cancellationToken = default) =>
ChangeSubscriptionStatusAsync(actor, command, "cancelled", cancellationToken);
public Task<TenantSaasSubscription> ExtendSubscriptionAsync(SaasCatalogActor actor, ChangePlatformSubscriptionCommand command, CancellationToken cancellationToken = default) =>
ChangeSubscriptionStatusAsync(actor, command, "extended", cancellationToken);
public Task<PlatformBillingRefund> RequestRefundAsync(
SaasCatalogActor actor,
RequestPlatformRefundCommand command,
CancellationToken cancellationToken = default) =>
ExecuteAsync("request SaaS refund", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
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) =>
ReviewRefundAsync(actor, command, true, cancellationToken);
public Task<PlatformBillingRefund> RejectRefundAsync(SaasCatalogActor actor, ReviewPlatformRefundCommand command, CancellationToken cancellationToken = default) =>
ReviewRefundAsync(actor, command, false, cancellationToken);
public Task<PlatformBillingRefund> RetryRefundAsync(SaasCatalogActor actor, ReviewPlatformRefundCommand command, CancellationToken cancellationToken = default) =>
ExecuteAsync("retry SaaS refund", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
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) =>
ExecuteAsync<CommercialMetrics>("get SaaS commercial metrics", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
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) =>
ExecuteAsync($"{action} SaaS subscription", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
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) =>
ExecuteAsync(approve ? "approve SaaS refund" : "reject SaaS refund", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
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) =>
string.IsNullOrWhiteSpace(value) ? throw Error($"{field} is required.", "required_field") : value.Trim();
private static string Number(string prefix) => $"{prefix}{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Guid.NewGuid():N}"[..32];
private static string Hash(string value) => Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
private Task<TResult> ExecuteAsync<TResult>(
string reason,
Func<IServiceProvider, CancellationToken, Task<TResult>> operation,