清理代码
Some checks failed
ci / release-gate (push) Has been cancelled

This commit is contained in:
2026-08-03 12:31:39 +08:00
parent caea0062b0
commit c497a3ca8d
537 changed files with 14171 additions and 12503 deletions

View File

@@ -37,14 +37,9 @@ internal sealed class CommercialBillingProcessor(
public async Task<int> ProcessDueAsync(CancellationToken cancellationToken = default)
{
if (!options.Value.Enabled)
{
return 0;
}
if (!options.Value.Enabled) return 0;
if (!tenantContext.IsSystem || tenantContext.TenantId.HasValue)
{
throw new InvalidOperationException("Commercial billing discovery requires a global system context.");
}
var processed = 0;
processed += await ProcessRenewalsAsync(cancellationToken);
@@ -58,24 +53,27 @@ internal sealed class CommercialBillingProcessor(
{
var now = DateTimeOffset.UtcNow;
var candidates = await (
from subscription in directoryDbContext.TenantSaasSubscriptions.AsNoTracking()
join policy in directoryDbContext.TenantBillingPolicies.AsNoTracking() on subscription.TenantId equals policy.TenantId
where policy.AutoGenerateRenewal &&
(subscription.Status == TenantSaasSubscriptionStatus.Active || subscription.Status == TenantSaasSubscriptionStatus.Trial) &&
subscription.CurrentPeriodEnd <= now.AddDays(policy.RenewalLeadDays)
orderby subscription.CurrentPeriodEnd
select new { subscription.TenantId, SubscriptionId = subscription.Id })
from subscription in directoryDbContext.TenantSaasSubscriptions.AsNoTracking()
join policy in directoryDbContext.TenantBillingPolicies.AsNoTracking() on subscription.TenantId equals
policy.TenantId
where policy.AutoGenerateRenewal &&
(subscription.Status == TenantSaasSubscriptionStatus.Active ||
subscription.Status == TenantSaasSubscriptionStatus.Trial) &&
subscription.CurrentPeriodEnd <= now.AddDays(policy.RenewalLeadDays)
orderby subscription.CurrentPeriodEnd
select new { subscription.TenantId, SubscriptionId = subscription.Id })
.Take(BatchSize())
.ToArrayAsync(cancellationToken);
foreach (var candidate in candidates)
{
await tenantExecutionScope.ExecuteAsync(
Scope(candidate.TenantId, "Generate subscription renewal receivable", candidate.SubscriptionId),
async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var subscription = await db.TenantSaasSubscriptions.SingleAsync(value => value.Id == candidate.SubscriptionId, token);
var subscription =
await db.TenantSaasSubscriptions.SingleAsync(value => value.Id == candidate.SubscriptionId,
token);
var key = $"auto-renewal:{subscription.Id:N}:{subscription.CurrentPeriodEnd:yyyyMMddHHmmss}";
var existingOrder = await db.PlatformBillingOrders.SingleOrDefaultAsync(value =>
value.TenantId == subscription.TenantId && value.IdempotencyKey == key, token);
@@ -83,13 +81,15 @@ internal sealed class CommercialBillingProcessor(
if (existingOrder is null)
{
var ownerId = await db.Tenants.AsNoTracking()
.Where(value => value.Id == subscription.TenantId)
.Select(value => value.OwnerUserId)
.SingleAsync(token)
?? throw new PlatformBillingException("Tenant owner is required for renewal billing.", "tenant_owner_not_found");
.Where(value => value.Id == subscription.TenantId)
.Select(value => value.OwnerUserId)
.SingleAsync(token)
?? throw new PlatformBillingException(
"Tenant owner is required for renewal billing.", "tenant_owner_not_found");
orderView = await services.GetRequiredService<ITenantBillingService>()
.RenewSubscriptionAsync(new TenantBillingActor(ownerId, subscription.TenantId), key, token);
existingOrder = await db.PlatformBillingOrders.SingleAsync(value => value.Id == orderView.Id, token);
existingOrder =
await db.PlatformBillingOrders.SingleAsync(value => value.Id == orderView.Id, token);
existingOrder.ExpiresAt = subscription.CurrentPeriodEnd;
}
else
@@ -108,7 +108,6 @@ internal sealed class CommercialBillingProcessor(
if (!await db.PlatformBillingInvoices.AnyAsync(value =>
value.TenantId == subscription.TenantId && value.OrderId == existingOrder.Id, token))
{
db.PlatformBillingInvoices.Add(new PlatformBillingInvoice
{
TenantId = subscription.TenantId,
@@ -121,11 +120,9 @@ internal sealed class CommercialBillingProcessor(
IssuedAt = now,
BillingProfileSnapshot = await BillingProfileSnapshotAsync(db, subscription.TenantId, token)
});
}
await db.SaveChangesAsync(token);
},
cancellationToken);
}
return candidates.Length;
}
@@ -134,7 +131,8 @@ internal sealed class CommercialBillingProcessor(
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var candidates = await directoryDbContext.PlatformBillingInvoices.AsNoTracking()
.Where(value => value.DueDate != null &&
(value.Status == PlatformBillingInvoiceStatus.Issued || value.Status == PlatformBillingInvoiceStatus.Overdue) &&
(value.Status == PlatformBillingInvoiceStatus.Issued ||
value.Status == PlatformBillingInvoiceStatus.Overdue) &&
value.DueDate <= today.AddDays(7))
.OrderBy(value => value.DueDate)
.Select(value => new { value.TenantId, InvoiceId = value.Id })
@@ -142,27 +140,27 @@ internal sealed class CommercialBillingProcessor(
.ToArrayAsync(cancellationToken);
var created = 0;
foreach (var candidate in candidates)
{
created += await tenantExecutionScope.ExecuteAsync(
Scope(candidate.TenantId, "Generate billing reminder", candidate.InvoiceId),
async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var invoice = await db.PlatformBillingInvoices.SingleAsync(value => value.Id == candidate.InvoiceId, token);
var invoice =
await db.PlatformBillingInvoices.SingleAsync(value => value.Id == candidate.InvoiceId, token);
var dueDate = invoice.DueDate!.Value;
if (dueDate < today && invoice.Status == PlatformBillingInvoiceStatus.Issued)
{
invoice.Status = PlatformBillingInvoiceStatus.Overdue;
}
var schedule = ReminderFor(today, dueDate);
if (schedule is null || await db.PlatformBillingInvoiceReminders.AnyAsync(value =>
value.TenantId == invoice.TenantId && value.InvoiceId == invoice.Id &&
value.ReminderType == schedule.Value.Type && value.Channel == PlatformBillingInvoiceReminderChannel.Internal &&
value.ReminderType == schedule.Value.Type &&
value.Channel == PlatformBillingInvoiceReminderChannel.Internal &&
value.ReminderDate == today, token))
{
await db.SaveChangesAsync(token);
return 0;
}
var reminder = new PlatformBillingInvoiceReminder
{
TenantId = invoice.TenantId,
@@ -181,7 +179,6 @@ internal sealed class CommercialBillingProcessor(
var ownerId = await db.Tenants.AsNoTracking().Where(value => value.Id == invoice.TenantId)
.Select(value => value.OwnerUserId).SingleAsync(token);
if (ownerId.HasValue)
{
await services.GetRequiredService<INotificationProvider>().UpsertInAppAsync(
new InAppNotificationRequest(
invoice.TenantId,
@@ -194,7 +191,6 @@ internal sealed class CommercialBillingProcessor(
SourceId: invoice.Id,
DedupeKey: $"billing:{invoice.Id:N}:{schedule.Value.Type}:{today:yyyyMMdd}"),
token);
}
var channels = await db.PlatformBillingDunningNotificationChannels.AsNoTracking()
.Where(value => value.Enabled && value.MinReminderLevel <= schedule.Value.Level &&
(value.TenantIds.Length == 0 || value.TenantIds.Contains(invoice.TenantId)))
@@ -202,7 +198,6 @@ internal sealed class CommercialBillingProcessor(
foreach (var channel in channels.Where(value =>
value.ReminderTypes.Length == 0 ||
value.ReminderTypes.Contains(schedule.Value.Type.ToString().ToLowerInvariant())))
{
db.PlatformBillingDunningNotificationEvents.Add(new PlatformBillingDunningNotificationEvent
{
TenantId = invoice.TenantId,
@@ -222,12 +217,10 @@ internal sealed class CommercialBillingProcessor(
reminder.Message
})
});
}
await db.SaveChangesAsync(token);
return 1;
},
cancellationToken);
}
return created;
}
@@ -240,45 +233,48 @@ internal sealed class CommercialBillingProcessor(
.Take(BatchSize())
.ToArrayAsync(cancellationToken);
foreach (var candidate in candidates)
{
await tenantExecutionScope.ExecuteAsync(
Scope(candidate.TenantId, "Execute approved SaaS refund", candidate.RefundId),
async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var refund = await db.PlatformBillingRefunds.SingleAsync(value => value.Id == candidate.RefundId, token);
var payment = await db.PlatformBillingPayments.SingleAsync(value => value.Id == refund.PaymentId, token);
var refund =
await db.PlatformBillingRefunds.SingleAsync(value => value.Id == candidate.RefundId, token);
var payment =
await db.PlatformBillingPayments.SingleAsync(value => value.Id == refund.PaymentId, token);
var order = await db.PlatformBillingOrders.SingleAsync(value => value.Id == refund.OrderId, token);
try
{
var result = await services.GetRequiredService<IPlatformBillingPaymentGateway>().CreateRefundAsync(
payment.Provider,
new CreateRefundProviderRequest(
refund.TenantId,
order.OrderNo,
refund.RefundNo,
payment.ProviderTradeNo,
refund.AmountCents,
refund.Reason,
JsonSerializer.SerializeToElement(new { refund.SubscriptionEffect })),
token);
var result = await services.GetRequiredService<IPlatformBillingPaymentGateway>()
.CreateRefundAsync(
payment.Provider,
new CreateRefundProviderRequest(
refund.TenantId,
order.OrderNo,
refund.RefundNo,
payment.ProviderTradeNo,
refund.AmountCents,
refund.Reason,
JsonSerializer.SerializeToElement(new { refund.SubscriptionEffect })),
token);
if (!result.Succeeded)
{
throw new PlatformBillingException("Refund provider did not accept the refund.", "platform_billing_refund_provider_failed");
}
throw new PlatformBillingException("Refund provider did not accept the refund.",
"platform_billing_refund_provider_failed");
refund.Status = PlatformBillingRefundStatus.Succeeded;
refund.ProviderRefundNo = result.ProviderRefundNo;
refund.CompletedAt = DateTimeOffset.UtcNow;
refund.LastError = null;
var totalRefunded = await db.PlatformBillingRefunds.AsNoTracking()
.Where(value => value.TenantId == refund.TenantId && value.PaymentId == payment.Id &&
value.Status == PlatformBillingRefundStatus.Succeeded && value.Id != refund.Id)
value.Status == PlatformBillingRefundStatus.Succeeded &&
value.Id != refund.Id)
.SumAsync(value => (int?)value.AmountCents, token) ?? 0;
if (totalRefunded + refund.AmountCents >= payment.AmountCents)
{
payment.Status = PlatformBillingPaymentStatus.Refunded;
order.Status = PlatformBillingOrderStatus.Refunded;
}
await ApplyRefundEffectAsync(db, refund, token);
db.AuditLogs.Add(new AuditLog
{
@@ -287,7 +283,8 @@ internal sealed class CommercialBillingProcessor(
Action = "platform.saas.refund.succeeded",
TargetType = "platform_billing_refunds",
TargetId = refund.Id.ToString(),
Details = JsonSerializer.SerializeToElement(new { refund.AmountCents, refund.SubscriptionEffect })
Details = JsonSerializer.SerializeToElement(new
{ refund.AmountCents, refund.SubscriptionEffect })
});
}
catch (Exception exception)
@@ -295,11 +292,12 @@ internal sealed class CommercialBillingProcessor(
refund.Status = PlatformBillingRefundStatus.Failed;
refund.LastError = Truncate(exception.Message, 2000);
}
await db.SaveChangesAsync(token);
await services.GetRequiredService<ITenantFeatureCacheInvalidator>().InvalidateAsync(refund.TenantId, token);
await services.GetRequiredService<ITenantFeatureCacheInvalidator>()
.InvalidateAsync(refund.TenantId, token);
},
cancellationToken);
}
return candidates.Length;
}
@@ -316,14 +314,15 @@ internal sealed class CommercialBillingProcessor(
.Take(BatchSize())
.ToArrayAsync(cancellationToken);
foreach (var candidate in candidates)
{
await tenantExecutionScope.ExecuteAsync(
Scope(candidate.TenantId, "Dispatch billing dunning notification", candidate.EventId),
async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var item = await db.PlatformBillingDunningNotificationEvents.SingleAsync(value => value.Id == candidate.EventId, token);
var channel = await db.PlatformBillingDunningNotificationChannels.AsNoTracking().SingleAsync(value => value.Id == item.ChannelId, token);
var item = await db.PlatformBillingDunningNotificationEvents.SingleAsync(
value => value.Id == candidate.EventId, token);
var channel = await db.PlatformBillingDunningNotificationChannels.AsNoTracking()
.SingleAsync(value => value.Id == item.ChannelId, token);
item.Status = PlatformBillingDunningNotificationStatus.Processing;
item.Attempts++;
item.LastAttemptAt = DateTimeOffset.UtcNow;
@@ -348,6 +347,7 @@ internal sealed class CommercialBillingProcessor(
Encoding.UTF8.GetBytes(item.RequestPayload.GetRawText()))).ToLowerInvariant();
request.Headers.Add("X-Tiku-Signature", $"sha256={signature}");
}
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token);
timeout.CancelAfter(TimeSpan.FromSeconds(Math.Clamp(channel.TimeoutSeconds, 1, 60)));
using var response = await httpClient.SendAsync(request, timeout.Token);
@@ -355,9 +355,8 @@ internal sealed class CommercialBillingProcessor(
item.LastHttpCode = (int)response.StatusCode;
item.LastResponseSummary = summary;
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"Dunning webhook returned {(int)response.StatusCode}.", null, response.StatusCode);
}
throw new HttpRequestException($"Dunning webhook returned {(int)response.StatusCode}.",
null, response.StatusCode);
item.Status = PlatformBillingDunningNotificationStatus.Sent;
item.SentAt = DateTimeOffset.UtcNow;
item.NextAttemptAt = null;
@@ -374,35 +373,33 @@ internal sealed class CommercialBillingProcessor(
else
{
item.Status = PlatformBillingDunningNotificationStatus.Retrying;
item.NextAttemptAt = DateTimeOffset.UtcNow.Add(RetryDelays[Math.Min(item.Attempts - 1, RetryDelays.Length - 1)]);
item.NextAttemptAt =
DateTimeOffset.UtcNow.Add(
RetryDelays[Math.Min(item.Attempts - 1, RetryDelays.Length - 1)]);
}
}
await db.SaveChangesAsync(token);
},
cancellationToken);
}
return candidates.Length;
}
private async Task ApplyRefundEffectAsync(TikuDbContext db, PlatformBillingRefund refund, CancellationToken cancellationToken)
private async Task ApplyRefundEffectAsync(TikuDbContext db, PlatformBillingRefund refund,
CancellationToken cancellationToken)
{
if (refund.SubscriptionEffect == PlatformBillingRefundSubscriptionEffect.KeepService)
{
return;
}
if (refund.SubscriptionEffect == PlatformBillingRefundSubscriptionEffect.KeepService) return;
var subscription = await db.TenantSaasSubscriptions
.OrderByDescending(value => value.UpdatedAt)
.FirstOrDefaultAsync(value => value.TenantId == refund.TenantId, cancellationToken);
if (subscription is null)
{
return;
}
if (subscription is null) return;
if (refund.SubscriptionEffect == PlatformBillingRefundSubscriptionEffect.CancelAtPeriodEnd)
{
subscription.CancelAtPeriodEnd = true;
subscription.CancelledAt = DateTimeOffset.UtcNow;
return;
}
subscription.Status = TenantSaasSubscriptionStatus.Cancelled;
subscription.CancelAtPeriodEnd = false;
subscription.CancelledAt = DateTimeOffset.UtcNow;
@@ -411,7 +408,8 @@ internal sealed class CommercialBillingProcessor(
tenant.BillingStatus = BillingStatus.Cancelled;
var items = await db.TenantSaasSubscriptionItems
.Where(value => value.TenantId == refund.TenantId && value.SubscriptionId == subscription.Id &&
(value.Status == TenantSaasSubscriptionItemStatus.Active || value.Status == TenantSaasSubscriptionItemStatus.Scheduled))
(value.Status == TenantSaasSubscriptionItemStatus.Active ||
value.Status == TenantSaasSubscriptionItemStatus.Scheduled))
.ToArrayAsync(cancellationToken);
foreach (var item in items)
{
@@ -423,14 +421,11 @@ internal sealed class CommercialBillingProcessor(
private async Task<Uri> ValidateWebhookAsync(string value, CancellationToken cancellationToken)
{
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps || uri.IsLoopback)
{
throw new PlatformBillingException("Dunning webhook must be a non-loopback HTTPS URL.", "dunning_webhook_rejected");
}
throw new PlatformBillingException("Dunning webhook must be a non-loopback HTTPS URL.",
"dunning_webhook_rejected");
if (options.Value.AllowedWebhookHosts.Length == 0 ||
!options.Value.AllowedWebhookHosts.Contains(uri.Host, StringComparer.OrdinalIgnoreCase))
{
throw new PlatformBillingException("Dunning webhook host is not allowlisted.", "dunning_webhook_rejected");
}
IPAddress[] addresses;
try
{
@@ -440,42 +435,37 @@ internal sealed class CommercialBillingProcessor(
}
catch (SocketException)
{
throw new PlatformBillingException("Dunning webhook host could not be resolved.", "dunning_webhook_rejected");
throw new PlatformBillingException("Dunning webhook host could not be resolved.",
"dunning_webhook_rejected");
}
if (addresses.Length == 0 || addresses.Any(IsPrivate))
{
throw new PlatformBillingException("Dunning webhook cannot resolve to a private or reserved address.", "dunning_webhook_rejected");
}
throw new PlatformBillingException("Dunning webhook cannot resolve to a private or reserved address.",
"dunning_webhook_rejected");
return uri;
}
private static bool IsPrivate(IPAddress address)
{
if (address.IsIPv4MappedToIPv6)
{
address = address.MapToIPv4();
}
if (address.IsIPv4MappedToIPv6) address = address.MapToIPv4();
if (IPAddress.IsLoopback(address) || address.Equals(IPAddress.Any) || address.Equals(IPAddress.IPv6Any) ||
address.Equals(IPAddress.None) || address.Equals(IPAddress.IPv6None))
{
return true;
}
var bytes = address.GetAddressBytes();
if (address.AddressFamily == AddressFamily.InterNetwork)
{
return bytes[0] == 0 || bytes[0] == 10 || bytes[0] == 127 || bytes[0] >= 224 ||
(bytes[0] == 100 && bytes[1] is >= 64 and <= 127) ||
(bytes[0] == 169 && bytes[1] == 254) ||
(bytes[0] == 172 && bytes[1] is >= 16 and <= 31) ||
(bytes[0] == 192 && bytes[1] == 168) ||
(bytes[0] == 198 && bytes[1] is 18 or 19);
}
return address.AddressFamily != AddressFamily.InterNetworkV6 ||
address.IsIPv6LinkLocal || address.IsIPv6Multicast || address.IsIPv6SiteLocal ||
(bytes[0] & 0xfe) == 0xfc;
}
private static (PlatformBillingInvoiceReminderType Type, int Level, string Message)? ReminderFor(DateOnly today, DateOnly dueDate)
private static (PlatformBillingInvoiceReminderType Type, int Level, string Message)? ReminderFor(DateOnly today,
DateOnly dueDate)
{
var days = dueDate.DayNumber - today.DayNumber;
return days switch
@@ -488,27 +478,47 @@ internal sealed class CommercialBillingProcessor(
};
}
private static async Task<JsonElement> BillingProfileSnapshotAsync(TikuDbContext db, Guid tenantId, CancellationToken cancellationToken)
private static async Task<JsonElement> BillingProfileSnapshotAsync(TikuDbContext db, Guid tenantId,
CancellationToken cancellationToken)
{
var profile = await db.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
});
var profile = await db.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 SystemScopeRequest Scope(Guid tenantId, string reason, Guid correlationId) =>
new(tenantId, SystemScopeCallerType.Worker, nameof(CommercialBillingProcessor), reason, correlationId.ToString("N"));
private SystemScopeRequest Scope(Guid tenantId, string reason, Guid correlationId)
{
return new SystemScopeRequest(tenantId, SystemScopeCallerType.Worker, nameof(CommercialBillingProcessor),
reason,
correlationId.ToString("N"));
}
private int BatchSize()
{
return Math.Clamp(options.Value.BatchSize, 1, 1000);
}
private static string Number(string prefix)
{
return $"{prefix}{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Guid.NewGuid():N}"[..32];
}
private static string Truncate(string value, int length)
{
return value.Length <= length ? value : value[..length];
}
private int BatchSize() => Math.Clamp(options.Value.BatchSize, 1, 1000);
private static string Number(string prefix) => $"{prefix}{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Guid.NewGuid():N}"[..32];
private static string Truncate(string value, int length) => value.Length <= length ? value : value[..length];
private static string RedactResponseSummary(string value)
{
var redacted = Regex.Replace(
@@ -531,16 +541,14 @@ internal sealed class CommercialBillingProcessor(
TimeSpan.FromMilliseconds(100));
return Truncate(redacted, 500);
}
private static string GetSecret(JsonElement value, params string[] names)
{
foreach (var name in names)
{
if (value.ValueKind == JsonValueKind.Object && value.TryGetProperty(name, out var property) &&
property.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(property.GetString()))
{
return property.GetString()!;
}
}
throw new PlatformBillingException("Dunning signing secret is missing.", "dunning_secret_missing");
}
}
}

View File

@@ -1,3 +1,5 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
@@ -13,92 +15,133 @@ 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) =>
public Task<IReadOnlyCollection<PlatformBillingOrder>> GetOrdersAsync(SaasCatalogActor actor,
PlatformBillingAdminQuery query, CancellationToken cancellationToken = default)
{
return ExecuteAsync<IReadOnlyCollection<PlatformBillingOrder>>("list SaaS orders", async (services, token) =>
{
var db = services.GetRequiredService<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);
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<PlatformBillingPayment>> GetPaymentsAsync(SaasCatalogActor actor,
PlatformBillingAdminQuery query, CancellationToken cancellationToken = default)
{
return 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) =>
public Task<IReadOnlyCollection<PlatformBillingRefund>> GetRefundsAsync(SaasCatalogActor actor,
PlatformBillingAdminQuery query, CancellationToken cancellationToken = default)
{
return ExecuteAsync<IReadOnlyCollection<PlatformBillingRefund>>("list SaaS refunds", async (services, token) =>
{
var db = services.GetRequiredService<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);
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<PlatformBillingInvoice>> GetInvoicesAsync(SaasCatalogActor actor,
PlatformBillingAdminQuery query, CancellationToken cancellationToken = default)
{
return 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<TenantFeatureUsage>> GetUsageAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default) =>
ExecuteAsync<IReadOnlyCollection<TenantFeatureUsage>>("list SaaS usage", async (services, token) =>
public Task<IReadOnlyCollection<TenantFeatureUsage>> GetUsageAsync(SaasCatalogActor actor,
PlatformBillingAdminQuery query, CancellationToken cancellationToken = default)
{
return ExecuteAsync<IReadOnlyCollection<TenantFeatureUsage>>("list SaaS usage", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var values = db.TenantFeatureUsages.AsNoTracking();
if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId);
return await values.OrderByDescending(value => value.PeriodStart).ThenBy(value => value.MetricCode).Take(Limit(query.Limit)).ToArrayAsync(token);
return await values.OrderByDescending(value => value.PeriodStart).ThenBy(value => value.MetricCode)
.Take(Limit(query.Limit)).ToArrayAsync(token);
}, cancellationToken);
}
public Task<IReadOnlyCollection<PlatformBillingInvoiceReminder>> GetInvoiceRemindersAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default) =>
ExecuteAsync<IReadOnlyCollection<PlatformBillingInvoiceReminder>>("list SaaS invoice reminders", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var values = db.PlatformBillingInvoiceReminders.AsNoTracking();
if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId);
if (!string.IsNullOrWhiteSpace(query.Status)) values = values.Where(value => value.Status == Parse<PlatformBillingInvoiceReminderStatus>(query.Status));
return await values.OrderByDescending(value => value.ReminderDate).ThenByDescending(value => value.CreatedAt).Take(Limit(query.Limit)).ToArrayAsync(token);
}, cancellationToken);
public Task<IReadOnlyCollection<PlatformBillingInvoiceReminder>> GetInvoiceRemindersAsync(SaasCatalogActor actor,
PlatformBillingAdminQuery query, CancellationToken cancellationToken = default)
{
return ExecuteAsync<IReadOnlyCollection<PlatformBillingInvoiceReminder>>("list SaaS invoice reminders",
async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var values = db.PlatformBillingInvoiceReminders.AsNoTracking();
if (query.TenantId.HasValue) values = values.Where(value => value.TenantId == query.TenantId);
if (!string.IsNullOrWhiteSpace(query.Status))
values = values.Where(value =>
value.Status == Parse<PlatformBillingInvoiceReminderStatus>(query.Status));
return await values.OrderByDescending(value => value.ReminderDate)
.ThenByDescending(value => value.CreatedAt).Take(Limit(query.Limit)).ToArrayAsync(token);
}, cancellationToken);
}
public Task<IReadOnlyCollection<TenantSaasSubscription>> GetSubscriptionsAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default) =>
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<IReadOnlyCollection<TenantSaasSubscription>> GetSubscriptionsAsync(SaasCatalogActor actor,
PlatformBillingAdminQuery query, CancellationToken cancellationToken = default)
{
return 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) =>
CancellationToken cancellationToken = default)
{
return 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");
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");
}
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}",
@@ -109,24 +152,31 @@ internal sealed class PlatformBillingAdminService(
actor.UserId,
token);
}, cancellationToken);
}
public Task<TenantFeatureOverride> UpsertFeatureOverrideAsync(
SaasCatalogActor actor,
UpsertTenantFeatureOverrideCommand command,
CancellationToken cancellationToken = default) =>
ExecuteAsync("upsert tenant feature override", async (services, token) =>
CancellationToken cancellationToken = default)
{
return 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 (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();
@@ -137,19 +187,22 @@ internal sealed class PlatformBillingAdminService(
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 })
Details = JsonSerializer.SerializeToElement(new
{ item.FeatureCode, item.Mode, item.ExpiresAt, item.Reason })
});
await db.SaveChangesAsync(token);
await services.GetRequiredService<ITenantFeatureCacheInvalidator>()
.InvalidateAsync(command.TenantId, token);
return item;
}, cancellationToken);
}
public Task<TenantSaasSubscription> GrantTrialAsync(
SaasCatalogActor actor,
GrantTenantTrialCommand command,
CancellationToken cancellationToken = default) =>
ExecuteAsync("grant tenant SaaS trial", async (services, token) =>
CancellationToken cancellationToken = default)
{
return ExecuteAsync("grant tenant SaaS trial", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var key = Required(command.IdempotencyKey, "idempotencyKey");
@@ -161,27 +214,30 @@ internal sealed class PlatformBillingAdminService(
{
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);
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))
{
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");
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");
?? 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
@@ -219,30 +275,44 @@ internal sealed class PlatformBillingAdminService(
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> SuspendSubscriptionAsync(SaasCatalogActor actor,
ChangePlatformSubscriptionCommand command, CancellationToken cancellationToken = default)
{
return ChangeSubscriptionStatusAsync(actor, command, "suspended", cancellationToken);
}
public Task<TenantSaasSubscription> ResumeSubscriptionAsync(SaasCatalogActor actor, ChangePlatformSubscriptionCommand command, CancellationToken cancellationToken = default) =>
ChangeSubscriptionStatusAsync(actor, command, "resumed", cancellationToken);
public Task<TenantSaasSubscription> ResumeSubscriptionAsync(SaasCatalogActor actor,
ChangePlatformSubscriptionCommand command, CancellationToken cancellationToken = default)
{
return ChangeSubscriptionStatusAsync(actor, command, "resumed", cancellationToken);
}
public Task<TenantSaasSubscription> CancelSubscriptionAsync(SaasCatalogActor actor, ChangePlatformSubscriptionCommand command, CancellationToken cancellationToken = default) =>
ChangeSubscriptionStatusAsync(actor, command, "cancelled", cancellationToken);
public Task<TenantSaasSubscription> CancelSubscriptionAsync(SaasCatalogActor actor,
ChangePlatformSubscriptionCommand command, CancellationToken cancellationToken = default)
{
return ChangeSubscriptionStatusAsync(actor, command, "cancelled", cancellationToken);
}
public Task<TenantSaasSubscription> ExtendSubscriptionAsync(SaasCatalogActor actor, ChangePlatformSubscriptionCommand command, CancellationToken cancellationToken = default) =>
ChangeSubscriptionStatusAsync(actor, command, "extended", cancellationToken);
public Task<TenantSaasSubscription> ExtendSubscriptionAsync(SaasCatalogActor actor,
ChangePlatformSubscriptionCommand command, CancellationToken cancellationToken = default)
{
return ChangeSubscriptionStatusAsync(actor, command, "extended", cancellationToken);
}
public Task<PlatformBillingRefund> RequestRefundAsync(
SaasCatalogActor actor,
RequestPlatformRefundCommand command,
CancellationToken cancellationToken = default) =>
ExecuteAsync("request SaaS refund", async (services, token) =>
CancellationToken cancellationToken = default)
{
return 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");
.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);
@@ -251,24 +321,23 @@ internal sealed class PlatformBillingAdminService(
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");
}
throw Error("Refund amount exceeds the refundable payment balance.",
"platform_billing_refund_amount_invalid");
var refund = new PlatformBillingRefund
{
TenantId = payment.TenantId,
@@ -287,39 +356,52 @@ internal sealed class PlatformBillingAdminService(
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> ApproveRefundAsync(SaasCatalogActor actor, ReviewPlatformRefundCommand command,
CancellationToken cancellationToken = default)
{
return ReviewRefundAsync(actor, command, true, cancellationToken);
}
public Task<PlatformBillingRefund> RejectRefundAsync(SaasCatalogActor actor, ReviewPlatformRefundCommand command, CancellationToken cancellationToken = default) =>
ReviewRefundAsync(actor, command, false, cancellationToken);
public Task<PlatformBillingRefund> RejectRefundAsync(SaasCatalogActor actor, ReviewPlatformRefundCommand command,
CancellationToken cancellationToken = default)
{
return ReviewRefundAsync(actor, command, false, cancellationToken);
}
public Task<PlatformBillingRefund> RetryRefundAsync(SaasCatalogActor actor, ReviewPlatformRefundCommand command, CancellationToken cancellationToken = default) =>
ExecuteAsync("retry SaaS refund", async (services, token) =>
public Task<PlatformBillingRefund> RetryRefundAsync(SaasCatalogActor actor, ReviewPlatformRefundCommand command,
CancellationToken cancellationToken = default)
{
return ExecuteAsync("retry SaaS refund", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var refund = await db.PlatformBillingRefunds.SingleOrDefaultAsync(value => value.Id == command.RefundId, token)
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) =>
public Task<CommercialMetrics> GetCommercialMetricsAsync(SaasCatalogActor actor,
CancellationToken cancellationToken = default)
{
return 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
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
@@ -333,10 +415,13 @@ internal sealed class PlatformBillingAdminService(
.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)
.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)
.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)
@@ -351,18 +436,23 @@ internal sealed class PlatformBillingAdminService(
outstanding,
overdue,
refunded,
subscriptions.Count(value => value.Status is TenantSaasSubscriptionStatus.Cancelled or TenantSaasSubscriptionStatus.Expired));
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) =>
CancellationToken cancellationToken)
{
return ExecuteAsync($"{action} SaaS subscription", async (services, token) =>
{
var db = services.GetRequiredService<TikuDbContext>();
var subscription = await db.TenantSaasSubscriptions.SingleOrDefaultAsync(value => value.Id == command.SubscriptionId, token)
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
@@ -372,60 +462,76 @@ internal sealed class PlatformBillingAdminService(
switch (action)
{
case "suspended":
if (subscription.Status is TenantSaasSubscriptionStatus.Cancelled or TenantSaasSubscriptionStatus.Expired)
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");
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;
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))
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);
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))
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);
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);
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) =>
CancellationToken cancellationToken)
{
return 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)
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;
@@ -437,34 +543,67 @@ internal sealed class PlatformBillingAdminService(
await db.SaveChangesAsync(token);
return refund;
}, cancellationToken);
}
private static void AddAudit(TikuDbContext db, Guid actorUserId, Guid tenantId, string action, Guid targetId, string? reason) =>
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",
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 static string Required(string? value, string field)
{
return string.IsNullOrWhiteSpace(value) ? throw Error($"{field} is required.", "required_field") : value.Trim();
}
private static string Number(string prefix)
{
return $"{prefix}{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Guid.NewGuid():N}"[..32];
}
private static string Hash(string value)
{
return Convert
.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)))
.ToLowerInvariant();
}
private Task<TResult> ExecuteAsync<TResult>(
string reason,
Func<IServiceProvider, CancellationToken, Task<TResult>> operation,
CancellationToken cancellationToken) =>
tenantExecutionScope.ExecuteAsync(
new SystemScopeRequest(null, SystemScopeCallerType.Platform, nameof(PlatformBillingAdminService), reason, Guid.NewGuid().ToString("N"), true),
CancellationToken cancellationToken)
{
return tenantExecutionScope.ExecuteAsync(
new SystemScopeRequest(null, SystemScopeCallerType.Platform, nameof(PlatformBillingAdminService), reason,
Guid.NewGuid().ToString("N"), true),
operation,
cancellationToken);
}
private static T Parse<T>(string value) where T : struct, Enum =>
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);
}
private static T Parse<T>(string value) where T : struct, Enum
{
return Enum.TryParse<T>(value, true, out var parsed)
? parsed
: throw Error("Status is invalid.", "status_invalid");
}
private static int Limit(int value)
{
return Math.Clamp(value, 1, 200);
}
private static PlatformBillingException Error(string message, string code)
{
return new PlatformBillingException(message, code);
}
}

View File

@@ -3,7 +3,6 @@ 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;
@@ -12,33 +11,37 @@ internal sealed class PlatformBillingNotificationService(
ITenantExecutionScope tenantExecutionScope,
IPlatformBillingPaymentGateway paymentGateway) : IPlatformBillingNotificationService
{
public async Task ProcessAsync(PlatformBillingNotification notification, CancellationToken cancellationToken = default)
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),
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),
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");
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");
}
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");
.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,
@@ -52,5 +55,8 @@ internal sealed class PlatformBillingNotificationService(
cancellationToken);
}
private static PlatformBillingException Error(string message, string code) => new(message, code);
}
private static PlatformBillingException Error(string message, string code)
{
return new PlatformBillingException(message, code);
}
}

View File

@@ -16,23 +16,29 @@ internal sealed class PlatformBillingPaymentGateway(
public Task<CreatePaymentProviderResult> CreatePaymentAsync(
string provider,
CreatePaymentProviderRequest request,
CancellationToken cancellationToken = default) =>
ExecuteAsync(provider, request.OrderNo, async (resolvedProvider, account, token) =>
CancellationToken cancellationToken = default)
{
return 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) =>
CancellationToken cancellationToken = default)
{
return ExecuteAsync(provider, "payment-notification", async (resolvedProvider, account, token) =>
await resolvedProvider.ParsePaymentNotificationAsync(account, request, token), cancellationToken);
}
public Task<CreateRefundProviderResult> CreateRefundAsync(
string provider,
CreateRefundProviderRequest request,
CancellationToken cancellationToken = default) =>
ExecuteAsync(provider, request.RefundNo, async (resolvedProvider, account, token) =>
CancellationToken cancellationToken = default)
{
return ExecuteAsync(provider, request.RefundNo, async (resolvedProvider, account, token) =>
await resolvedProvider.CreateRefundAsync(account, request, token), cancellationToken);
}
private Task<TResult> ExecuteAsync<TResult>(
string provider,
@@ -47,7 +53,7 @@ internal sealed class PlatformBillingPaymentGateway(
nameof(PlatformBillingPaymentGateway),
"Resolve the platform-owned SaaS billing payment account",
correlationId,
IsGlobal: true),
true),
async (services, token) =>
{
var dbContext = services.GetRequiredService<TikuDbContext>();
@@ -56,14 +62,14 @@ internal sealed class PlatformBillingPaymentGateway(
.Select(value => value.Id)
.SingleOrDefaultAsync(token);
if (platformTenantId == Guid.Empty)
{
throw new PaymentProviderException("Platform-owned tenant is missing.", "platform_payment_owner_missing");
}
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");
.SingleOrDefault(value => value.Provider == normalized)
?? throw new PaymentProviderException("Payment provider is not supported.",
"payment_provider_not_supported");
PaymentProviderAccount account;
if (normalized == PaymentProviders.Manual)
{
@@ -77,10 +83,13 @@ internal sealed class PlatformBillingPaymentGateway(
else
{
var channel = await dbContext.PlatformPaymentChannels.AsNoTracking()
.Where(value => value.Provider == normalized && value.Status == PlatformPaymentChannelStatus.Active)
.OrderBy(value => value.Priority)
.FirstOrDefaultAsync(token)
?? throw new PaymentProviderException("Platform payment channel is missing.", "platform_payment_channel_missing");
.Where(value =>
value.Provider == normalized &&
value.Status == PlatformPaymentChannelStatus.Active)
.OrderBy(value => value.Priority)
.FirstOrDefaultAsync(token)
?? throw new PaymentProviderException("Platform payment channel is missing.",
"platform_payment_channel_missing");
account = new PaymentProviderAccount(
platformTenantId,
normalized,
@@ -102,7 +111,8 @@ internal sealed class PlatformBillingPaymentGateway(
"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")
_ => throw new PaymentProviderException("Payment provider is not supported.",
"payment_provider_not_supported")
};
}
}
}

View File

@@ -2,9 +2,9 @@ using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.PlatformBilling;
using Tiku.Application.Security;
using Tiku.Domain.Common;
using Tiku.Domain.Operations;
using Tiku.Domain.Platform;
using Tiku.Domain.Common;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
@@ -24,22 +24,20 @@ internal sealed class PlatformBillingSettlementService(
Guid? actorUserId,
CancellationToken cancellationToken = default)
{
var payment = await dbContext.PlatformBillingPayments.SingleOrDefaultAsync(value => value.Id == paymentId, cancellationToken)
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
{
@@ -55,10 +53,11 @@ internal sealed class PlatformBillingSettlementService(
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");
}
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;
@@ -72,7 +71,7 @@ internal sealed class PlatformBillingSettlementService(
.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");
?? 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
@@ -132,6 +131,7 @@ internal sealed class PlatformBillingSettlementService(
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,
@@ -158,6 +158,7 @@ internal sealed class PlatformBillingSettlementService(
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,
@@ -189,6 +190,7 @@ internal sealed class PlatformBillingSettlementService(
};
dbContext.PlatformBillingInvoices.Add(invoice);
}
invoice.Status = PlatformBillingInvoiceStatus.Paid;
invoice.PaidAt = now;
dbContext.AuditLogs.Add(new AuditLog
@@ -198,7 +200,8 @@ internal sealed class PlatformBillingSettlementService(
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 })
Details = JsonSerializer.SerializeToElement(new
{ order.OrderNo, payment.PaymentNo, payment.Provider, order.Purpose })
});
await dbContext.SaveChangesAsync(cancellationToken);
await featureCacheInvalidator.InvalidateAsync(order.TenantId, cancellationToken);
@@ -224,14 +227,20 @@ internal sealed class PlatformBillingSettlementService(
});
}
private static DateTimeOffset AddCycle(DateTimeOffset start, PlatformBillingCycle cycle) => cycle switch
private static DateTimeOffset AddCycle(DateTimeOffset start, PlatformBillingCycle cycle)
{
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))
};
return 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);
}
private static PlatformBillingException Error(string message, string code)
{
return new PlatformBillingException(message, code);
}
}

View File

@@ -37,29 +37,30 @@ internal sealed class SaasCatalogAdminService(
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");
}
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);
? 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) &&
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");
throw Error("A limit definition referenced by offering versions cannot change identity or kind.",
"saas_feature_limit_locked");
}
item.MetricCode = metricCode;
@@ -82,13 +83,9 @@ internal sealed class SaasCatalogAdminService(
{
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)
@@ -98,10 +95,9 @@ internal sealed class SaasCatalogAdminService(
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");
@@ -112,7 +108,8 @@ internal sealed class SaasCatalogAdminService(
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);
await AuditAsync(actor, "platform.saas.feature.upserted", "saas_features", item.Id,
new { item.Code, item.Status }, cancellationToken);
return item;
}
@@ -130,7 +127,8 @@ internal sealed class SaasCatalogAdminService(
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))
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");
}
@@ -142,7 +140,8 @@ internal sealed class SaasCatalogAdminService(
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);
await AuditAsync(actor, "platform.saas.offering.upserted", "saas_offerings", item.Id,
new { item.Code, item.Type, item.Status }, cancellationToken);
return item;
}
@@ -151,24 +150,22 @@ internal sealed class SaasCatalogAdminService(
UpsertSaasOfferingVersionCommand command,
CancellationToken cancellationToken = default)
{
var offering = await dbContext.SaasOfferings.SingleOrDefaultAsync(value => value.Id == command.OfferingId, cancellationToken)
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();
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);
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()
@@ -177,23 +174,19 @@ internal sealed class SaasCatalogAdminService(
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");
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");
}
throw Error("Offering version cannot move to another offering.",
"saas_offering_version_offering_locked");
}
else
{
@@ -211,8 +204,10 @@ internal sealed class SaasCatalogAdminService(
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);
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,
@@ -225,7 +220,8 @@ internal sealed class SaasCatalogAdminService(
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);
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();
}
@@ -235,19 +231,20 @@ internal sealed class SaasCatalogAdminService(
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");
}
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);
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);
await AuditAsync(actor, "platform.saas.offering_version.published", "saas_offering_versions", version.Id,
new { version.Version }, cancellationToken);
return (await LoadVersionsAsync(version.Id, cancellationToken)).Single();
}
@@ -256,10 +253,15 @@ internal sealed class SaasCatalogAdminService(
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);
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,
@@ -278,73 +280,103 @@ internal sealed class SaasCatalogAdminService(
Guid versionId,
CancellationToken cancellationToken = default)
{
var version = await dbContext.SaasOfferingVersions.SingleOrDefaultAsync(value => value.Id == versionId, cancellationToken)
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);
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)
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);
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);
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)))
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)
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(
private Task AuditAsync(SaasCatalogActor actor, string action, string targetType, Guid targetId, object details,
CancellationToken cancellationToken)
{
return 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();
}
private static PlatformBillingException Error(string message, string code)
{
return new PlatformBillingException(message, code);
}
private static string Normalize(string value)
{
return Required(value, "code").ToLowerInvariant();
}
private static string NormalizeCurrency(string value)
{
return Required(value, "currency").ToUpperInvariant();
}
private static string Required(string value, string field)
{
return string.IsNullOrWhiteSpace(value) ? throw Error($"{field} is required.", "required_field") : value.Trim();
}
private static string? Clean(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
private static JsonElement ObjectOrEmpty(JsonElement value)
{
return value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDocument.Parse("{}").RootElement.Clone();
}
}

View File

@@ -4,7 +4,6 @@ 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;
@@ -24,15 +23,10 @@ internal sealed class SaasSubscriptionLifecycleService(
DateTimeOffset? asOf = null,
CancellationToken cancellationToken = default)
{
if (!options.Enabled)
{
return 0;
}
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()
@@ -60,10 +54,7 @@ internal sealed class SaasSubscriptionLifecycleService(
$"saas-subscription-lifecycle-{tenantId:N}-{effectiveAt:yyyyMMddHHmmss}"),
(provider, token) => ProcessTenantAsync(provider, tenantId, effectiveAt, token),
cancellationToken);
if (wasChanged)
{
changed++;
}
if (wasChanged) changed++;
}
return changed;
@@ -79,16 +70,13 @@ internal sealed class SaasSubscriptionLifecycleService(
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),
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;
}
if (subscription is null) return false;
var previousStatus = subscription.Status;
var previousBaseVersionId = subscription.BaseOfferingVersionId;
@@ -105,7 +93,7 @@ internal sealed class SaasSubscriptionLifecycleService(
subscription.CancelledAt ??= asOf;
subscription.ScheduledBaseOfferingVersionId = null;
tenant.BillingStatus = BillingStatus.Cancelled;
EndItems(items, subscription.CurrentPeriodEnd, cancelScheduled: true);
EndItems(items, subscription.CurrentPeriodEnd, true);
}
else if (subscription.ScheduledBaseOfferingVersionId is { } scheduledVersionId)
{
@@ -124,14 +112,17 @@ internal sealed class SaasSubscriptionLifecycleService(
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();
EndItems(items.Where(value => value.Status == TenantSaasSubscriptionItemStatus.Active), nextPeriodStart,
false);
var scheduledItems = items.Where(value => value.Status == TenantSaasSubscriptionItemStatus.Scheduled)
.ToArray();
foreach (var item in scheduledItems)
{
item.Status = TenantSaasSubscriptionItemStatus.Active;
@@ -153,7 +144,7 @@ internal sealed class SaasSubscriptionLifecycleService(
subscription.Status = TenantSaasSubscriptionStatus.Expired;
subscription.CancelledAt ??= asOf;
tenant.BillingStatus = BillingStatus.Cancelled;
EndItems(items, subscription.CurrentPeriodEnd, cancelScheduled: true);
EndItems(items, subscription.CurrentPeriodEnd, true);
}
else if (subscription.Status == TenantSaasSubscriptionStatus.Active)
{
@@ -168,7 +159,7 @@ internal sealed class SaasSubscriptionLifecycleService(
subscription.Status = TenantSaasSubscriptionStatus.Expired;
subscription.CancelledAt ??= asOf;
tenant.BillingStatus = BillingStatus.Cancelled;
EndItems(items, subscription.CurrentPeriodEnd, cancelScheduled: true);
EndItems(items, subscription.CurrentPeriodEnd, true);
}
else
{
@@ -216,20 +207,14 @@ internal sealed class SaasSubscriptionLifecycleService(
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;
}
if (periodEnd > item.StartsAt && item.EndsAt > periodEnd) item.EndsAt = periodEnd;
}
else if (cancelScheduled && item.Status == TenantSaasSubscriptionItemStatus.Scheduled)
{
item.Status = TenantSaasSubscriptionItemStatus.Cancelled;
}
}
}
}
}

View File

@@ -5,6 +5,7 @@ using Tiku.Application.Commerce;
using Tiku.Application.PlatformBilling;
using Tiku.Application.Security;
using Tiku.Domain.Common;
using Tiku.Domain.Operations;
using Tiku.Domain.Platform;
using Tiku.Infrastructure.Persistence;
@@ -43,11 +44,9 @@ internal sealed class TenantBillingService(
{
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);
}
.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)
@@ -55,24 +54,22 @@ internal sealed class TenantBillingService(
.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 })
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))
{
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))
@@ -80,7 +77,8 @@ internal sealed class TenantBillingService(
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 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);
@@ -119,8 +117,10 @@ internal sealed class TenantBillingService(
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)
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);
@@ -134,16 +134,14 @@ internal sealed class TenantBillingService(
{
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);
}
.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");
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;
@@ -197,15 +195,13 @@ internal sealed class TenantBillingService(
{
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);
}
.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");
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)
@@ -213,6 +209,7 @@ internal sealed class TenantBillingService(
order.Status = PlatformBillingOrderStatus.Expired;
await dbContext.SaveChangesAsync(cancellationToken);
}
throw Error("Order does not allow a new payment.", "platform_billing_order_status_invalid");
}
@@ -285,15 +282,15 @@ internal sealed class TenantBillingService(
.Select(value => value.OrderNo)
.ToArrayAsync(cancellationToken);
var result = new List<PlatformBillingOrderView>();
foreach (var orderNo in orderNos)
{
result.Add(await LoadOrderAsync(actor.TenantId, orderNo, cancellationToken));
}
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 Task<PlatformBillingOrderView> GetOrderAsync(TenantBillingActor actor, string orderNo,
CancellationToken cancellationToken = default)
{
return LoadOrderAsync(actor.TenantId, orderNo, cancellationToken);
}
public async Task<TenantSubscriptionView?> GetSubscriptionAsync(
TenantBillingActor actor,
@@ -318,17 +315,23 @@ internal sealed class TenantBillingService(
.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");
: 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);
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(
@@ -337,10 +340,10 @@ internal sealed class TenantBillingService(
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");
.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 &&
@@ -348,8 +351,10 @@ internal sealed class TenantBillingService(
.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);
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(
@@ -357,34 +362,40 @@ internal sealed class TenantBillingService(
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");
.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 Task<IReadOnlyCollection<FeatureQuotaSnapshot>> GetUsageAsync(TenantBillingActor actor,
CancellationToken cancellationToken = default)
{
return featureAccessService.GetQuotaSummaryAsync(actor.TenantId, cancellationToken);
}
public async Task<IReadOnlyCollection<PlatformBillingInvoice>> GetInvoicesAsync(
TenantBillingActor actor,
int limit,
CancellationToken cancellationToken = default) =>
await dbContext.PlatformBillingInvoices.AsNoTracking()
CancellationToken cancellationToken = default)
{
return await dbContext.PlatformBillingInvoices.AsNoTracking()
.Where(value => value.TenantId == actor.TenantId)
.OrderByDescending(value => value.CreatedAt)
.Take(Math.Clamp(limit, 1, 200))
.ToArrayAsync(cancellationToken);
}
public async Task<IReadOnlyCollection<TenantReceivableView>> GetReceivablesAsync(
TenantBillingActor actor,
int limit,
CancellationToken cancellationToken = default) =>
await dbContext.PlatformBillingInvoices.AsNoTracking()
CancellationToken cancellationToken = default)
{
return await dbContext.PlatformBillingInvoices.AsNoTracking()
.Where(value => value.TenantId == actor.TenantId)
.OrderByDescending(value => value.CreatedAt)
.Take(Math.Clamp(limit, 1, 200))
@@ -399,6 +410,7 @@ internal sealed class TenantBillingService(
value.IssuedAt,
value.PaidAt))
.ToArrayAsync(cancellationToken);
}
public async Task<PlatformBillingOrderView> CancelOrderAsync(
TenantBillingActor actor,
@@ -407,12 +419,10 @@ internal sealed class TenantBillingService(
{
var normalized = Required(orderNo, "orderNo");
var order = await dbContext.PlatformBillingOrders.SingleOrDefaultAsync(value =>
value.TenantId == actor.TenantId && value.OrderNo == normalized,
value.TenantId == actor.TenantId && value.OrderNo == normalized,
cancellationToken) ?? throw Error("Order was not found.", "platform_billing_order_not_found");
if (order.Status != PlatformBillingOrderStatus.PendingPayment)
{
throw Error("Only a pending order can be cancelled.", "platform_billing_order_not_cancellable");
}
order.Status = PlatformBillingOrderStatus.Cancelled;
order.CancelledAt = DateTimeOffset.UtcNow;
var receivables = await dbContext.PlatformBillingInvoices
@@ -420,11 +430,8 @@ internal sealed class TenantBillingService(
(value.Status == PlatformBillingInvoiceStatus.Draft ||
value.Status == PlatformBillingInvoiceStatus.Issued))
.ToArrayAsync(cancellationToken);
foreach (var receivable in receivables)
{
receivable.Status = PlatformBillingInvoiceStatus.Void;
}
dbContext.AuditLogs.Add(new Tiku.Domain.Operations.AuditLog
foreach (var receivable in receivables) receivable.Status = PlatformBillingInvoiceStatus.Void;
dbContext.AuditLogs.Add(new AuditLog
{
TenantId = actor.TenantId,
ActorUserId = actor.UserId,
@@ -439,16 +446,20 @@ internal sealed class TenantBillingService(
public async Task<IReadOnlyCollection<PlatformBillingRefund>> GetRefundsAsync(
TenantBillingActor actor,
int limit,
CancellationToken cancellationToken = default) =>
await dbContext.PlatformBillingRefunds.AsNoTracking()
CancellationToken cancellationToken = default)
{
return await dbContext.PlatformBillingRefunds.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)
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 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,
@@ -465,39 +476,53 @@ internal sealed class TenantBillingService(
ReadLongDictionary(quote.LimitSnapshot));
}
private async Task<PlatformBillingOrderView> LoadOrderAsync(Guid tenantId, string orderNo, CancellationToken cancellationToken)
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 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))
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);
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))
private async Task<PlatformBillingQuoteItemView[]> LoadQuoteItemsAsync(Guid tenantId, Guid quoteId,
CancellationToken cancellationToken)
{
return 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)
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)
.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];
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)
@@ -516,27 +541,33 @@ internal sealed class TenantBillingService(
features);
}
private async Task<SaasOfferingVersionItem[]> LoadPublishedVersionsAsync(DateTimeOffset now, CancellationToken cancellationToken)
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 })
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);
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)))
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();
}
@@ -544,23 +575,44 @@ internal sealed class TenantBillingService(
{
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/integrations/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 PlatformBillingPaymentView ToPaymentView(PlatformBillingPayment value)
{
return new PlatformBillingPaymentView(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 Number(string prefix)
{
return $"{prefix}{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Guid.NewGuid():N}"[..32];
}
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 string Required(string? value, string field)
{
return string.IsNullOrWhiteSpace(value) ? throw Error($"{field} is required.", "required_field") : value.Trim();
}
private static string NormalizeProvider(string value)
{
return Required(value, "provider").ToLowerInvariant().Replace('-', '_');
}
private static PlatformBillingException Error(string message, string code)
{
return new PlatformBillingException(message, code);
}
private static string[] ReadStringArray(JsonElement value)
{
return 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)
{
@@ -568,4 +620,4 @@ internal sealed class TenantBillingService(
return value.EnumerateObject().Where(property => property.Value.TryGetInt64(out _))
.ToDictionary(property => property.Name, property => property.Value.GetInt64(), StringComparer.Ordinal);
}
}
}