625 lines
32 KiB
C#
625 lines
32 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.Operations;
|
|
using Tiku.Domain.Platform;
|
|
using Tiku.Infrastructure.Persistence;
|
|
|
|
namespace Tiku.Infrastructure.PlatformBilling;
|
|
|
|
internal sealed class TenantBillingService(
|
|
IPlatformControlPlanePersistence platformControlPlanePersistence,
|
|
IJobsOperationsPersistence jobsOperationsPersistence,
|
|
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 platformControlPlanePersistence.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 platformControlPlanePersistence.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 platformControlPlanePersistence.SaasOfferingVersions.AsNoTracking()
|
|
join offering in platformControlPlanePersistence.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 platformControlPlanePersistence.SaasOfferingVersionFeatures.AsNoTracking()
|
|
.Where(value => requestedIds.Contains(value.OfferingVersionId))
|
|
.ToArrayAsync(cancellationToken);
|
|
var limitRows = await platformControlPlanePersistence.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)
|
|
};
|
|
platformControlPlanePersistence.PlatformBillingQuotes.Add(quote);
|
|
platformControlPlanePersistence.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 platformControlPlanePersistence.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 platformControlPlanePersistence.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 platformControlPlanePersistence.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 platformControlPlanePersistence.SaveChangesAsync(cancellationToken);
|
|
throw Error("Quote is no longer active.", "platform_billing_quote_expired");
|
|
}
|
|
|
|
var quoteItems = await platformControlPlanePersistence.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
|
|
})
|
|
};
|
|
platformControlPlanePersistence.PlatformBillingOrders.Add(order);
|
|
platformControlPlanePersistence.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 platformControlPlanePersistence.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 platformControlPlanePersistence.PlatformBillingPayments.AsNoTracking()
|
|
.SingleOrDefaultAsync(value => value.TenantId == actor.TenantId && value.IdempotencyKey == key,
|
|
cancellationToken);
|
|
if (existing is not null) return ToPaymentView(existing);
|
|
|
|
var order = await platformControlPlanePersistence.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 platformControlPlanePersistence.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
|
|
};
|
|
platformControlPlanePersistence.PlatformBillingPayments.Add(payment);
|
|
await platformControlPlanePersistence.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 platformControlPlanePersistence.SaveChangesAsync(cancellationToken);
|
|
return ToPaymentView(payment);
|
|
}
|
|
catch
|
|
{
|
|
payment.Status = PlatformBillingPaymentStatus.Failed;
|
|
await platformControlPlanePersistence.SaveChangesAsync(CancellationToken.None);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public async Task<IReadOnlyCollection<PlatformBillingOrderView>> GetOrdersAsync(
|
|
TenantBillingActor actor,
|
|
int limit,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var orderNos = await platformControlPlanePersistence.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)
|
|
{
|
|
return LoadOrderAsync(actor.TenantId, orderNo, cancellationToken);
|
|
}
|
|
|
|
public async Task<TenantSubscriptionView?> GetSubscriptionAsync(
|
|
TenantBillingActor actor,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var subscription = await platformControlPlanePersistence.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 platformControlPlanePersistence.TenantSaasSubscriptions.AsNoTracking()
|
|
.Where(value => value.TenantId == actor.TenantId)
|
|
.OrderByDescending(value => value.UpdatedAt)
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
var currentAmount = current is null
|
|
? 0
|
|
: await platformControlPlanePersistence.SaasOfferingVersions.AsNoTracking()
|
|
.Where(value => value.Id == current.BaseOfferingVersionId).Select(value => value.AmountCents)
|
|
.SingleAsync(cancellationToken);
|
|
var requestedAmount = await platformControlPlanePersistence.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 platformControlPlanePersistence.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 platformControlPlanePersistence.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 platformControlPlanePersistence.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 platformControlPlanePersistence.SaveChangesAsync(cancellationToken);
|
|
return await ToSubscriptionViewAsync(subscription, 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)
|
|
{
|
|
return await platformControlPlanePersistence.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)
|
|
{
|
|
return await platformControlPlanePersistence.PlatformBillingInvoices.AsNoTracking()
|
|
.Where(value => value.TenantId == actor.TenantId)
|
|
.OrderByDescending(value => value.CreatedAt)
|
|
.Take(Math.Clamp(limit, 1, 200))
|
|
.Select(value => new TenantReceivableView(
|
|
value.Id,
|
|
value.OrderId,
|
|
value.InvoiceNo,
|
|
value.Status,
|
|
value.TotalAmountCents,
|
|
value.Currency,
|
|
value.DueDate,
|
|
value.IssuedAt,
|
|
value.PaidAt))
|
|
.ToArrayAsync(cancellationToken);
|
|
}
|
|
|
|
public async Task<PlatformBillingOrderView> CancelOrderAsync(
|
|
TenantBillingActor actor,
|
|
string orderNo,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var normalized = Required(orderNo, "orderNo");
|
|
var order = await platformControlPlanePersistence.PlatformBillingOrders.SingleOrDefaultAsync(value =>
|
|
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 platformControlPlanePersistence.PlatformBillingInvoices
|
|
.Where(value => value.TenantId == actor.TenantId && value.OrderId == order.Id &&
|
|
(value.Status == PlatformBillingInvoiceStatus.Draft ||
|
|
value.Status == PlatformBillingInvoiceStatus.Issued))
|
|
.ToArrayAsync(cancellationToken);
|
|
foreach (var receivable in receivables) receivable.Status = PlatformBillingInvoiceStatus.Void;
|
|
jobsOperationsPersistence.AuditLogs.Add(new AuditLog
|
|
{
|
|
TenantId = actor.TenantId,
|
|
ActorUserId = actor.UserId,
|
|
Action = "platform_billing.order.cancelled",
|
|
TargetType = "platform_billing_orders",
|
|
TargetId = order.Id.ToString()
|
|
});
|
|
await platformControlPlanePersistence.SaveChangesAsync(cancellationToken);
|
|
return await LoadOrderAsync(actor.TenantId, normalized, cancellationToken);
|
|
}
|
|
|
|
public async Task<IReadOnlyCollection<PlatformBillingRefund>> GetRefundsAsync(
|
|
TenantBillingActor actor,
|
|
int limit,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return await platformControlPlanePersistence.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)
|
|
{
|
|
var quote = await platformControlPlanePersistence.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 platformControlPlanePersistence.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 platformControlPlanePersistence.PlatformBillingOrderItems.AsNoTracking()
|
|
join version in platformControlPlanePersistence.SaasOfferingVersions.AsNoTracking() on item.OfferingVersionId equals version
|
|
.Id
|
|
join offering in platformControlPlanePersistence.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)
|
|
{
|
|
return await (
|
|
from item in platformControlPlanePersistence.PlatformBillingQuoteItems.AsNoTracking()
|
|
join version in platformControlPlanePersistence.SaasOfferingVersions.AsNoTracking() on item.OfferingVersionId equals version
|
|
.Id
|
|
join offering in platformControlPlanePersistence.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 platformControlPlanePersistence.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 platformControlPlanePersistence.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 platformControlPlanePersistence.SaasOfferingVersions.AsNoTracking()
|
|
join offering in platformControlPlanePersistence.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 platformControlPlanePersistence.SaasOfferingVersionFeatures.AsNoTracking()
|
|
.Where(value => ids.Contains(value.OfferingVersionId)).ToArrayAsync(cancellationToken);
|
|
var limits = await platformControlPlanePersistence.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/integrations/platform-billing/callbacks/{provider}";
|
|
}
|
|
|
|
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)
|
|
{
|
|
return $"{prefix}{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Guid.NewGuid():N}"[..32];
|
|
}
|
|
|
|
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)
|
|
{
|
|
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);
|
|
}
|
|
}
|