feat: complete SaaS commercial delivery workflows
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user