138 lines
5.3 KiB
C#
138 lines
5.3 KiB
C#
using MediatR;
|
||
using Microsoft.Extensions.Logging;
|
||
using System.Text.Json;
|
||
using TakeoutSaaS.Application.App.Subscriptions.Commands;
|
||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||
|
||
namespace TakeoutSaaS.Application.App.Subscriptions.Handlers;
|
||
|
||
/// <summary>
|
||
/// 自动续费处理器:生成续费账单(幂等)。
|
||
/// </summary>
|
||
public sealed class ProcessAutoRenewalCommandHandler(
|
||
ISubscriptionRepository subscriptionRepository,
|
||
ITenantBillingRepository billingRepository,
|
||
IIdGenerator idGenerator,
|
||
ILogger<ProcessAutoRenewalCommandHandler> logger)
|
||
: IRequestHandler<ProcessAutoRenewalCommand, ProcessAutoRenewalResult>
|
||
{
|
||
/// <inheritdoc />
|
||
public async Task<ProcessAutoRenewalResult> Handle(ProcessAutoRenewalCommand request, CancellationToken cancellationToken)
|
||
{
|
||
// 1. 计算续费阈值时间
|
||
var now = DateTime.UtcNow;
|
||
var renewalThreshold = now.AddDays(request.RenewalDaysBeforeExpiry);
|
||
|
||
// 2. 查询候选订阅(含套餐)
|
||
var candidates = await subscriptionRepository.FindAutoRenewalCandidatesAsync(
|
||
now,
|
||
renewalThreshold,
|
||
cancellationToken);
|
||
var createdBillCount = 0;
|
||
|
||
// 3. 遍历候选订阅,生成账单
|
||
foreach (var candidate in candidates)
|
||
{
|
||
// 3.1 幂等校验:同一周期开始时间只允许存在一张未取消账单
|
||
var periodStart = candidate.Subscription.EffectiveTo;
|
||
var exists = await billingRepository.ExistsNotCancelledByPeriodStartAsync(
|
||
candidate.Subscription.TenantId,
|
||
periodStart,
|
||
cancellationToken);
|
||
|
||
if (exists)
|
||
{
|
||
logger.LogInformation(
|
||
"自动续费:租户 {TenantId} 订阅 {SubscriptionId} 已存在周期 {PeriodStart} 的续费账单,跳过",
|
||
candidate.Subscription.TenantId,
|
||
candidate.Subscription.Id,
|
||
periodStart);
|
||
continue;
|
||
}
|
||
|
||
// 3.2 计算续费周期(月)
|
||
var durationMonths = CalculateDurationMonths(candidate.Subscription.EffectiveFrom, candidate.Subscription.EffectiveTo);
|
||
if (durationMonths <= 0)
|
||
{
|
||
durationMonths = 1;
|
||
}
|
||
|
||
// 3.3 计算账单周期与金额
|
||
var periodEnd = periodStart.AddMonths(durationMonths);
|
||
var amountDue = CalculateRenewalAmount(candidate.Package, durationMonths);
|
||
|
||
// 3.4 生成账单(Pending)
|
||
var statementNo = $"BILL-{now:yyyyMMddHHmmss}-{candidate.Subscription.TenantId}-{candidate.Subscription.Id}";
|
||
var lineItemsJson = JsonSerializer.Serialize(new
|
||
{
|
||
PackageName = candidate.Package.Name,
|
||
RenewalMonths = durationMonths,
|
||
SubscriptionId = candidate.Subscription.Id
|
||
});
|
||
|
||
await billingRepository.AddAsync(new TenantBillingStatement
|
||
{
|
||
Id = idGenerator.NextId(),
|
||
TenantId = candidate.Subscription.TenantId,
|
||
StatementNo = statementNo,
|
||
PeriodStart = periodStart,
|
||
PeriodEnd = periodEnd,
|
||
AmountDue = amountDue,
|
||
AmountPaid = 0,
|
||
DueDate = periodStart.AddDays(-1),
|
||
LineItemsJson = lineItemsJson,
|
||
CreatedAt = now
|
||
}, cancellationToken);
|
||
|
||
createdBillCount++;
|
||
logger.LogInformation(
|
||
"自动续费:为租户 {TenantId} 订阅 {SubscriptionId} 生成账单 {StatementNo},金额 {AmountDue},周期 {PeriodStart}~{PeriodEnd}",
|
||
candidate.Subscription.TenantId,
|
||
candidate.Subscription.Id,
|
||
statementNo,
|
||
amountDue,
|
||
periodStart,
|
||
periodEnd);
|
||
}
|
||
|
||
// 4. 保存账单变更
|
||
if (createdBillCount > 0)
|
||
{
|
||
await billingRepository.SaveChangesAsync(cancellationToken);
|
||
}
|
||
|
||
return new ProcessAutoRenewalResult
|
||
{
|
||
CandidateCount = candidates.Count,
|
||
CreatedBillCount = createdBillCount
|
||
};
|
||
}
|
||
|
||
private static int CalculateDurationMonths(DateTime effectiveFrom, DateTime effectiveTo)
|
||
{
|
||
// 1. 以年月差作为周期(月),兼容“按月续费”模型
|
||
var months = (effectiveTo.Year - effectiveFrom.Year) * 12 + effectiveTo.Month - effectiveFrom.Month;
|
||
|
||
// 2. 对不足 1 个月的情况兜底为 1
|
||
return months <= 0 ? 1 : months;
|
||
}
|
||
|
||
private static decimal CalculateRenewalAmount(TenantPackage package, int durationMonths)
|
||
{
|
||
// 1. 优先使用年付价(按整年计费),剩余月份按月付价补齐
|
||
var monthlyPrice = package.MonthlyPrice ?? 0m;
|
||
var yearlyPrice = package.YearlyPrice;
|
||
if (yearlyPrice is null || durationMonths < 12)
|
||
{
|
||
return monthlyPrice * durationMonths;
|
||
}
|
||
|
||
// 2. 按年 + 月组合计算金额
|
||
var years = durationMonths / 12;
|
||
var remainingMonths = durationMonths % 12;
|
||
return yearlyPrice.Value * years + monthlyPrice * remainingMonths;
|
||
}
|
||
}
|