Files
TakeoutSaaS.TenantApi/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ProcessAutoRenewalCommandHandler.cs

138 lines
5.3 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}