Files
tiku-backend.net/Tiku.Infrastructure/PlatformBilling/PlatformBillingSettlementService.cs
xiong 33375a38d7
Some checks failed
ci / release-gate (push) Has been cancelled
refactor(architecture): harden module boundaries
2026-08-04 12:10:36 +08:00

249 lines
12 KiB
C#

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.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.PlatformBilling;
internal sealed class PlatformBillingSettlementService(
IPlatformControlPlanePersistence platformControlPlanePersistence,
ITenancyPersistence tenancyPersistence,
ITenantAdministrationPersistence tenantAdministrationPersistence,
IJobsOperationsPersistence jobsOperationsPersistence,
ITenantFeatureCacheInvalidator featureCacheInvalidator) : IPlatformBillingSettlementService
{
public async Task<PlatformBillingPayment> MarkPaidAsync(
Guid paymentId,
string providerEventId,
string eventType,
string? providerTradeNo,
DateTimeOffset paidAt,
JsonElement payload,
Guid? actorUserId,
CancellationToken cancellationToken = default)
{
var payment =
await platformControlPlanePersistence.PlatformBillingPayments.SingleOrDefaultAsync(value => value.Id == paymentId,
cancellationToken)
?? throw Error("Platform billing payment was not found.", "platform_billing_payment_not_found");
if (await platformControlPlanePersistence.PlatformBillingPaymentEvents.AnyAsync(value =>
value.TenantId == payment.TenantId &&
value.Provider == payment.Provider &&
value.ProviderEventId == providerEventId, cancellationToken))
return payment;
var order = await platformControlPlanePersistence.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");
platformControlPlanePersistence.PlatformBillingPaymentEvents.Add(new PlatformBillingPaymentEvent
{
TenantId = payment.TenantId,
PaymentId = payment.Id,
Provider = payment.Provider,
ProviderEventId = providerEventId,
EventType = eventType,
Payload = payload.ValueKind == JsonValueKind.Undefined ? JsonDefaults.Object() : payload.Clone()
});
if (payment.Status == PlatformBillingPaymentStatus.Succeeded)
{
await platformControlPlanePersistence.SaveChangesAsync(cancellationToken);
return payment;
}
if (payment.Status != PlatformBillingPaymentStatus.Pending ||
order.Status != PlatformBillingOrderStatus.PendingPayment)
throw Error("Payment or order status does not allow settlement.",
"platform_billing_payment_status_invalid");
payment.Status = PlatformBillingPaymentStatus.Succeeded;
payment.ProviderTradeNo = providerTradeNo;
payment.PaidAt = paidAt;
order.Status = PlatformBillingOrderStatus.Paid;
order.PaidAt = paidAt;
var tenant = await tenancyPersistence.Tenants.SingleAsync(value => value.Id == order.TenantId, cancellationToken);
tenant.BillingStatus = BillingStatus.Active;
var orderItems = await platformControlPlanePersistence.PlatformBillingOrderItems.AsNoTracking()
.Where(value => value.TenantId == order.TenantId && value.OrderId == order.Id)
.ToArrayAsync(cancellationToken);
var baseItem = orderItems.SingleOrDefault(value => value.ItemType == PlatformBillingItemType.BasePlan)
?? throw Error("Order base plan item is missing.", "platform_billing_base_plan_missing");
var baseVersion = await platformControlPlanePersistence.SaasOfferingVersions.AsNoTracking()
.SingleAsync(value => value.Id == baseItem.OfferingVersionId, cancellationToken);
var subscription = await platformControlPlanePersistence.TenantSaasSubscriptions
.OrderByDescending(value => value.UpdatedAt)
.FirstOrDefaultAsync(value => value.TenantId == order.TenantId, cancellationToken);
var now = paidAt;
if (subscription is null)
{
subscription = new TenantSaasSubscription
{
TenantId = order.TenantId,
BaseOfferingVersionId = baseVersion.Id,
Status = TenantSaasSubscriptionStatus.Active,
StartsAt = now,
CurrentPeriodStart = now,
CurrentPeriodEnd = AddCycle(now, baseVersion.BillingCycle)
};
platformControlPlanePersistence.TenantSaasSubscriptions.Add(subscription);
}
else if (order.Purpose == PlatformBillingOrderPurpose.Renewal)
{
var periodStart = subscription.CurrentPeriodEnd > now ? subscription.CurrentPeriodEnd : now;
subscription.Status = TenantSaasSubscriptionStatus.Active;
subscription.CurrentPeriodStart = subscription.CurrentPeriodEnd > now
? subscription.CurrentPeriodStart
: now;
subscription.CurrentPeriodEnd = AddCycle(periodStart, baseVersion.BillingCycle);
subscription.CancelAtPeriodEnd = false;
subscription.CancelledAt = null;
}
else if (order.Purpose == PlatformBillingOrderPurpose.Downgrade)
{
subscription.ScheduledBaseOfferingVersionId = baseVersion.Id;
}
else
{
subscription.BaseOfferingVersionId = baseVersion.Id;
subscription.ScheduledBaseOfferingVersionId = null;
subscription.Status = TenantSaasSubscriptionStatus.Active;
subscription.CancelAtPeriodEnd = false;
subscription.CancelledAt = null;
if (subscription.CurrentPeriodEnd <= now)
{
subscription.CurrentPeriodStart = now;
subscription.CurrentPeriodEnd = AddCycle(now, baseVersion.BillingCycle);
}
}
if (order.Purpose != PlatformBillingOrderPurpose.Downgrade)
{
var existingItems = await platformControlPlanePersistence.TenantSaasSubscriptionItems
.Where(value => value.TenantId == order.TenantId && value.SubscriptionId == subscription.Id &&
value.Status == TenantSaasSubscriptionItemStatus.Active)
.ToArrayAsync(cancellationToken);
foreach (var existing in existingItems)
{
existing.Status = TenantSaasSubscriptionItemStatus.Cancelled;
existing.EndsAt = now > existing.StartsAt ? now : existing.StartsAt.AddTicks(1);
}
platformControlPlanePersistence.TenantSaasSubscriptionItems.AddRange(orderItems.Select(item => new TenantSaasSubscriptionItem
{
TenantId = order.TenantId,
SubscriptionId = subscription.Id,
OfferingVersionId = item.OfferingVersionId,
SourceOrderItemId = item.Id,
ItemType = item.ItemType == PlatformBillingItemType.BasePlan
? TenantSaasSubscriptionItemType.BasePlan
: TenantSaasSubscriptionItemType.AddOn,
Status = TenantSaasSubscriptionItemStatus.Active,
StartsAt = now,
EndsAt = subscription.CurrentPeriodEnd
}));
}
else
{
var existingScheduledItems = await platformControlPlanePersistence.TenantSaasSubscriptionItems
.Where(value => value.TenantId == order.TenantId &&
value.SubscriptionId == subscription.Id &&
value.Status == TenantSaasSubscriptionItemStatus.Scheduled)
.ToArrayAsync(cancellationToken);
foreach (var existing in existingScheduledItems)
{
existing.Status = TenantSaasSubscriptionItemStatus.Cancelled;
existing.EndsAt = now > existing.StartsAt ? now : existing.StartsAt.AddTicks(1);
}
platformControlPlanePersistence.TenantSaasSubscriptionItems.AddRange(orderItems.Select(item => new TenantSaasSubscriptionItem
{
TenantId = order.TenantId,
SubscriptionId = subscription.Id,
OfferingVersionId = item.OfferingVersionId,
SourceOrderItemId = item.Id,
ItemType = item.ItemType == PlatformBillingItemType.BasePlan
? TenantSaasSubscriptionItemType.BasePlan
: TenantSaasSubscriptionItemType.AddOn,
Status = TenantSaasSubscriptionItemStatus.Scheduled,
StartsAt = subscription.CurrentPeriodEnd,
EndsAt = AddCycle(subscription.CurrentPeriodEnd, baseVersion.BillingCycle)
}));
}
var invoice = await platformControlPlanePersistence.PlatformBillingInvoices.SingleOrDefaultAsync(value =>
value.TenantId == order.TenantId && value.OrderId == order.Id, cancellationToken);
if (invoice is null)
{
invoice = new PlatformBillingInvoice
{
TenantId = order.TenantId,
OrderId = order.Id,
InvoiceNo = $"SI{now:yyyyMMddHHmmss}{Random.Shared.Next(1000, 9999)}",
TotalAmountCents = order.TotalAmountCents,
Currency = order.Currency,
IssuedAt = now,
BillingProfileSnapshot = await LoadBillingProfileSnapshotAsync(order.TenantId, cancellationToken)
};
platformControlPlanePersistence.PlatformBillingInvoices.Add(invoice);
}
invoice.Status = PlatformBillingInvoiceStatus.Paid;
invoice.PaidAt = now;
jobsOperationsPersistence.AuditLogs.Add(new AuditLog
{
TenantId = order.TenantId,
ActorUserId = actorUserId,
Action = "platform_billing.payment.settled",
TargetType = "platform_billing_orders",
TargetId = order.Id.ToString(),
Details = JsonSerializer.SerializeToElement(new
{ order.OrderNo, payment.PaymentNo, payment.Provider, order.Purpose })
});
await platformControlPlanePersistence.SaveChangesAsync(cancellationToken);
await featureCacheInvalidator.InvalidateAsync(order.TenantId, cancellationToken);
return payment;
}
private async Task<JsonElement> LoadBillingProfileSnapshotAsync(Guid tenantId, CancellationToken cancellationToken)
{
var profile = await tenantAdministrationPersistence.TenantBillingProfiles.AsNoTracking()
.SingleOrDefaultAsync(value => value.TenantId == tenantId, cancellationToken);
return profile is null
? JsonDefaults.Object()
: JsonSerializer.SerializeToElement(new
{
profile.BillingName,
profile.TaxId,
profile.ContactName,
profile.ContactPhone,
profile.ContactEmail,
profile.InvoiceTitle,
profile.InvoiceType
});
}
private static DateTimeOffset AddCycle(DateTimeOffset start, PlatformBillingCycle cycle)
{
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)
{
return new PlatformBillingException(message, code);
}
}