Files

506 lines
27 KiB
C#

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