forked from gongxuegit/tiku-backend.net
feat(saas): implement marketplace and tenant onboarding
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.PlatformBilling;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Messaging;
|
||||
|
||||
namespace Tiku.Infrastructure.PlatformBilling;
|
||||
|
||||
internal sealed class PlatformBillingSettlementService(
|
||||
TikuDbContext dbContext,
|
||||
ISecurityEventPublisher securityEventPublisher) : IPlatformBillingSettlementService
|
||||
{
|
||||
public async Task<PlatformBillingPayment> MarkPaidAsync(
|
||||
Guid paymentId,
|
||||
string providerEventId,
|
||||
string eventType,
|
||||
string? providerTradeNo,
|
||||
DateTimeOffset paidAt,
|
||||
JsonElement payload,
|
||||
Guid? actorUserId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payment = await dbContext.PlatformBillingPayments.SingleOrDefaultAsync(value => value.Id == paymentId, cancellationToken)
|
||||
?? throw Error("Platform billing payment was not found.", "platform_billing_payment_not_found");
|
||||
if (await dbContext.PlatformBillingPaymentEvents.AnyAsync(value =>
|
||||
value.TenantId == payment.TenantId &&
|
||||
value.Provider == payment.Provider &&
|
||||
value.ProviderEventId == providerEventId, cancellationToken))
|
||||
{
|
||||
return payment;
|
||||
}
|
||||
|
||||
var order = await dbContext.PlatformBillingOrders.SingleAsync(value =>
|
||||
value.TenantId == payment.TenantId && value.Id == payment.OrderId, cancellationToken);
|
||||
if (payment.AmountCents != order.TotalAmountCents)
|
||||
{
|
||||
throw Error("Payment amount does not match the order.", "platform_billing_payment_amount_mismatch");
|
||||
}
|
||||
|
||||
dbContext.PlatformBillingPaymentEvents.Add(new PlatformBillingPaymentEvent
|
||||
{
|
||||
TenantId = payment.TenantId,
|
||||
PaymentId = payment.Id,
|
||||
Provider = payment.Provider,
|
||||
ProviderEventId = providerEventId,
|
||||
EventType = eventType,
|
||||
Payload = payload.ValueKind == JsonValueKind.Undefined ? JsonDefaults.Object() : payload.Clone()
|
||||
});
|
||||
if (payment.Status == PlatformBillingPaymentStatus.Succeeded)
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return payment;
|
||||
}
|
||||
if (payment.Status != PlatformBillingPaymentStatus.Pending || order.Status != PlatformBillingOrderStatus.PendingPayment)
|
||||
{
|
||||
throw Error("Payment or order status does not allow settlement.", "platform_billing_payment_status_invalid");
|
||||
}
|
||||
|
||||
payment.Status = PlatformBillingPaymentStatus.Succeeded;
|
||||
payment.ProviderTradeNo = providerTradeNo;
|
||||
payment.PaidAt = paidAt;
|
||||
order.Status = PlatformBillingOrderStatus.Paid;
|
||||
order.PaidAt = paidAt;
|
||||
var tenant = await dbContext.Tenants.SingleAsync(value => value.Id == order.TenantId, cancellationToken);
|
||||
tenant.BillingStatus = BillingStatus.Active;
|
||||
|
||||
var orderItems = await dbContext.PlatformBillingOrderItems.AsNoTracking()
|
||||
.Where(value => value.TenantId == order.TenantId && value.OrderId == order.Id)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var baseItem = orderItems.SingleOrDefault(value => value.ItemType == PlatformBillingItemType.BasePlan)
|
||||
?? throw Error("Order base plan item is missing.", "platform_billing_base_plan_missing");
|
||||
var baseVersion = await dbContext.SaasOfferingVersions.AsNoTracking()
|
||||
.SingleAsync(value => value.Id == baseItem.OfferingVersionId, cancellationToken);
|
||||
var subscription = await dbContext.TenantSaasSubscriptions
|
||||
.OrderByDescending(value => value.UpdatedAt)
|
||||
.FirstOrDefaultAsync(value => value.TenantId == order.TenantId, cancellationToken);
|
||||
var oldFeatures = subscription is null
|
||||
? []
|
||||
: await LoadSubscriptionFeaturesAsync(subscription, cancellationToken);
|
||||
|
||||
var now = paidAt;
|
||||
if (subscription is null)
|
||||
{
|
||||
subscription = new TenantSaasSubscription
|
||||
{
|
||||
TenantId = order.TenantId,
|
||||
BaseOfferingVersionId = baseVersion.Id,
|
||||
Status = TenantSaasSubscriptionStatus.Active,
|
||||
StartsAt = now,
|
||||
CurrentPeriodStart = now,
|
||||
CurrentPeriodEnd = AddCycle(now, baseVersion.BillingCycle)
|
||||
};
|
||||
dbContext.TenantSaasSubscriptions.Add(subscription);
|
||||
}
|
||||
else if (order.Purpose == PlatformBillingOrderPurpose.Renewal)
|
||||
{
|
||||
var periodStart = subscription.CurrentPeriodEnd > now ? subscription.CurrentPeriodEnd : now;
|
||||
subscription.Status = TenantSaasSubscriptionStatus.Active;
|
||||
subscription.CurrentPeriodStart = subscription.CurrentPeriodEnd > now
|
||||
? subscription.CurrentPeriodStart
|
||||
: now;
|
||||
subscription.CurrentPeriodEnd = AddCycle(periodStart, baseVersion.BillingCycle);
|
||||
subscription.CancelAtPeriodEnd = false;
|
||||
subscription.CancelledAt = null;
|
||||
}
|
||||
else if (order.Purpose == PlatformBillingOrderPurpose.Downgrade)
|
||||
{
|
||||
subscription.ScheduledBaseOfferingVersionId = baseVersion.Id;
|
||||
}
|
||||
else
|
||||
{
|
||||
subscription.BaseOfferingVersionId = baseVersion.Id;
|
||||
subscription.ScheduledBaseOfferingVersionId = null;
|
||||
subscription.Status = TenantSaasSubscriptionStatus.Active;
|
||||
subscription.CancelAtPeriodEnd = false;
|
||||
subscription.CancelledAt = null;
|
||||
if (subscription.CurrentPeriodEnd <= now)
|
||||
{
|
||||
subscription.CurrentPeriodStart = now;
|
||||
subscription.CurrentPeriodEnd = AddCycle(now, baseVersion.BillingCycle);
|
||||
}
|
||||
}
|
||||
|
||||
if (order.Purpose != PlatformBillingOrderPurpose.Downgrade)
|
||||
{
|
||||
var existingItems = await dbContext.TenantSaasSubscriptionItems
|
||||
.Where(value => value.TenantId == order.TenantId && value.SubscriptionId == subscription.Id &&
|
||||
value.Status == TenantSaasSubscriptionItemStatus.Active)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
foreach (var existing in existingItems)
|
||||
{
|
||||
existing.Status = TenantSaasSubscriptionItemStatus.Cancelled;
|
||||
existing.EndsAt = now > existing.StartsAt ? now : existing.StartsAt.AddTicks(1);
|
||||
}
|
||||
dbContext.TenantSaasSubscriptionItems.AddRange(orderItems.Select(item => new TenantSaasSubscriptionItem
|
||||
{
|
||||
TenantId = order.TenantId,
|
||||
SubscriptionId = subscription.Id,
|
||||
OfferingVersionId = item.OfferingVersionId,
|
||||
SourceOrderItemId = item.Id,
|
||||
ItemType = item.ItemType == PlatformBillingItemType.BasePlan
|
||||
? TenantSaasSubscriptionItemType.BasePlan
|
||||
: TenantSaasSubscriptionItemType.AddOn,
|
||||
Status = TenantSaasSubscriptionItemStatus.Active,
|
||||
StartsAt = now,
|
||||
EndsAt = subscription.CurrentPeriodEnd
|
||||
}));
|
||||
}
|
||||
else
|
||||
{
|
||||
var existingScheduledItems = await dbContext.TenantSaasSubscriptionItems
|
||||
.Where(value => value.TenantId == order.TenantId &&
|
||||
value.SubscriptionId == subscription.Id &&
|
||||
value.Status == TenantSaasSubscriptionItemStatus.Scheduled)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
foreach (var existing in existingScheduledItems)
|
||||
{
|
||||
existing.Status = TenantSaasSubscriptionItemStatus.Cancelled;
|
||||
existing.EndsAt = now > existing.StartsAt ? now : existing.StartsAt.AddTicks(1);
|
||||
}
|
||||
dbContext.TenantSaasSubscriptionItems.AddRange(orderItems.Select(item => new TenantSaasSubscriptionItem
|
||||
{
|
||||
TenantId = order.TenantId,
|
||||
SubscriptionId = subscription.Id,
|
||||
OfferingVersionId = item.OfferingVersionId,
|
||||
SourceOrderItemId = item.Id,
|
||||
ItemType = item.ItemType == PlatformBillingItemType.BasePlan
|
||||
? TenantSaasSubscriptionItemType.BasePlan
|
||||
: TenantSaasSubscriptionItemType.AddOn,
|
||||
Status = TenantSaasSubscriptionItemStatus.Scheduled,
|
||||
StartsAt = subscription.CurrentPeriodEnd,
|
||||
EndsAt = AddCycle(subscription.CurrentPeriodEnd, baseVersion.BillingCycle)
|
||||
}));
|
||||
}
|
||||
|
||||
var invoice = new PlatformBillingInvoice
|
||||
{
|
||||
TenantId = order.TenantId,
|
||||
OrderId = order.Id,
|
||||
InvoiceNo = $"SI{now:yyyyMMddHHmmss}{Random.Shared.Next(1000, 9999)}",
|
||||
Status = PlatformBillingInvoiceStatus.Paid,
|
||||
TotalAmountCents = order.TotalAmountCents,
|
||||
Currency = order.Currency,
|
||||
IssuedAt = now,
|
||||
PaidAt = now,
|
||||
BillingProfileSnapshot = await LoadBillingProfileSnapshotAsync(order.TenantId, cancellationToken)
|
||||
};
|
||||
dbContext.PlatformBillingInvoices.Add(invoice);
|
||||
dbContext.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
TenantId = order.TenantId,
|
||||
ActorUserId = actorUserId,
|
||||
Action = "platform_billing.payment.settled",
|
||||
TargetType = "platform_billing_orders",
|
||||
TargetId = order.Id.ToString(),
|
||||
Details = JsonSerializer.SerializeToElement(new { order.OrderNo, payment.PaymentNo, payment.Provider, order.Purpose })
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var newFeatures = await LoadSubscriptionFeaturesAsync(subscription, cancellationToken);
|
||||
var changedFeatures = oldFeatures.Concat(newFeatures).Distinct(StringComparer.Ordinal).ToArray();
|
||||
var version = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
foreach (var featureCode in changedFeatures)
|
||||
{
|
||||
await securityEventPublisher.CapabilityChangedAsync(
|
||||
order.TenantId,
|
||||
featureCode,
|
||||
"saas_subscription_changed",
|
||||
version,
|
||||
$"platform-billing-order-{order.Id:N}",
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
return payment;
|
||||
}
|
||||
|
||||
private async Task<string[]> LoadSubscriptionFeaturesAsync(TenantSaasSubscription subscription, CancellationToken cancellationToken)
|
||||
{
|
||||
var versionIds = await dbContext.TenantSaasSubscriptionItems.AsNoTracking()
|
||||
.Where(value => value.TenantId == subscription.TenantId && value.SubscriptionId == subscription.Id &&
|
||||
value.Status == TenantSaasSubscriptionItemStatus.Active)
|
||||
.Select(value => value.OfferingVersionId)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
if (!versionIds.Contains(subscription.BaseOfferingVersionId))
|
||||
{
|
||||
versionIds = [.. versionIds, subscription.BaseOfferingVersionId];
|
||||
}
|
||||
return await dbContext.SaasOfferingVersionFeatures.AsNoTracking()
|
||||
.Where(value => versionIds.Contains(value.OfferingVersionId))
|
||||
.Select(value => value.FeatureCode)
|
||||
.Distinct()
|
||||
.ToArrayAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<JsonElement> LoadBillingProfileSnapshotAsync(Guid tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
var profile = await dbContext.TenantBillingProfiles.AsNoTracking()
|
||||
.SingleOrDefaultAsync(value => value.TenantId == tenantId, cancellationToken);
|
||||
return profile is null
|
||||
? JsonDefaults.Object()
|
||||
: JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
profile.BillingName,
|
||||
profile.TaxId,
|
||||
profile.ContactName,
|
||||
profile.ContactPhone,
|
||||
profile.ContactEmail,
|
||||
profile.InvoiceTitle,
|
||||
profile.InvoiceType
|
||||
});
|
||||
}
|
||||
|
||||
private static DateTimeOffset AddCycle(DateTimeOffset start, PlatformBillingCycle cycle) => cycle switch
|
||||
{
|
||||
PlatformBillingCycle.Monthly => start.AddMonths(1),
|
||||
PlatformBillingCycle.Quarterly => start.AddMonths(3),
|
||||
PlatformBillingCycle.Yearly => start.AddYears(1),
|
||||
PlatformBillingCycle.OneTime => start.AddYears(100),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(cycle))
|
||||
};
|
||||
|
||||
private static PlatformBillingException Error(string message, string code) => new(message, code);
|
||||
}
|
||||
Reference in New Issue
Block a user