Files
tiku-backend.net/Tiku.Infrastructure/PlatformBilling/PlatformBillingSettlementService.cs

233 lines
11 KiB
C#

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