feat(saas): implement marketplace and tenant onboarding

This commit is contained in:
2026-07-29 13:58:59 +08:00
parent 76606029e2
commit 6db200a2fc
145 changed files with 16862 additions and 94529 deletions

View File

@@ -0,0 +1,147 @@
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.Infrastructure.Persistence;
using Tiku.Infrastructure.Messaging;
namespace Tiku.Infrastructure.PlatformBilling;
internal sealed class PlatformBillingAdminService(
ITenantExecutionScope tenantExecutionScope) : IPlatformBillingAdminService
{
public Task<IReadOnlyCollection<PlatformBillingOrder>> GetOrdersAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default) =>
ExecuteAsync<IReadOnlyCollection<PlatformBillingOrder>>("list SaaS orders", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
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) =>
ExecuteAsync<IReadOnlyCollection<PlatformBillingPayment>>("list SaaS payments", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
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) =>
ExecuteAsync<IReadOnlyCollection<PlatformBillingRefund>>("list SaaS refunds", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
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) =>
ExecuteAsync<IReadOnlyCollection<PlatformBillingInvoice>>("list SaaS invoices", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
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<TenantSaasSubscription>> GetSubscriptionsAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default) =>
ExecuteAsync<IReadOnlyCollection<TenantSaasSubscription>>("list SaaS subscriptions", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
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) =>
ExecuteAsync("confirm manual SaaS payment", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
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) =>
ExecuteAsync("upsert tenant feature override", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
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<ISecurityEventPublisher>().CapabilityChangedAsync(
command.TenantId,
featureCode,
"tenant_feature_override_changed",
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
$"tenant-feature-override-{item.Id:N}",
token);
return item;
}, cancellationToken);
private Task<TResult> ExecuteAsync<TResult>(
string reason,
Func<IServiceProvider, CancellationToken, Task<TResult>> operation,
CancellationToken cancellationToken) =>
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 =>
Enum.TryParse<T>(value, true, out var parsed) ? parsed : throw Error("Status is invalid.", "status_invalid");
private static int Limit(int value) => Math.Clamp(value, 1, 200);
private static PlatformBillingException Error(string message, string code) => new(message, code);
}

View File

@@ -0,0 +1,56 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Application.Commerce;
using Tiku.Application.PlatformBilling;
using Tiku.Application.Security;
using Tiku.Domain.Platform;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.PlatformBilling;
internal sealed class PlatformBillingNotificationService(
ITenantExecutionScope tenantExecutionScope,
IPlatformBillingPaymentGateway paymentGateway) : IPlatformBillingNotificationService
{
public async Task ProcessAsync(PlatformBillingNotification notification, CancellationToken cancellationToken = default)
{
var parsed = await paymentGateway.ParseNotificationAsync(
notification.Provider,
new PaymentNotificationRequest(Guid.Empty, notification.Provider, notification.Headers, notification.RawBody, notification.Body),
cancellationToken);
if (!parsed.SignatureValid || !parsed.Paid)
{
throw Error("Payment notification was not a valid paid event.", "platform_billing_notification_invalid");
}
await tenantExecutionScope.ExecuteAsync(
new SystemScopeRequest(null, SystemScopeCallerType.Platform, nameof(PlatformBillingNotificationService), "Settle platform billing notification", parsed.EventId, true),
async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var order = await db.PlatformBillingOrders.AsNoTracking().SingleOrDefaultAsync(value => value.OrderNo == parsed.OrderNo, token)
?? throw Error("Platform billing order was not found.", "platform_billing_order_not_found");
if (order.TotalAmountCents != parsed.AmountCents)
{
throw Error("Payment notification amount does not match the order.", "platform_billing_payment_amount_mismatch");
}
var payment = await db.PlatformBillingPayments.AsNoTracking()
.Where(value => value.TenantId == order.TenantId && value.OrderId == order.Id && value.Provider == parsed.Provider)
.OrderByDescending(value => value.CreatedAt)
.FirstOrDefaultAsync(token)
?? throw Error("Platform billing payment was not found.", "platform_billing_payment_not_found");
await services.GetRequiredService<IPlatformBillingSettlementService>().MarkPaidAsync(
payment.Id,
parsed.EventId,
parsed.EventType,
parsed.ProviderTradeNo,
parsed.PaidAt ?? DateTimeOffset.UtcNow,
parsed.RawPayload,
null,
token);
},
cancellationToken);
}
private static PlatformBillingException Error(string message, string code) => new(message, code);
}

View File

@@ -0,0 +1,91 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Application.Commerce;
using Tiku.Application.PlatformBilling;
using Tiku.Application.Security;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.PlatformBilling;
internal sealed class PlatformBillingPaymentGateway(
ITenantExecutionScope tenantExecutionScope) : IPlatformBillingPaymentGateway
{
public Task<CreatePaymentProviderResult> CreatePaymentAsync(
string provider,
CreatePaymentProviderRequest request,
CancellationToken cancellationToken = default) =>
ExecuteAsync(provider, request.OrderNo, async (resolvedProvider, account, token) =>
await resolvedProvider.CreatePaymentAsync(account, request, token), cancellationToken);
public Task<PaymentNotificationResult> ParseNotificationAsync(
string provider,
PaymentNotificationRequest request,
CancellationToken cancellationToken = default) =>
ExecuteAsync(provider, "payment-notification", async (resolvedProvider, account, token) =>
await resolvedProvider.ParsePaymentNotificationAsync(account, request, token), cancellationToken);
private Task<TResult> ExecuteAsync<TResult>(
string provider,
string correlationId,
Func<IPaymentProvider, PaymentProviderAccount, CancellationToken, Task<TResult>> operation,
CancellationToken cancellationToken)
{
return tenantExecutionScope.ExecuteAsync(
new SystemScopeRequest(
null,
SystemScopeCallerType.Platform,
nameof(PlatformBillingPaymentGateway),
"Resolve the platform-owned SaaS billing payment account",
correlationId,
IsGlobal: true),
async (services, token) =>
{
var dbContext = services.GetRequiredService<TikuDbContext>();
var platformTenantId = await dbContext.Tenants.AsNoTracking()
.Where(value => value.Mode == TenantMode.PlatformOwned)
.Select(value => value.Id)
.SingleOrDefaultAsync(token);
if (platformTenantId == Guid.Empty)
{
throw new PaymentProviderException("Platform-owned tenant is missing.", "platform_payment_owner_missing");
}
var normalized = NormalizeProvider(provider);
var resolvedProvider = services.GetServices<IPaymentProvider>()
.SingleOrDefault(value => value.Provider == normalized)
?? throw new PaymentProviderException("Payment provider is not supported.", "payment_provider_not_supported");
PaymentProviderAccount account;
if (normalized == PaymentProviders.Manual)
{
account = new PaymentProviderAccount(
platformTenantId,
PaymentProviders.Manual,
"PlatformCollect",
JsonDocument.Parse("{}").RootElement.Clone(),
JsonDocument.Parse("{}").RootElement.Clone());
}
else
{
account = await services.GetRequiredService<IPaymentProviderConfigService>()
.GetActiveAccountAsync(platformTenantId, normalized, token);
}
return await operation(resolvedProvider, account, token);
},
cancellationToken);
}
private static string NormalizeProvider(string provider)
{
var normalized = provider.Trim().ToLowerInvariant().Replace("-", "_", StringComparison.Ordinal);
return normalized switch
{
"wechat" or "wechatpay" or "wxpay" or "wx_pay" => PaymentProviders.WechatPay,
"ali_pay" => PaymentProviders.Alipay,
PaymentProviders.WechatPay or PaymentProviders.Alipay or PaymentProviders.Manual => normalized,
_ => throw new PaymentProviderException("Payment provider is not supported.", "payment_provider_not_supported")
};
}
}

View File

@@ -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);
}

View File

@@ -0,0 +1,350 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Backoffice;
using Tiku.Application.PlatformBilling;
using Tiku.Application.Security;
using Tiku.Domain.Platform;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.PlatformBilling;
internal sealed class SaasCatalogAdminService(
TikuDbContext dbContext,
IOperationAuditService auditService) : ISaasCatalogAdminService
{
public async Task<SaasCatalogSnapshot> GetCatalogAsync(
SaasCatalogActor actor,
CancellationToken cancellationToken = default)
{
var features = await dbContext.SaasFeatures.AsNoTracking()
.OrderBy(value => value.SortOrder).ThenBy(value => value.Code)
.ToArrayAsync(cancellationToken);
var limits = await dbContext.SaasFeatureLimitDefinitions.AsNoTracking()
.OrderBy(value => value.FeatureCode).ThenBy(value => value.MetricCode)
.ToArrayAsync(cancellationToken);
var offerings = await dbContext.SaasOfferings.AsNoTracking()
.OrderBy(value => value.SortOrder).ThenBy(value => value.Code)
.ToArrayAsync(cancellationToken);
var versions = await LoadVersionsAsync(null, cancellationToken);
return new SaasCatalogSnapshot(features, limits, offerings, versions);
}
public async Task<SaasFeatureLimitDefinition> UpsertLimitDefinitionAsync(
SaasCatalogActor actor,
UpsertSaasFeatureLimitCommand command,
CancellationToken cancellationToken = default)
{
var metricCode = Normalize(command.MetricCode);
var featureCode = Normalize(command.FeatureCode);
if (command.WarningPercent != 80 || !command.IsHardLimit)
{
throw Error("Phase nine supports hard limits with an 80 percent warning threshold.", "saas_feature_limit_policy_invalid");
}
if (!await dbContext.SaasFeatures.AnyAsync(value =>
value.Code == featureCode && value.Status == SaasFeatureStatus.Active, cancellationToken))
{
throw Error("SaaS feature was not found or active.", "saas_feature_not_found");
}
var item = command.Id.HasValue
? await dbContext.SaasFeatureLimitDefinitions.SingleOrDefaultAsync(value => value.Id == command.Id, cancellationToken)
: await dbContext.SaasFeatureLimitDefinitions.SingleOrDefaultAsync(value => value.MetricCode == metricCode, cancellationToken);
if (item is null)
{
item = new SaasFeatureLimitDefinition { MetricCode = metricCode };
dbContext.SaasFeatureLimitDefinitions.Add(item);
}
else if (await dbContext.SaasOfferingVersionLimits.AnyAsync(value => value.MetricCode == item.MetricCode, cancellationToken) &&
(!string.Equals(item.MetricCode, metricCode, StringComparison.Ordinal) ||
!string.Equals(item.FeatureCode, featureCode, StringComparison.Ordinal) ||
item.Kind != command.Kind))
{
throw Error("A limit definition referenced by offering versions cannot change identity or kind.", "saas_feature_limit_locked");
}
item.MetricCode = metricCode;
item.FeatureCode = featureCode;
item.Name = Required(command.Name, "name");
item.Unit = Required(command.Unit, "unit");
item.Kind = command.Kind;
item.WarningPercent = command.WarningPercent;
item.IsHardLimit = command.IsHardLimit;
await dbContext.SaveChangesAsync(cancellationToken);
await AuditAsync(actor, "platform.saas.feature_limit.upserted", "saas_feature_limit_definitions", item.Id,
new { item.MetricCode, item.FeatureCode, item.Kind }, cancellationToken);
return item;
}
public async Task<SaasFeature> UpsertFeatureAsync(
SaasCatalogActor actor,
UpsertSaasFeatureCommand command,
CancellationToken cancellationToken = default)
{
var code = Normalize(command.Code);
if (!SaasFeatureCatalog.All.Contains(code))
{
throw Error("Feature code is not part of the application catalog.", "saas_feature_code_unknown");
}
if (command.ReferencePriceCents < 0)
{
throw Error("Reference price cannot be negative.", "saas_feature_price_invalid");
}
var item = command.Id.HasValue
? await dbContext.SaasFeatures.SingleOrDefaultAsync(value => value.Id == command.Id, cancellationToken)
: await dbContext.SaasFeatures.SingleOrDefaultAsync(value => value.Code == code, cancellationToken);
if (item is null)
{
item = new SaasFeature { Code = code };
dbContext.SaasFeatures.Add(item);
}
if (item.IsCore)
{
throw Error("Core features cannot be changed through the product catalog.", "saas_core_feature_locked");
}
item.Code = code;
item.Name = Required(command.Name, "name");
item.Category = Required(command.Category, "category");
item.Description = Clean(command.Description);
item.ReferencePriceCents = command.ReferencePriceCents;
item.Currency = NormalizeCurrency(command.Currency);
item.Status = command.Status;
item.SortOrder = command.SortOrder;
await dbContext.SaveChangesAsync(cancellationToken);
await AuditAsync(actor, "platform.saas.feature.upserted", "saas_features", item.Id, new { item.Code, item.Status }, cancellationToken);
return item;
}
public async Task<SaasOffering> UpsertOfferingAsync(
SaasCatalogActor actor,
UpsertSaasOfferingCommand command,
CancellationToken cancellationToken = default)
{
var code = Normalize(command.Code);
var item = command.Id.HasValue
? await dbContext.SaasOfferings.SingleOrDefaultAsync(value => value.Id == command.Id, cancellationToken)
: await dbContext.SaasOfferings.SingleOrDefaultAsync(value => value.Code == code, cancellationToken);
if (item is null)
{
item = new SaasOffering { Code = code };
dbContext.SaasOfferings.Add(item);
}
else if (item.Type != command.Type && await dbContext.SaasOfferingVersions.AnyAsync(value => value.OfferingId == item.Id, cancellationToken))
{
throw Error("Offering type cannot change after versions exist.", "saas_offering_type_locked");
}
item.Code = code;
item.Name = Required(command.Name, "name");
item.Type = command.Type;
item.Status = command.Status;
item.Description = Clean(command.Description);
item.SortOrder = command.SortOrder;
await dbContext.SaveChangesAsync(cancellationToken);
await AuditAsync(actor, "platform.saas.offering.upserted", "saas_offerings", item.Id, new { item.Code, item.Type, item.Status }, cancellationToken);
return item;
}
public async Task<SaasOfferingVersionItem> UpsertDraftVersionAsync(
SaasCatalogActor actor,
UpsertSaasOfferingVersionCommand command,
CancellationToken cancellationToken = default)
{
var offering = await dbContext.SaasOfferings.SingleOrDefaultAsync(value => value.Id == command.OfferingId, cancellationToken)
?? throw Error("SaaS offering was not found.", "saas_offering_not_found");
if (command.AmountCents < 0 || command.OriginalAmountCents < command.AmountCents)
{
throw Error("Offering version price is invalid.", "saas_offering_price_invalid");
}
var featureCodes = command.FeatureCodes.Select(Normalize).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray();
if (offering.Type == SaasOfferingType.BasePlan && featureCodes.Length == 0)
{
throw Error("A base plan must contain at least one sellable feature.", "saas_base_plan_features_required");
}
var validFeatures = await dbContext.SaasFeatures.CountAsync(value =>
featureCodes.Contains(value.Code) && value.Status == SaasFeatureStatus.Active && !value.IsCore, cancellationToken);
if (validFeatures != featureCodes.Length)
{
throw Error("One or more features are unavailable.", "saas_feature_unavailable");
}
var metricCodes = command.Limits.Keys.Select(Normalize).Distinct(StringComparer.Ordinal).ToArray();
var limitDefinitions = await dbContext.SaasFeatureLimitDefinitions.AsNoTracking()
.Where(value => metricCodes.Contains(value.MetricCode))
.ToArrayAsync(cancellationToken);
if (command.Limits.Any(value => value.Value < 0) ||
limitDefinitions.Length != metricCodes.Length ||
limitDefinitions.Any(value => !featureCodes.Contains(value.FeatureCode)))
{
throw Error("One or more feature limits are invalid.", "saas_feature_limit_invalid");
}
SaasOfferingVersion version;
if (command.Id.HasValue)
{
version = await dbContext.SaasOfferingVersions.SingleOrDefaultAsync(value => value.Id == command.Id, cancellationToken)
?? throw Error("Offering version was not found.", "saas_offering_version_not_found");
if (version.Status != SaasOfferingVersionStatus.Draft)
{
throw Error("Published offering versions are immutable.", "saas_offering_version_immutable");
}
if (version.OfferingId != command.OfferingId)
{
throw Error("Offering version cannot move to another offering.", "saas_offering_version_offering_locked");
}
}
else
{
var nextVersion = (await dbContext.SaasOfferingVersions
.Where(value => value.OfferingId == command.OfferingId)
.MaxAsync(value => (int?)value.Version, cancellationToken) ?? 0) + 1;
version = new SaasOfferingVersion { OfferingId = command.OfferingId, Version = nextVersion };
dbContext.SaasOfferingVersions.Add(version);
}
version.BillingCycle = command.BillingCycle;
version.OriginalAmountCents = command.OriginalAmountCents;
version.AmountCents = command.AmountCents;
version.Currency = NormalizeCurrency(command.Currency);
version.EffectiveAt = command.EffectiveAt;
version.Metadata = ObjectOrEmpty(command.Metadata);
await dbContext.SaasOfferingVersionFeatures.Where(value => value.OfferingVersionId == version.Id).ExecuteDeleteAsync(cancellationToken);
await dbContext.SaasOfferingVersionLimits.Where(value => value.OfferingVersionId == version.Id).ExecuteDeleteAsync(cancellationToken);
dbContext.SaasOfferingVersionFeatures.AddRange(featureCodes.Select(code => new SaasOfferingVersionFeature
{
OfferingVersionId = version.Id,
FeatureCode = code
}));
dbContext.SaasOfferingVersionLimits.AddRange(command.Limits.Select(value => new SaasOfferingVersionLimit
{
OfferingVersionId = version.Id,
MetricCode = Normalize(value.Key),
LimitValue = value.Value
}));
await dbContext.SaveChangesAsync(cancellationToken);
await AuditAsync(actor, "platform.saas.offering_version.saved", "saas_offering_versions", version.Id, new { offering.Code, version.Version }, cancellationToken);
return (await LoadVersionsAsync(version.Id, cancellationToken)).Single();
}
public async Task<SaasOfferingVersionItem> PublishVersionAsync(
SaasCatalogActor actor,
Guid versionId,
CancellationToken cancellationToken = default)
{
var version = await RequireDraftAsync(versionId, cancellationToken);
var hasFeatures = await dbContext.SaasOfferingVersionFeatures.AnyAsync(value => value.OfferingVersionId == versionId, cancellationToken);
if (!hasFeatures)
{
throw Error("Offering version has no features.", "saas_offering_version_empty");
}
version.Status = SaasOfferingVersionStatus.Published;
version.PublishedAt = DateTimeOffset.UtcNow;
version.EffectiveAt ??= version.PublishedAt;
var offering = await dbContext.SaasOfferings.SingleAsync(value => value.Id == version.OfferingId, cancellationToken);
offering.Status = SaasOfferingStatus.Active;
await dbContext.SaveChangesAsync(cancellationToken);
await AuditAsync(actor, "platform.saas.offering_version.published", "saas_offering_versions", version.Id, new { version.Version }, cancellationToken);
return (await LoadVersionsAsync(version.Id, cancellationToken)).Single();
}
public async Task<SaasOfferingVersionItem> CloneVersionAsync(
SaasCatalogActor actor,
Guid versionId,
CancellationToken cancellationToken = default)
{
var source = await dbContext.SaasOfferingVersions.AsNoTracking().SingleOrDefaultAsync(value => value.Id == versionId, cancellationToken)
?? throw Error("Offering version was not found.", "saas_offering_version_not_found");
var features = await dbContext.SaasOfferingVersionFeatures.AsNoTracking().Where(value => value.OfferingVersionId == versionId).Select(value => value.FeatureCode).ToArrayAsync(cancellationToken);
var limits = await dbContext.SaasOfferingVersionLimits.AsNoTracking().Where(value => value.OfferingVersionId == versionId).ToDictionaryAsync(value => value.MetricCode, value => value.LimitValue, cancellationToken);
return await UpsertDraftVersionAsync(actor, new UpsertSaasOfferingVersionCommand(
null,
source.OfferingId,
source.BillingCycle,
source.OriginalAmountCents,
source.AmountCents,
source.Currency,
null,
features,
limits,
source.Metadata), cancellationToken);
}
public async Task<SaasOfferingVersionItem> RetireVersionAsync(
SaasCatalogActor actor,
Guid versionId,
CancellationToken cancellationToken = default)
{
var version = await dbContext.SaasOfferingVersions.SingleOrDefaultAsync(value => value.Id == versionId, cancellationToken)
?? throw Error("Offering version was not found.", "saas_offering_version_not_found");
if (version.Status != SaasOfferingVersionStatus.Published)
{
throw Error("Only a published offering version can be retired.", "saas_offering_version_status_invalid");
}
version.Status = SaasOfferingVersionStatus.Retired;
version.RetiredAt = DateTimeOffset.UtcNow;
await dbContext.SaveChangesAsync(cancellationToken);
await AuditAsync(actor, "platform.saas.offering_version.retired", "saas_offering_versions", version.Id, new { version.Version }, cancellationToken);
return (await LoadVersionsAsync(version.Id, cancellationToken)).Single();
}
private async Task<SaasOfferingVersionItem[]> LoadVersionsAsync(Guid? versionId, CancellationToken cancellationToken)
{
var query = from version in dbContext.SaasOfferingVersions.AsNoTracking()
join offering in dbContext.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id
select new { Version = version, Offering = offering };
if (versionId.HasValue)
{
query = query.Where(value => value.Version.Id == versionId);
}
var rows = await query.OrderBy(value => value.Offering.SortOrder).ThenBy(value => value.Offering.Code).ThenByDescending(value => value.Version.Version).ToArrayAsync(cancellationToken);
var ids = rows.Select(value => value.Version.Id).ToArray();
var features = await dbContext.SaasOfferingVersionFeatures.AsNoTracking().Where(value => ids.Contains(value.OfferingVersionId)).ToArrayAsync(cancellationToken);
var limits = await dbContext.SaasOfferingVersionLimits.AsNoTracking().Where(value => ids.Contains(value.OfferingVersionId)).ToArrayAsync(cancellationToken);
return rows.Select(row => new SaasOfferingVersionItem(
row.Version.Id,
row.Offering.Id,
row.Offering.Code,
row.Offering.Name,
row.Offering.Type,
row.Version.Version,
row.Version.Status,
row.Version.BillingCycle,
row.Version.OriginalAmountCents,
row.Version.AmountCents,
row.Version.Currency,
row.Version.EffectiveAt,
row.Version.PublishedAt,
features.Where(value => value.OfferingVersionId == row.Version.Id).Select(value => value.FeatureCode).Order(StringComparer.Ordinal).ToArray(),
limits.Where(value => value.OfferingVersionId == row.Version.Id).ToDictionary(value => value.MetricCode, value => value.LimitValue, StringComparer.Ordinal)))
.ToArray();
}
private async Task<SaasOfferingVersion> RequireDraftAsync(Guid versionId, CancellationToken cancellationToken)
{
var version = await dbContext.SaasOfferingVersions.SingleOrDefaultAsync(value => value.Id == versionId, cancellationToken)
?? throw Error("Offering version was not found.", "saas_offering_version_not_found");
return version.Status == SaasOfferingVersionStatus.Draft
? version
: throw Error("Published offering versions are immutable.", "saas_offering_version_immutable");
}
private Task AuditAsync(SaasCatalogActor actor, string action, string targetType, Guid targetId, object details, CancellationToken cancellationToken) =>
auditService.WriteAsync(new BackofficeOperationAuditCommand(
null,
actor.UserId,
action,
targetType,
targetId.ToString(),
JsonSerializer.SerializeToElement(details)), cancellationToken);
private static PlatformBillingException Error(string message, string code) => new(message, code);
private static string Normalize(string value) => Required(value, "code").ToLowerInvariant();
private static string NormalizeCurrency(string value) => Required(value, "currency").ToUpperInvariant();
private static string Required(string value, string field) => string.IsNullOrWhiteSpace(value) ? throw Error($"{field} is required.", "required_field") : value.Trim();
private static string? Clean(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static JsonElement ObjectOrEmpty(JsonElement value) => value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDocument.Parse("{}").RootElement.Clone();
}

View File

@@ -0,0 +1,272 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Tiku.Application.PlatformBilling;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Domain.Operations;
using Tiku.Domain.Platform;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Messaging;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.PlatformBilling;
internal sealed class SaasSubscriptionLifecycleService(
TikuDbContext directoryDbContext,
ITenantContext tenantContext,
ITenantExecutionScope tenantExecutionScope,
IOptions<SaasSubscriptionLifecycleOptions> options) : ISaasSubscriptionLifecycleService
{
private readonly SaasSubscriptionLifecycleOptions options = options.Value;
public async Task<int> ProcessDueAsync(
DateTimeOffset? asOf = null,
CancellationToken cancellationToken = default)
{
if (!options.Enabled)
{
return 0;
}
if (!tenantContext.IsSystem || tenantContext.TenantId.HasValue)
{
throw new InvalidOperationException(
"SaaS subscription lifecycle discovery requires a global system context.");
}
var effectiveAt = asOf ?? DateTimeOffset.UtcNow;
var tenantIds = await directoryDbContext.TenantSaasSubscriptions.AsNoTracking()
.Where(subscription =>
subscription.CurrentPeriodEnd <= effectiveAt &&
(subscription.Status == TenantSaasSubscriptionStatus.Trial ||
subscription.Status == TenantSaasSubscriptionStatus.Active ||
subscription.Status == TenantSaasSubscriptionStatus.PastDue))
.OrderBy(subscription => subscription.CurrentPeriodEnd)
.ThenBy(subscription => subscription.TenantId)
.Select(subscription => subscription.TenantId)
.Distinct()
.Take(Math.Clamp(options.BatchSize, 1, 1000))
.ToArrayAsync(cancellationToken);
var changed = 0;
foreach (var tenantId in tenantIds)
{
var wasChanged = await tenantExecutionScope.ExecuteAsync(
new SystemScopeRequest(
tenantId,
SystemScopeCallerType.Worker,
nameof(SaasSubscriptionLifecycleService),
"Apply due SaaS subscription lifecycle transition",
$"saas-subscription-lifecycle-{tenantId:N}-{effectiveAt:yyyyMMddHHmmss}"),
(provider, token) => ProcessTenantAsync(provider, tenantId, effectiveAt, token),
cancellationToken);
if (wasChanged)
{
changed++;
}
}
return changed;
}
private async Task<bool> ProcessTenantAsync(
IServiceProvider services,
Guid tenantId,
DateTimeOffset asOf,
CancellationToken cancellationToken)
{
var dbContext = services.GetRequiredService<TikuDbContext>();
var subscription = await dbContext.TenantSaasSubscriptions
.OrderByDescending(value => value.UpdatedAt)
.FirstOrDefaultAsync(value =>
value.TenantId == tenantId &&
value.CurrentPeriodEnd <= asOf &&
(value.Status == TenantSaasSubscriptionStatus.Trial ||
value.Status == TenantSaasSubscriptionStatus.Active ||
value.Status == TenantSaasSubscriptionStatus.PastDue),
cancellationToken);
if (subscription is null)
{
return false;
}
var previousStatus = subscription.Status;
var previousBaseVersionId = subscription.BaseOfferingVersionId;
var oldFeatures = await LoadFeaturesAsync(dbContext, subscription, cancellationToken);
var items = await dbContext.TenantSaasSubscriptionItems
.Where(value => value.TenantId == tenantId && value.SubscriptionId == subscription.Id)
.ToArrayAsync(cancellationToken);
var tenant = await dbContext.Tenants.SingleAsync(value => value.Id == tenantId, cancellationToken);
string transition;
if (subscription.CancelAtPeriodEnd)
{
transition = "cancelled_at_period_end";
subscription.Status = TenantSaasSubscriptionStatus.Cancelled;
subscription.CancelledAt ??= asOf;
subscription.ScheduledBaseOfferingVersionId = null;
tenant.BillingStatus = BillingStatus.Cancelled;
EndItems(items, subscription.CurrentPeriodEnd, cancelScheduled: true);
}
else if (subscription.ScheduledBaseOfferingVersionId is { } scheduledVersionId)
{
var scheduledBase = items.SingleOrDefault(value =>
value.ItemType == TenantSaasSubscriptionItemType.BasePlan &&
value.Status == TenantSaasSubscriptionItemStatus.Scheduled &&
value.OfferingVersionId == scheduledVersionId);
if (scheduledBase is null)
{
var current = await dbContext.TenantSaasSubscriptions.AsNoTracking()
.SingleAsync(value => value.TenantId == tenantId && value.Id == subscription.Id, cancellationToken);
if (current.ScheduledBaseOfferingVersionId is null &&
current.BaseOfferingVersionId == scheduledVersionId &&
current.LifecycleVersion != subscription.LifecycleVersion)
{
dbContext.ChangeTracker.Clear();
return false;
}
throw new InvalidOperationException(
$"Scheduled base offering version '{scheduledVersionId}' has no matching subscription item.");
}
transition = "scheduled_plan_activated";
var nextPeriodStart = subscription.CurrentPeriodEnd;
EndItems(items.Where(value => value.Status == TenantSaasSubscriptionItemStatus.Active), nextPeriodStart, cancelScheduled: false);
var scheduledItems = items.Where(value => value.Status == TenantSaasSubscriptionItemStatus.Scheduled).ToArray();
foreach (var item in scheduledItems)
{
item.Status = TenantSaasSubscriptionItemStatus.Active;
item.StartsAt = nextPeriodStart;
}
subscription.BaseOfferingVersionId = scheduledVersionId;
subscription.ScheduledBaseOfferingVersionId = null;
subscription.Status = TenantSaasSubscriptionStatus.Active;
subscription.CurrentPeriodStart = nextPeriodStart;
subscription.CurrentPeriodEnd = scheduledBase.EndsAt;
subscription.CancelAtPeriodEnd = false;
subscription.CancelledAt = null;
tenant.BillingStatus = BillingStatus.Active;
}
else if (subscription.Status == TenantSaasSubscriptionStatus.Trial)
{
transition = "trial_expired";
subscription.Status = TenantSaasSubscriptionStatus.Expired;
subscription.CancelledAt ??= asOf;
tenant.BillingStatus = BillingStatus.Cancelled;
EndItems(items, subscription.CurrentPeriodEnd, cancelScheduled: true);
}
else if (subscription.Status == TenantSaasSubscriptionStatus.Active)
{
transition = "payment_past_due";
subscription.Status = TenantSaasSubscriptionStatus.PastDue;
tenant.BillingStatus = BillingStatus.PastDue;
}
else if (subscription.Status == TenantSaasSubscriptionStatus.PastDue &&
asOf >= subscription.CurrentPeriodEnd.AddDays(Math.Clamp(options.PastDueGraceDays, 0, 365)))
{
transition = "past_due_expired";
subscription.Status = TenantSaasSubscriptionStatus.Expired;
subscription.CancelledAt ??= asOf;
tenant.BillingStatus = BillingStatus.Cancelled;
EndItems(items, subscription.CurrentPeriodEnd, cancelScheduled: true);
}
else
{
return false;
}
subscription.LifecycleVersion++;
dbContext.AuditLogs.Add(new AuditLog
{
TenantId = tenantId,
Action = $"platform_billing.subscription.{transition}",
TargetType = "tenant_saas_subscriptions",
TargetId = subscription.Id.ToString(),
Details = JsonSerializer.SerializeToElement(new
{
previousStatus,
subscription.Status,
previousBaseVersionId,
subscription.BaseOfferingVersionId,
subscription.CurrentPeriodStart,
subscription.CurrentPeriodEnd,
subscription.LifecycleVersion
})
});
try
{
await dbContext.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateConcurrencyException)
{
dbContext.ChangeTracker.Clear();
return false;
}
var newFeatures = await LoadFeaturesAsync(dbContext, subscription, cancellationToken);
var changedFeatures = oldFeatures.Concat(newFeatures).Distinct(StringComparer.Ordinal).ToArray();
var eventPublisher = services.GetRequiredService<ISecurityEventPublisher>();
var eventVersion = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var correlationId = $"saas-subscription-{subscription.Id:N}-lifecycle-{subscription.LifecycleVersion}";
foreach (var featureCode in changedFeatures)
{
await eventPublisher.CapabilityChangedAsync(
tenantId,
featureCode,
transition,
eventVersion,
correlationId,
cancellationToken);
}
services.GetRequiredService<ITenantRuntimeCacheInvalidator>().Invalidate(tenantId);
return true;
}
private static void EndItems(
IEnumerable<TenantSaasSubscriptionItem> items,
DateTimeOffset periodEnd,
bool cancelScheduled)
{
foreach (var item in items)
{
if (item.Status == TenantSaasSubscriptionItemStatus.Active)
{
item.Status = TenantSaasSubscriptionItemStatus.Expired;
if (periodEnd > item.StartsAt && item.EndsAt > periodEnd)
{
item.EndsAt = periodEnd;
}
}
else if (cancelScheduled && item.Status == TenantSaasSubscriptionItemStatus.Scheduled)
{
item.Status = TenantSaasSubscriptionItemStatus.Cancelled;
}
}
}
private static async Task<string[]> LoadFeaturesAsync(
TikuDbContext dbContext,
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);
}
}

View File

@@ -0,0 +1,505 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Tiku.Application.Commerce;
using Tiku.Application.PlatformBilling;
using Tiku.Application.Security;
using Tiku.Domain.Common;
using Tiku.Domain.Platform;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.PlatformBilling;
internal sealed class TenantBillingService(
TikuDbContext dbContext,
IPlatformBillingPaymentGateway paymentGateway,
IPlatformBillingSettlementService settlementService,
IFeatureAccessService featureAccessService,
IConfiguration configuration) : ITenantBillingService
{
private static readonly TimeSpan QuoteLifetime = TimeSpan.FromMinutes(30);
private static readonly TimeSpan OrderLifetime = TimeSpan.FromHours(2);
public async Task<TenantBillingCatalog> GetCatalogAsync(
TenantBillingActor actor,
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var features = await dbContext.SaasFeatures.AsNoTracking()
.Where(value => value.Status == SaasFeatureStatus.Active && !value.IsCore)
.OrderBy(value => value.SortOrder).ThenBy(value => value.Code)
.ToArrayAsync(cancellationToken);
var versions = await LoadPublishedVersionsAsync(now, cancellationToken);
return new TenantBillingCatalog(
features,
versions.Where(value => value.OfferingType == SaasOfferingType.BasePlan).ToArray(),
versions.Where(value => value.OfferingType == SaasOfferingType.AddOn).ToArray());
}
public async Task<PlatformBillingQuoteView> CreateQuoteAsync(
TenantBillingActor actor,
CreatePlatformBillingQuoteCommand command,
CancellationToken cancellationToken = default)
{
var idempotencyKey = Required(command.IdempotencyKey, "idempotencyKey");
var existingQuote = await dbContext.PlatformBillingQuotes.AsNoTracking()
.SingleOrDefaultAsync(value => value.TenantId == actor.TenantId && value.IdempotencyKey == idempotencyKey, cancellationToken);
if (existingQuote is not null)
{
return await LoadQuoteAsync(actor.TenantId, existingQuote.Id, cancellationToken);
}
var requestedIds = new[] { command.BaseOfferingVersionId }
.Concat(command.AddOnOfferingVersionIds)
.Distinct()
.ToArray();
var now = DateTimeOffset.UtcNow;
var versions = await (
from version in dbContext.SaasOfferingVersions.AsNoTracking()
join offering in dbContext.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id
where requestedIds.Contains(version.Id) &&
version.Status == SaasOfferingVersionStatus.Published &&
offering.Status == SaasOfferingStatus.Active &&
(version.EffectiveAt == null || version.EffectiveAt <= now)
select new { Version = version, Offering = offering })
.ToArrayAsync(cancellationToken);
if (versions.Length != requestedIds.Length ||
versions.SingleOrDefault(value => value.Version.Id == command.BaseOfferingVersionId)?.Offering.Type != SaasOfferingType.BasePlan ||
versions.Any(value => value.Version.Id != command.BaseOfferingVersionId && value.Offering.Type != SaasOfferingType.AddOn))
{
throw Error("One or more offering versions are unavailable.", "saas_offering_version_unavailable");
}
if (versions.Select(value => value.Version.Currency).Distinct(StringComparer.Ordinal).Count() != 1)
{
throw Error("All quote items must use the same currency.", "platform_billing_currency_mismatch");
}
var featureRows = await dbContext.SaasOfferingVersionFeatures.AsNoTracking()
.Where(value => requestedIds.Contains(value.OfferingVersionId))
.ToArrayAsync(cancellationToken);
var limitRows = await dbContext.SaasOfferingVersionLimits.AsNoTracking()
.Where(value => requestedIds.Contains(value.OfferingVersionId))
.ToArrayAsync(cancellationToken);
var featureCodes = featureRows.Select(value => value.FeatureCode).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray();
var limits = limitRows.GroupBy(value => value.MetricCode, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.Sum(value => value.LimitValue), StringComparer.Ordinal);
var originalAmount = versions.Sum(value => value.Version.OriginalAmountCents);
var totalAmount = versions.Sum(value => value.Version.AmountCents);
var quote = new PlatformBillingQuote
{
TenantId = actor.TenantId,
QuoteNo = Number("SQ"),
IdempotencyKey = idempotencyKey,
Purpose = command.Purpose,
OriginalAmountCents = originalAmount,
DiscountAmountCents = originalAmount - totalAmount,
TotalAmountCents = totalAmount,
Currency = versions[0].Version.Currency,
ExpiresAt = now.Add(QuoteLifetime),
FeatureSnapshot = JsonSerializer.SerializeToElement(featureCodes),
LimitSnapshot = JsonSerializer.SerializeToElement(limits)
};
dbContext.PlatformBillingQuotes.Add(quote);
dbContext.PlatformBillingQuoteItems.AddRange(versions.Select(value => new PlatformBillingQuoteItem
{
TenantId = actor.TenantId,
QuoteId = quote.Id,
OfferingVersionId = value.Version.Id,
ItemType = value.Offering.Type == SaasOfferingType.BasePlan
? PlatformBillingItemType.BasePlan
: PlatformBillingItemType.AddOn,
Quantity = 1,
UnitAmountCents = value.Version.AmountCents,
AmountCents = value.Version.AmountCents,
Snapshot = JsonSerializer.SerializeToElement(new
{
offeringId = value.Offering.Id,
offeringCode = value.Offering.Code,
offeringName = value.Offering.Name,
offeringType = value.Offering.Type,
version = value.Version.Version,
value.Version.BillingCycle,
featureCodes = featureRows.Where(feature => feature.OfferingVersionId == value.Version.Id).Select(feature => feature.FeatureCode).Order(StringComparer.Ordinal),
limits = limitRows.Where(limit => limit.OfferingVersionId == value.Version.Id).ToDictionary(limit => limit.MetricCode, limit => limit.LimitValue)
})
}));
await dbContext.SaveChangesAsync(cancellationToken);
return await LoadQuoteAsync(actor.TenantId, quote.Id, cancellationToken);
}
public async Task<PlatformBillingOrderView> CreateOrderAsync(
TenantBillingActor actor,
CreatePlatformBillingOrderCommand command,
CancellationToken cancellationToken = default)
{
var key = Required(command.IdempotencyKey, "idempotencyKey");
var existing = await dbContext.PlatformBillingOrders.AsNoTracking()
.SingleOrDefaultAsync(value => value.TenantId == actor.TenantId && value.IdempotencyKey == key, cancellationToken);
if (existing is not null)
{
return await LoadOrderAsync(actor.TenantId, existing.OrderNo, cancellationToken);
}
var now = DateTimeOffset.UtcNow;
var quote = await dbContext.PlatformBillingQuotes.SingleOrDefaultAsync(value =>
value.TenantId == actor.TenantId && value.Id == command.QuoteId, cancellationToken)
?? throw Error("Quote was not found.", "platform_billing_quote_not_found");
if (quote.Status != PlatformBillingQuoteStatus.Active || quote.ExpiresAt <= now)
{
quote.Status = quote.ExpiresAt <= now ? PlatformBillingQuoteStatus.Expired : quote.Status;
await dbContext.SaveChangesAsync(cancellationToken);
throw Error("Quote is no longer active.", "platform_billing_quote_expired");
}
var quoteItems = await dbContext.PlatformBillingQuoteItems.AsNoTracking()
.Where(value => value.TenantId == actor.TenantId && value.QuoteId == quote.Id)
.ToArrayAsync(cancellationToken);
var order = new PlatformBillingOrder
{
TenantId = actor.TenantId,
QuoteId = quote.Id,
OrderNo = Number("SO"),
IdempotencyKey = key,
Purpose = quote.Purpose,
OriginalAmountCents = quote.OriginalAmountCents,
DiscountAmountCents = quote.DiscountAmountCents,
TotalAmountCents = quote.TotalAmountCents,
Currency = quote.Currency,
ExpiresAt = now.Add(OrderLifetime),
Snapshot = JsonSerializer.SerializeToElement(new
{
quote.QuoteNo,
quote.FeatureSnapshot,
quote.LimitSnapshot
})
};
dbContext.PlatformBillingOrders.Add(order);
dbContext.PlatformBillingOrderItems.AddRange(quoteItems.Select(value => new PlatformBillingOrderItem
{
TenantId = actor.TenantId,
OrderId = order.Id,
OfferingVersionId = value.OfferingVersionId,
ItemType = value.ItemType,
Quantity = value.Quantity,
UnitAmountCents = value.UnitAmountCents,
AmountCents = value.AmountCents,
Snapshot = value.Snapshot
}));
quote.Status = PlatformBillingQuoteStatus.Converted;
await dbContext.SaveChangesAsync(cancellationToken);
return await LoadOrderAsync(actor.TenantId, order.OrderNo, cancellationToken);
}
public async Task<PlatformBillingPaymentView> CreatePaymentAsync(
TenantBillingActor actor,
CreatePlatformBillingPaymentCommand command,
CancellationToken cancellationToken = default)
{
var key = Required(command.IdempotencyKey, "idempotencyKey");
var existing = await dbContext.PlatformBillingPayments.AsNoTracking()
.SingleOrDefaultAsync(value => value.TenantId == actor.TenantId && value.IdempotencyKey == key, cancellationToken);
if (existing is not null)
{
return ToPaymentView(existing);
}
var order = await dbContext.PlatformBillingOrders.SingleOrDefaultAsync(value =>
value.TenantId == actor.TenantId && value.OrderNo == command.OrderNo, cancellationToken)
?? throw Error("Order was not found.", "platform_billing_order_not_found");
if (order.Status != PlatformBillingOrderStatus.PendingPayment || order.ExpiresAt <= DateTimeOffset.UtcNow)
{
if (order.ExpiresAt <= DateTimeOffset.UtcNow && order.Status == PlatformBillingOrderStatus.PendingPayment)
{
order.Status = PlatformBillingOrderStatus.Expired;
await dbContext.SaveChangesAsync(cancellationToken);
}
throw Error("Order does not allow a new payment.", "platform_billing_order_status_invalid");
}
var provider = NormalizeProvider(command.Provider);
var payment = new PlatformBillingPayment
{
TenantId = actor.TenantId,
OrderId = order.Id,
PaymentNo = Number("SP"),
IdempotencyKey = key,
Provider = provider,
Method = Required(command.Method, "method"),
AmountCents = order.TotalAmountCents
};
dbContext.PlatformBillingPayments.Add(payment);
await dbContext.SaveChangesAsync(cancellationToken);
if (order.TotalAmountCents == 0)
{
await settlementService.MarkPaidAsync(
payment.Id,
$"zero-{payment.Id:N}",
"zero_amount_settled",
null,
DateTimeOffset.UtcNow,
JsonDefaults.Object(),
actor.UserId,
cancellationToken);
return ToPaymentView(payment);
}
try
{
var result = await paymentGateway.CreatePaymentAsync(
provider,
new CreatePaymentProviderRequest(
actor.TenantId,
order.OrderNo,
$"SaaS subscription {order.OrderNo}",
order.TotalAmountCents,
payment.Method,
command.OpenId,
command.ReturnUrl,
command.QuitUrl,
provider == PaymentProviders.Manual ? string.Empty : BuildNotifyUrl(provider),
JsonSerializer.SerializeToElement(new { payment.PaymentNo })),
cancellationToken);
payment.ProviderTradeNo = result.ProviderTradeNo;
payment.ClientPayload = result.ClientPayload;
await dbContext.SaveChangesAsync(cancellationToken);
return ToPaymentView(payment);
}
catch
{
payment.Status = PlatformBillingPaymentStatus.Failed;
await dbContext.SaveChangesAsync(CancellationToken.None);
throw;
}
}
public async Task<IReadOnlyCollection<PlatformBillingOrderView>> GetOrdersAsync(
TenantBillingActor actor,
int limit,
CancellationToken cancellationToken = default)
{
var orderNos = await dbContext.PlatformBillingOrders.AsNoTracking()
.Where(value => value.TenantId == actor.TenantId)
.OrderByDescending(value => value.CreatedAt)
.Take(Math.Clamp(limit, 1, 200))
.Select(value => value.OrderNo)
.ToArrayAsync(cancellationToken);
var result = new List<PlatformBillingOrderView>();
foreach (var orderNo in orderNos)
{
result.Add(await LoadOrderAsync(actor.TenantId, orderNo, cancellationToken));
}
return result;
}
public Task<PlatformBillingOrderView> GetOrderAsync(TenantBillingActor actor, string orderNo, CancellationToken cancellationToken = default) =>
LoadOrderAsync(actor.TenantId, orderNo, cancellationToken);
public async Task<TenantSubscriptionView?> GetSubscriptionAsync(
TenantBillingActor actor,
CancellationToken cancellationToken = default)
{
var subscription = await dbContext.TenantSaasSubscriptions.AsNoTracking()
.Where(value => value.TenantId == actor.TenantId)
.OrderByDescending(value => value.UpdatedAt)
.FirstOrDefaultAsync(cancellationToken);
return subscription is null ? null : await ToSubscriptionViewAsync(subscription, cancellationToken);
}
public async Task<PlatformBillingOrderView> ChangeSubscriptionAsync(
TenantBillingActor actor,
ChangeTenantSubscriptionCommand command,
string idempotencyKey,
CancellationToken cancellationToken = default)
{
var current = await dbContext.TenantSaasSubscriptions.AsNoTracking()
.Where(value => value.TenantId == actor.TenantId)
.OrderByDescending(value => value.UpdatedAt)
.FirstOrDefaultAsync(cancellationToken);
var currentAmount = current is null
? 0
: await dbContext.SaasOfferingVersions.AsNoTracking().Where(value => value.Id == current.BaseOfferingVersionId).Select(value => value.AmountCents).SingleAsync(cancellationToken);
var requestedAmount = await dbContext.SaasOfferingVersions.AsNoTracking().Where(value => value.Id == command.BaseOfferingVersionId).Select(value => (int?)value.AmountCents).SingleOrDefaultAsync(cancellationToken)
?? throw Error("Offering version was not found.", "saas_offering_version_not_found");
var purpose = current is null
? PlatformBillingOrderPurpose.NewSubscription
: requestedAmount >= currentAmount
? PlatformBillingOrderPurpose.Upgrade
: PlatformBillingOrderPurpose.Downgrade;
var quote = await CreateQuoteAsync(actor, new CreatePlatformBillingQuoteCommand(
command.BaseOfferingVersionId, command.AddOnOfferingVersionIds, purpose, $"{Required(idempotencyKey, "idempotencyKey")}:quote"), cancellationToken);
return await CreateOrderAsync(actor, new CreatePlatformBillingOrderCommand(quote.Id, idempotencyKey), cancellationToken);
}
public async Task<PlatformBillingOrderView> RenewSubscriptionAsync(
TenantBillingActor actor,
string idempotencyKey,
CancellationToken cancellationToken = default)
{
var subscription = await dbContext.TenantSaasSubscriptions.AsNoTracking()
.Where(value => value.TenantId == actor.TenantId)
.OrderByDescending(value => value.UpdatedAt)
.FirstOrDefaultAsync(cancellationToken)
?? throw Error("Subscription was not found.", "tenant_saas_subscription_not_found");
var addOns = await dbContext.TenantSaasSubscriptionItems.AsNoTracking()
.Where(value => value.TenantId == actor.TenantId && value.SubscriptionId == subscription.Id &&
value.ItemType == TenantSaasSubscriptionItemType.AddOn &&
value.Status == TenantSaasSubscriptionItemStatus.Active)
.Select(value => value.OfferingVersionId)
.ToArrayAsync(cancellationToken);
var quote = await CreateQuoteAsync(actor, new CreatePlatformBillingQuoteCommand(
subscription.BaseOfferingVersionId, addOns, PlatformBillingOrderPurpose.Renewal, $"{Required(idempotencyKey, "idempotencyKey")}:quote"), cancellationToken);
return await CreateOrderAsync(actor, new CreatePlatformBillingOrderCommand(quote.Id, idempotencyKey), cancellationToken);
}
public async Task<TenantSubscriptionView> CancelSubscriptionAsync(
TenantBillingActor actor,
CancellationToken cancellationToken = default)
{
var subscription = await dbContext.TenantSaasSubscriptions
.Where(value => value.TenantId == actor.TenantId)
.OrderByDescending(value => value.UpdatedAt)
.FirstOrDefaultAsync(cancellationToken)
?? throw Error("Subscription was not found.", "tenant_saas_subscription_not_found");
subscription.CancelAtPeriodEnd = true;
subscription.CancelledAt = DateTimeOffset.UtcNow;
await dbContext.SaveChangesAsync(cancellationToken);
return await ToSubscriptionViewAsync(subscription, cancellationToken);
}
public Task<IReadOnlyCollection<FeatureQuotaSnapshot>> GetUsageAsync(TenantBillingActor actor, CancellationToken cancellationToken = default) =>
featureAccessService.GetQuotaSummaryAsync(actor.TenantId, cancellationToken);
public async Task<IReadOnlyCollection<PlatformBillingInvoice>> GetInvoicesAsync(
TenantBillingActor actor,
int limit,
CancellationToken cancellationToken = default) =>
await dbContext.PlatformBillingInvoices.AsNoTracking()
.Where(value => value.TenantId == actor.TenantId)
.OrderByDescending(value => value.CreatedAt)
.Take(Math.Clamp(limit, 1, 200))
.ToArrayAsync(cancellationToken);
private async Task<PlatformBillingQuoteView> LoadQuoteAsync(Guid tenantId, Guid quoteId, CancellationToken cancellationToken)
{
var quote = await dbContext.PlatformBillingQuotes.AsNoTracking().SingleAsync(value => value.TenantId == tenantId && value.Id == quoteId, cancellationToken);
var items = await LoadQuoteItemsAsync(tenantId, quote.Id, cancellationToken);
return new PlatformBillingQuoteView(
quote.Id,
quote.QuoteNo,
quote.Status,
quote.Purpose,
quote.OriginalAmountCents,
quote.DiscountAmountCents,
quote.TotalAmountCents,
quote.Currency,
quote.ExpiresAt,
items,
ReadStringArray(quote.FeatureSnapshot),
ReadLongDictionary(quote.LimitSnapshot));
}
private async Task<PlatformBillingOrderView> LoadOrderAsync(Guid tenantId, string orderNo, CancellationToken cancellationToken)
{
var normalized = Required(orderNo, "orderNo");
var order = await dbContext.PlatformBillingOrders.AsNoTracking().SingleOrDefaultAsync(value => value.TenantId == tenantId && value.OrderNo == normalized, cancellationToken)
?? throw Error("Order was not found.", "platform_billing_order_not_found");
var items = await (
from item in dbContext.PlatformBillingOrderItems.AsNoTracking()
join version in dbContext.SaasOfferingVersions.AsNoTracking() on item.OfferingVersionId equals version.Id
join offering in dbContext.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id
where item.TenantId == tenantId && item.OrderId == order.Id
orderby item.ItemType, offering.Code
select new PlatformBillingQuoteItemView(item.OfferingVersionId, offering.Code, offering.Name, item.ItemType, item.UnitAmountCents, item.AmountCents))
.ToArrayAsync(cancellationToken);
return new PlatformBillingOrderView(order.Id, order.OrderNo, order.Purpose, order.Status, order.TotalAmountCents, order.Currency, order.ExpiresAt, order.PaidAt, items);
}
private async Task<PlatformBillingQuoteItemView[]> LoadQuoteItemsAsync(Guid tenantId, Guid quoteId, CancellationToken cancellationToken) =>
await (
from item in dbContext.PlatformBillingQuoteItems.AsNoTracking()
join version in dbContext.SaasOfferingVersions.AsNoTracking() on item.OfferingVersionId equals version.Id
join offering in dbContext.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id
where item.TenantId == tenantId && item.QuoteId == quoteId
orderby item.ItemType, offering.Code
select new PlatformBillingQuoteItemView(item.OfferingVersionId, offering.Code, offering.Name, item.ItemType, item.UnitAmountCents, item.AmountCents))
.ToArrayAsync(cancellationToken);
private async Task<TenantSubscriptionView> ToSubscriptionViewAsync(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];
var features = await dbContext.SaasOfferingVersionFeatures.AsNoTracking()
.Where(value => versionIds.Contains(value.OfferingVersionId))
.Select(value => value.FeatureCode)
.Distinct()
.OrderBy(value => value)
.ToArrayAsync(cancellationToken);
return new TenantSubscriptionView(
subscription.Id,
subscription.Status,
subscription.StartsAt,
subscription.CurrentPeriodStart,
subscription.CurrentPeriodEnd,
subscription.CancelAtPeriodEnd,
subscription.BaseOfferingVersionId,
subscription.ScheduledBaseOfferingVersionId,
features);
}
private async Task<SaasOfferingVersionItem[]> LoadPublishedVersionsAsync(DateTimeOffset now, CancellationToken cancellationToken)
{
var rows = await (
from version in dbContext.SaasOfferingVersions.AsNoTracking()
join offering in dbContext.SaasOfferings.AsNoTracking() on version.OfferingId equals offering.Id
where version.Status == SaasOfferingVersionStatus.Published && offering.Status == SaasOfferingStatus.Active &&
(version.EffectiveAt == null || version.EffectiveAt <= now)
orderby offering.SortOrder, offering.Code, version.Version descending
select new { Version = version, Offering = offering })
.ToArrayAsync(cancellationToken);
var latest = rows.GroupBy(value => value.Offering.Id).Select(group => group.First()).ToArray();
var ids = latest.Select(value => value.Version.Id).ToArray();
var features = await dbContext.SaasOfferingVersionFeatures.AsNoTracking().Where(value => ids.Contains(value.OfferingVersionId)).ToArrayAsync(cancellationToken);
var limits = await dbContext.SaasOfferingVersionLimits.AsNoTracking().Where(value => ids.Contains(value.OfferingVersionId)).ToArrayAsync(cancellationToken);
return latest.Select(row => new SaasOfferingVersionItem(
row.Version.Id, row.Offering.Id, row.Offering.Code, row.Offering.Name, row.Offering.Type,
row.Version.Version, row.Version.Status, row.Version.BillingCycle,
row.Version.OriginalAmountCents, row.Version.AmountCents, row.Version.Currency,
row.Version.EffectiveAt, row.Version.PublishedAt,
features.Where(value => value.OfferingVersionId == row.Version.Id).Select(value => value.FeatureCode).Order(StringComparer.Ordinal).ToArray(),
limits.Where(value => value.OfferingVersionId == row.Version.Id).ToDictionary(value => value.MetricCode, value => value.LimitValue, StringComparer.Ordinal)))
.ToArray();
}
private string BuildNotifyUrl(string provider)
{
var baseUrl = configuration["PlatformBilling:PublicBaseUrl"]?.TrimEnd('/');
if (string.IsNullOrWhiteSpace(baseUrl))
{
throw Error("Platform billing public base URL is not configured.", "platform_billing_public_url_missing");
}
return $"{baseUrl}/api/platform-billing/callbacks/{provider}";
}
private static PlatformBillingPaymentView ToPaymentView(PlatformBillingPayment value) =>
new(value.Id, value.PaymentNo, value.Provider, value.Method, value.Status, value.AmountCents, value.ClientPayload);
private static string Number(string prefix) => $"{prefix}{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Guid.NewGuid():N}"[..32];
private static string Required(string? value, string field) => string.IsNullOrWhiteSpace(value) ? throw Error($"{field} is required.", "required_field") : value.Trim();
private static string NormalizeProvider(string value) => Required(value, "provider").ToLowerInvariant().Replace('-', '_');
private static PlatformBillingException Error(string message, string code) => new(message, code);
private static string[] ReadStringArray(JsonElement value) => value.ValueKind == JsonValueKind.Array
? value.EnumerateArray().Where(item => item.ValueKind == JsonValueKind.String).Select(item => item.GetString()!).ToArray()
: [];
private static IReadOnlyDictionary<string, long> ReadLongDictionary(JsonElement value)
{
if (value.ValueKind != JsonValueKind.Object) return new Dictionary<string, long>(StringComparer.Ordinal);
return value.EnumerateObject().Where(property => property.Value.TryGetInt64(out _))
.ToDictionary(property => property.Name, property => property.Value.GetInt64(), StringComparer.Ordinal);
}
}