forked from xiongyuxing/tiku-backend.net
233 lines
11 KiB
C#
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);
|
|
}
|