feat: 完成账单管理模块后端功能开发及API优化

核心功能:
- 账单CRUD操作(创建、查询、详情、更新状态、删除)
- 支付记录管理(创建支付、审核支付)
- 批量操作支持(批量更新账单状态)
- 统计分析功能(账单统计、逾期账单查询)
- 导出功能(Excel/PDF/CSV)

API端点 (16个):
- GET /api/admin/v1/billings - 账单列表(分页、筛选、排序)
- POST /api/admin/v1/billings - 创建账单
- GET /api/admin/v1/billings/{id} - 账单详情
- DELETE /api/admin/v1/billings/{id} - 删除账单
- PUT /api/admin/v1/billings/{id}/status - 更新状态
- POST /api/admin/v1/billings/batch/status - 批量更新
- GET /api/admin/v1/billings/{id}/payments - 支付记录
- POST /api/admin/v1/billings/{id}/payments - 创建支付
- PUT /api/admin/v1/billings/payments/{paymentId}/verify - 审核支付
- GET /api/admin/v1/billings/statistics - 统计数据
- GET /api/admin/v1/billings/overdue - 逾期账单
- POST /api/admin/v1/billings/export - 导出账单

架构优化:
- 采用CQRS模式分离读写(MediatR + Dapper + EF Core)
- 完整的领域模型设计(TenantBillingStatement, TenantPayment等)
- FluentValidation请求验证
- 状态机管理账单和支付状态流转

API设计优化 (三项改进):
1. 导出API响应Content-Type改为application/octet-stream
2. 支付审核API添加Approved和Notes可选参数,支持通过/拒绝
3. 移除TenantBillings API中重复的TenantId参数

数据库变更:
- 新增账单相关表及关系
- 支持Snowflake ID主键
- 完整的审计字段支持

🤖 Generated with Claude Code

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-18 11:24:44 +08:00
parent 98f49ea7ad
commit 4b53862ded
73 changed files with 12688 additions and 305 deletions

View File

@@ -1,3 +1,4 @@
using System.Text.Json;
using TakeoutSaaS.Application.App.Billings.Dto;
using TakeoutSaaS.Domain.Tenants.Entities;
@@ -9,7 +10,7 @@ namespace TakeoutSaaS.Application.App.Billings;
internal static class BillingMapping
{
/// <summary>
/// 将账单实体映射为账单 DTO。
/// 将账单实体映射为账单 DTO(旧版)
/// </summary>
/// <param name="bill">账单实体。</param>
/// <param name="tenantName">租户名称。</param>
@@ -31,7 +32,43 @@ internal static class BillingMapping
};
/// <summary>
/// 将账单实体与支付记录映射为账单详情 DTO。
/// 将账单实体映射为账单列表 DTO(新版)
/// </summary>
/// <param name="billing">账单实体。</param>
/// <param name="tenantName">租户名称。</param>
/// <returns>账单列表 DTO。</returns>
public static BillingListDto ToBillingListDto(this TenantBillingStatement billing, string? tenantName = null)
=> new()
{
Id = billing.Id,
TenantId = billing.TenantId,
SubscriptionId = billing.SubscriptionId,
TenantName = tenantName ?? string.Empty,
StatementNo = billing.StatementNo,
BillingType = billing.BillingType,
Status = billing.Status,
PeriodStart = billing.PeriodStart,
PeriodEnd = billing.PeriodEnd,
AmountDue = billing.AmountDue,
AmountPaid = billing.AmountPaid,
DiscountAmount = billing.DiscountAmount,
TaxAmount = billing.TaxAmount,
TotalAmount = billing.CalculateTotalAmount(),
Currency = billing.Currency,
DueDate = billing.DueDate,
CreatedAt = billing.CreatedAt,
UpdatedAt = billing.UpdatedAt,
IsOverdue = billing.Status == TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus.Overdue
|| (billing.Status == TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus.Pending && billing.DueDate < DateTime.UtcNow),
OverdueDays = (billing.Status is TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus.Pending
or TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus.Overdue)
&& billing.DueDate < DateTime.UtcNow
? (int)(DateTime.UtcNow - billing.DueDate).TotalDays
: 0
};
/// <summary>
/// 将账单实体与支付记录映射为账单详情 DTO旧版
/// </summary>
/// <param name="bill">账单实体。</param>
/// <param name="payments">支付记录列表。</param>
@@ -59,7 +96,64 @@ internal static class BillingMapping
};
/// <summary>
/// 将支付记录实体映射为支付 DTO
/// 将账单实体与支付记录映射为账单详情 DTO新版
/// </summary>
/// <param name="billing">账单实体。</param>
/// <param name="payments">支付记录列表。</param>
/// <param name="tenantName">租户名称。</param>
/// <returns>账单详情 DTO。</returns>
public static BillingDetailDto ToBillingDetailDto(
this TenantBillingStatement billing,
List<TenantPayment> payments,
string? tenantName = null)
{
// 反序列化账单明细
var lineItems = new List<BillingLineItemDto>();
if (!string.IsNullOrWhiteSpace(billing.LineItemsJson))
{
try
{
lineItems = JsonSerializer.Deserialize<List<BillingLineItemDto>>(billing.LineItemsJson) ?? [];
}
catch
{
lineItems = [];
}
}
return new BillingDetailDto
{
Id = billing.Id,
TenantId = billing.TenantId,
TenantName = tenantName ?? string.Empty,
SubscriptionId = billing.SubscriptionId,
StatementNo = billing.StatementNo,
BillingType = billing.BillingType,
Status = billing.Status,
PeriodStart = billing.PeriodStart,
PeriodEnd = billing.PeriodEnd,
AmountDue = billing.AmountDue,
AmountPaid = billing.AmountPaid,
DiscountAmount = billing.DiscountAmount,
TaxAmount = billing.TaxAmount,
TotalAmount = billing.CalculateTotalAmount(),
Currency = billing.Currency,
DueDate = billing.DueDate,
ReminderSentAt = billing.ReminderSentAt,
OverdueNotifiedAt = billing.OverdueNotifiedAt,
LineItemsJson = billing.LineItemsJson,
LineItems = lineItems,
Payments = payments.Select(p => p.ToPaymentRecordDto()).ToList(),
Notes = billing.Notes,
CreatedAt = billing.CreatedAt,
CreatedBy = billing.CreatedBy,
UpdatedAt = billing.UpdatedAt,
UpdatedBy = billing.UpdatedBy
};
}
/// <summary>
/// 将支付记录实体映射为支付 DTO旧版
/// </summary>
/// <param name="payment">支付记录实体。</param>
/// <returns>支付 DTO。</returns>
@@ -77,4 +171,30 @@ internal static class BillingMapping
Notes = payment.Notes,
CreatedAt = payment.CreatedAt
};
/// <summary>
/// 将支付记录实体映射为支付记录 DTO新版
/// </summary>
/// <param name="payment">支付记录实体。</param>
/// <returns>支付记录 DTO。</returns>
public static PaymentRecordDto ToPaymentRecordDto(this TenantPayment payment)
=> new()
{
Id = payment.Id,
TenantId = payment.TenantId,
BillingId = payment.BillingStatementId,
Amount = payment.Amount,
Method = payment.Method,
Status = payment.Status,
TransactionNo = payment.TransactionNo,
ProofUrl = payment.ProofUrl,
IsVerified = payment.VerifiedAt.HasValue,
PaidAt = payment.PaidAt,
VerifiedBy = payment.VerifiedBy,
VerifiedAt = payment.VerifiedAt,
RefundReason = payment.RefundReason,
RefundedAt = payment.RefundedAt,
Notes = payment.Notes,
CreatedAt = payment.CreatedAt
};
}

View File

@@ -0,0 +1,25 @@
using MediatR;
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.Application.App.Billings.Commands;
/// <summary>
/// 批量更新账单状态命令。
/// </summary>
public sealed record BatchUpdateStatusCommand : IRequest<int>
{
/// <summary>
/// 账单 ID 列表(雪花算法)。
/// </summary>
public long[] BillingIds { get; init; } = [];
/// <summary>
/// 新状态。
/// </summary>
public TenantBillingStatus NewStatus { get; init; }
/// <summary>
/// 批量操作备注。
/// </summary>
public string? Notes { get; init; }
}

View File

@@ -0,0 +1,19 @@
using MediatR;
namespace TakeoutSaaS.Application.App.Billings.Commands;
/// <summary>
/// 取消账单命令。
/// </summary>
public sealed record CancelBillingCommand : IRequest<Unit>
{
/// <summary>
/// 账单 ID雪花算法
/// </summary>
public long BillingId { get; init; }
/// <summary>
/// 取消原因。
/// </summary>
public string Reason { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,41 @@
using MediatR;
using TakeoutSaaS.Application.App.Billings.Dto;
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.Application.App.Billings.Commands;
/// <summary>
/// 创建账单命令。
/// </summary>
public sealed record CreateBillingCommand : IRequest<BillingDetailDto>
{
/// <summary>
/// 租户 ID雪花算法
/// </summary>
public long TenantId { get; init; }
/// <summary>
/// 账单类型。
/// </summary>
public BillingType BillingType { get; init; }
/// <summary>
/// 应付金额。
/// </summary>
public decimal AmountDue { get; init; }
/// <summary>
/// 到期日UTC
/// </summary>
public DateTime DueDate { get; init; }
/// <summary>
/// 账单明细列表。
/// </summary>
public List<BillingLineItemDto> LineItems { get; init; } = [];
/// <summary>
/// 备注。
/// </summary>
public string? Notes { get; init; }
}

View File

@@ -0,0 +1,15 @@
using MediatR;
using TakeoutSaaS.Application.App.Billings.Dto;
namespace TakeoutSaaS.Application.App.Billings.Commands;
/// <summary>
/// 生成订阅账单命令(自动化场景)。
/// </summary>
public sealed record GenerateSubscriptionBillingCommand : IRequest<BillingDetailDto>
{
/// <summary>
/// 订阅 ID雪花算法
/// </summary>
public long SubscriptionId { get; init; }
}

View File

@@ -0,0 +1,10 @@
using MediatR;
namespace TakeoutSaaS.Application.App.Billings.Commands;
/// <summary>
/// 处理逾期账单命令(后台任务场景)。
/// </summary>
public sealed record ProcessOverdueBillingsCommand : IRequest<int>
{
}

View File

@@ -7,12 +7,12 @@ namespace TakeoutSaaS.Application.App.Billings.Commands;
/// <summary>
/// 记录支付命令。
/// </summary>
public sealed record RecordPaymentCommand : IRequest<PaymentDto>
public sealed record RecordPaymentCommand : IRequest<PaymentRecordDto>
{
/// <summary>
/// 账单 ID雪花算法
/// </summary>
public long BillId { get; init; }
public long BillingId { get; init; }
/// <summary>
/// 支付金额。
@@ -22,7 +22,7 @@ public sealed record RecordPaymentCommand : IRequest<PaymentDto>
/// <summary>
/// 支付方式。
/// </summary>
public PaymentMethod Method { get; init; }
public TenantPaymentMethod Method { get; init; }
/// <summary>
/// 交易号。

View File

@@ -0,0 +1,25 @@
using MediatR;
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.Application.App.Billings.Commands;
/// <summary>
/// 更新账单状态命令。
/// </summary>
public sealed record UpdateBillingStatusCommand : IRequest<Unit>
{
/// <summary>
/// 账单 ID雪花算法
/// </summary>
public long BillingId { get; init; }
/// <summary>
/// 新状态。
/// </summary>
public TenantBillingStatus NewStatus { get; init; }
/// <summary>
/// 备注。
/// </summary>
public string? Notes { get; init; }
}

View File

@@ -0,0 +1,28 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Billings.Dto;
namespace TakeoutSaaS.Application.App.Billings.Commands;
/// <summary>
/// 审核支付命令。
/// </summary>
public sealed record VerifyPaymentCommand : IRequest<PaymentRecordDto>
{
/// <summary>
/// 支付记录 ID雪花算法
/// </summary>
[Required]
public long PaymentId { get; init; }
/// <summary>
/// 是否通过审核。
/// </summary>
public bool Approved { get; init; }
/// <summary>
/// 审核备注(可选)。
/// </summary>
[MaxLength(512)]
public string? Notes { get; init; }
}

View File

@@ -0,0 +1,146 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Billings.Dto;
/// <summary>
/// 账单详情 DTO管理员端
/// </summary>
public sealed record BillingDetailDto
{
/// <summary>
/// 账单 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 租户名称。
/// </summary>
public string TenantName { get; init; } = string.Empty;
/// <summary>
/// 账单编号。
/// </summary>
public string StatementNo { get; init; } = string.Empty;
/// <summary>
/// 计费周期开始时间UTC
/// </summary>
public DateTime PeriodStart { get; init; }
/// <summary>
/// 计费周期结束时间UTC
/// </summary>
public DateTime PeriodEnd { get; init; }
/// <summary>
/// 账单类型。
/// </summary>
public BillingType BillingType { get; init; }
/// <summary>
/// 账单状态。
/// </summary>
public TenantBillingStatus Status { get; init; }
/// <summary>
/// 应付金额。
/// </summary>
public decimal AmountDue { get; init; }
/// <summary>
/// 已支付金额。
/// </summary>
public decimal AmountPaid { get; init; }
/// <summary>
/// 折扣金额。
/// </summary>
public decimal DiscountAmount { get; init; }
/// <summary>
/// 税费金额。
/// </summary>
public decimal TaxAmount { get; init; }
/// <summary>
/// 总金额(应付金额 - 折扣 + 税费)。
/// </summary>
public decimal TotalAmount { get; init; }
/// <summary>
/// 币种。
/// </summary>
public string Currency { get; init; } = "CNY";
/// <summary>
/// 到期日。
/// </summary>
public DateTime DueDate { get; init; }
/// <summary>
/// 订阅 ID可选
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? SubscriptionId { get; init; }
/// <summary>
/// 账单明细 JSON原始字符串
/// </summary>
public string? LineItemsJson { get; init; }
/// <summary>
/// 账单明细行项目。
/// </summary>
public IReadOnlyList<BillingLineItemDto> LineItems { get; init; } = [];
/// <summary>
/// 支付记录。
/// </summary>
public IReadOnlyList<PaymentRecordDto> Payments { get; init; } = [];
/// <summary>
/// 提醒发送时间。
/// </summary>
public DateTime? ReminderSentAt { get; init; }
/// <summary>
/// 逾期通知时间。
/// </summary>
public DateTime? OverdueNotifiedAt { get; init; }
/// <summary>
/// 备注。
/// </summary>
public string? Notes { get; init; }
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
/// <summary>
/// 创建人 ID。
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? CreatedBy { get; init; }
/// <summary>
/// 更新时间。
/// </summary>
public DateTime? UpdatedAt { get; init; }
/// <summary>
/// 更新人 ID。
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? UpdatedBy { get; init; }
}

View File

@@ -0,0 +1,545 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Billings.Dto.Legacy;
/// <summary>
/// 账单列表 DTO用于列表展示
/// </summary>
public sealed record BillingListDto
{
/// <summary>
/// 账单 ID雪花算法序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID雪花算法序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 租户名称。
/// </summary>
public string TenantName { get; init; } = string.Empty;
/// <summary>
/// 关联订阅 ID仅订阅/续费账单可能有值)。
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? SubscriptionId { get; init; }
/// <summary>
/// 账单编号。
/// </summary>
public string StatementNo { get; init; } = string.Empty;
/// <summary>
/// 账单类型。
/// </summary>
public BillingType BillingType { get; init; }
/// <summary>
/// 计费周期开始时间UTC
/// </summary>
public DateTime PeriodStart { get; init; }
/// <summary>
/// 计费周期结束时间UTC
/// </summary>
public DateTime PeriodEnd { get; init; }
/// <summary>
/// 应付金额。
/// </summary>
public decimal AmountDue { get; init; }
/// <summary>
/// 折扣金额。
/// </summary>
public decimal DiscountAmount { get; init; }
/// <summary>
/// 税费金额。
/// </summary>
public decimal TaxAmount { get; init; }
/// <summary>
/// 总金额(应付金额 - 折扣 + 税费)。
/// </summary>
public decimal TotalAmount { get; init; }
/// <summary>
/// 已付金额。
/// </summary>
public decimal AmountPaid { get; init; }
/// <summary>
/// 币种。
/// </summary>
public string Currency { get; init; } = "CNY";
/// <summary>
/// 账单状态。
/// </summary>
public TenantBillingStatus Status { get; init; }
/// <summary>
/// 到期日UTC
/// </summary>
public DateTime DueDate { get; init; }
/// <summary>
/// 是否已逾期(根据到期日与状态综合判断)。
/// </summary>
public bool IsOverdue { get; init; }
/// <summary>
/// 逾期天数(未逾期为 0
/// </summary>
public int OverdueDays { get; init; }
/// <summary>
/// 创建时间UTC
/// </summary>
public DateTime CreatedAt { get; init; }
/// <summary>
/// 更新时间UTC
/// </summary>
public DateTime? UpdatedAt { get; init; }
}
/// <summary>
/// 账单详情 DTO含明细项
/// </summary>
public sealed record BillingDetailDto
{
/// <summary>
/// 账单 ID雪花算法序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID雪花算法序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 租户名称。
/// </summary>
public string TenantName { get; init; } = string.Empty;
/// <summary>
/// 账单编号。
/// </summary>
public string StatementNo { get; init; } = string.Empty;
/// <summary>
/// 关联订阅 ID仅订阅/续费账单可能有值)。
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? SubscriptionId { get; init; }
/// <summary>
/// 账单类型。
/// </summary>
public BillingType BillingType { get; init; }
/// <summary>
/// 计费周期开始时间UTC
/// </summary>
public DateTime PeriodStart { get; init; }
/// <summary>
/// 计费周期结束时间UTC
/// </summary>
public DateTime PeriodEnd { get; init; }
/// <summary>
/// 应付金额。
/// </summary>
public decimal AmountDue { get; init; }
/// <summary>
/// 折扣金额。
/// </summary>
public decimal DiscountAmount { get; init; }
/// <summary>
/// 税费金额。
/// </summary>
public decimal TaxAmount { get; init; }
/// <summary>
/// 总金额(应付金额 - 折扣 + 税费)。
/// </summary>
public decimal TotalAmount { get; init; }
/// <summary>
/// 已付金额。
/// </summary>
public decimal AmountPaid { get; init; }
/// <summary>
/// 币种。
/// </summary>
public string Currency { get; init; } = "CNY";
/// <summary>
/// 账单状态。
/// </summary>
public TenantBillingStatus Status { get; init; }
/// <summary>
/// 到期日UTC
/// </summary>
public DateTime DueDate { get; init; }
/// <summary>
/// 账单明细 JSON。
/// </summary>
public string? LineItemsJson { get; init; }
/// <summary>
/// 账单明细列表(从 JSON 反序列化)。
/// </summary>
public IReadOnlyList<BillingLineItemDto> LineItems { get; init; } = [];
/// <summary>
/// 支付记录列表。
/// </summary>
public IReadOnlyList<PaymentRecordDto> Payments { get; init; } = [];
/// <summary>
/// 备注信息。
/// </summary>
public string? Notes { get; init; }
/// <summary>
/// 创建时间UTC
/// </summary>
public DateTime CreatedAt { get; init; }
/// <summary>
/// 更新时间UTC
/// </summary>
public DateTime? UpdatedAt { get; init; }
}
/// <summary>
/// 账单明细项 DTO。
/// </summary>
public sealed record BillingLineItemDto
{
/// <summary>
/// 明细类型(如:套餐费、配额包费用、其他费用)。
/// </summary>
public string ItemType { get; init; } = string.Empty;
/// <summary>
/// 描述。
/// </summary>
public string Description { get; init; } = string.Empty;
/// <summary>
/// 数量。
/// </summary>
public decimal Quantity { get; init; }
/// <summary>
/// 单价。
/// </summary>
public decimal UnitPrice { get; init; }
/// <summary>
/// 金额(数量 × 单价)。
/// </summary>
public decimal Amount { get; init; }
/// <summary>
/// 折扣率0-1
/// </summary>
public decimal? DiscountRate { get; init; }
}
/// <summary>
/// 支付记录 DTO。
/// </summary>
public sealed record PaymentRecordDto
{
/// <summary>
/// 支付记录 ID雪花算法序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID雪花算法序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 账单 ID雪花算法序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long BillingId { get; init; }
/// <summary>
/// 支付金额。
/// </summary>
public decimal Amount { get; init; }
/// <summary>
/// 支付方式。
/// </summary>
public TenantPaymentMethod Method { get; init; }
/// <summary>
/// 支付状态。
/// </summary>
public TenantPaymentStatus Status { get; init; }
/// <summary>
/// 支付流水号。
/// </summary>
public string? TransactionNo { get; init; }
/// <summary>
/// 支付凭证 URL。
/// </summary>
public string? ProofUrl { get; init; }
/// <summary>
/// 备注。
/// </summary>
public string? Notes { get; init; }
/// <summary>
/// 审核状态(待审核/已通过/已拒绝)。
/// </summary>
public bool IsVerified { get; init; }
/// <summary>
/// 审核人 ID雪花算法序列化为字符串
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? VerifiedBy { get; init; }
/// <summary>
/// 审核时间UTC
/// </summary>
public DateTime? VerifiedAt { get; init; }
/// <summary>
/// 退款原因。
/// </summary>
public string? RefundReason { get; init; }
/// <summary>
/// 退款时间UTC
/// </summary>
public DateTime? RefundedAt { get; init; }
/// <summary>
/// 支付时间UTC
/// </summary>
public DateTime? PaidAt { get; init; }
/// <summary>
/// 创建时间UTC
/// </summary>
public DateTime CreatedAt { get; init; }
}
/// <summary>
/// 账单统计 DTO。
/// </summary>
public sealed record BillingStatisticsDto
{
/// <summary>
/// 租户 ID为空表示跨租户统计
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? TenantId { get; init; }
/// <summary>
/// 统计周期开始时间UTC
/// </summary>
public DateTime StartDate { get; init; }
/// <summary>
/// 统计周期结束时间UTC
/// </summary>
public DateTime EndDate { get; init; }
/// <summary>
/// 分组方式Day/Week/Month
/// </summary>
public string GroupBy { get; init; } = "Day";
/// <summary>
/// 总账单数量。
/// </summary>
public int TotalCount { get; init; }
/// <summary>
/// 待付款账单数量。
/// </summary>
public int PendingCount { get; init; }
/// <summary>
/// 已付款账单数量。
/// </summary>
public int PaidCount { get; init; }
/// <summary>
/// 逾期账单数量。
/// </summary>
public int OverdueCount { get; init; }
/// <summary>
/// 已取消账单数量。
/// </summary>
public int CancelledCount { get; init; }
/// <summary>
/// 总应收金额(账单原始应付)。
/// </summary>
public decimal TotalAmountDue { get; init; }
/// <summary>
/// 总实收金额。
/// </summary>
public decimal TotalAmountPaid { get; init; }
/// <summary>
/// 总未收金额(总金额 - 实收)。
/// </summary>
public decimal TotalAmountUnpaid { get; init; }
/// <summary>
/// 逾期未收金额。
/// </summary>
public decimal TotalOverdueAmount { get; init; }
/// <summary>
/// 分组统计应收金额趋势Key 为分组起始日期 yyyy-MM-dd
/// </summary>
public Dictionary<string, decimal> AmountDueTrend { get; init; } = [];
/// <summary>
/// 分组统计实收金额趋势Key 为分组起始日期 yyyy-MM-dd
/// </summary>
public Dictionary<string, decimal> AmountPaidTrend { get; init; } = [];
/// <summary>
/// 分组统计账单数量趋势Key 为分组起始日期 yyyy-MM-dd
/// </summary>
public Dictionary<string, int> CountTrend { get; init; } = [];
}
/// <summary>
/// 账单导出 DTO。
/// </summary>
public sealed record BillingExportDto
{
/// <summary>
/// 账单 ID雪花算法序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID雪花算法序列化为字符串
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 租户名称。
/// </summary>
public string TenantName { get; init; } = string.Empty;
/// <summary>
/// 账单编号。
/// </summary>
public string StatementNo { get; init; } = string.Empty;
/// <summary>
/// 关联订阅 ID仅订阅/续费账单可能有值)。
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? SubscriptionId { get; init; }
/// <summary>
/// 账单类型。
/// </summary>
public BillingType BillingType { get; init; }
/// <summary>
/// 计费周期开始时间UTC
/// </summary>
public DateTime PeriodStart { get; init; }
/// <summary>
/// 计费周期结束时间UTC
/// </summary>
public DateTime PeriodEnd { get; init; }
/// <summary>
/// 应付金额。
/// </summary>
public decimal AmountDue { get; init; }
/// <summary>
/// 折扣金额。
/// </summary>
public decimal DiscountAmount { get; init; }
/// <summary>
/// 税费金额。
/// </summary>
public decimal TaxAmount { get; init; }
/// <summary>
/// 总金额。
/// </summary>
public decimal TotalAmount { get; init; }
/// <summary>
/// 已付金额。
/// </summary>
public decimal AmountPaid { get; init; }
/// <summary>
/// 账单状态。
/// </summary>
public TenantBillingStatus Status { get; init; }
/// <summary>
/// 币种。
/// </summary>
public string Currency { get; init; } = "CNY";
/// <summary>
/// 到期日UTC
/// </summary>
public DateTime DueDate { get; init; }
/// <summary>
/// 备注信息。
/// </summary>
public string? Notes { get; init; }
/// <summary>
/// 账单明细列表。
/// </summary>
public List<BillingLineItemDto> LineItems { get; init; } = [];
}

View File

@@ -0,0 +1,104 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Billings.Dto;
/// <summary>
/// 账单导出 DTO。
/// </summary>
public sealed record BillingExportDto
{
/// <summary>
/// 账单 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 租户名称。
/// </summary>
public string TenantName { get; init; } = string.Empty;
/// <summary>
/// 账单编号。
/// </summary>
public string StatementNo { get; init; } = string.Empty;
/// <summary>
/// 订阅 ID可选
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? SubscriptionId { get; init; }
/// <summary>
/// 账单类型。
/// </summary>
public BillingType BillingType { get; init; }
/// <summary>
/// 账单状态。
/// </summary>
public TenantBillingStatus Status { get; init; }
/// <summary>
/// 计费周期开始时间UTC
/// </summary>
public DateTime PeriodStart { get; init; }
/// <summary>
/// 计费周期结束时间UTC
/// </summary>
public DateTime PeriodEnd { get; init; }
/// <summary>
/// 应付金额。
/// </summary>
public decimal AmountDue { get; init; }
/// <summary>
/// 折扣金额。
/// </summary>
public decimal DiscountAmount { get; init; }
/// <summary>
/// 税费金额。
/// </summary>
public decimal TaxAmount { get; init; }
/// <summary>
/// 总金额(应付金额 - 折扣 + 税费)。
/// </summary>
public decimal TotalAmount { get; init; }
/// <summary>
/// 已支付金额。
/// </summary>
public decimal AmountPaid { get; init; }
/// <summary>
/// 币种。
/// </summary>
public string Currency { get; init; } = "CNY";
/// <summary>
/// 到期日UTC
/// </summary>
public DateTime DueDate { get; init; }
/// <summary>
/// 备注。
/// </summary>
public string? Notes { get; init; }
/// <summary>
/// 账单明细。
/// </summary>
public IReadOnlyList<BillingLineItemDto> LineItems { get; init; } = [];
}

View File

@@ -0,0 +1,37 @@
namespace TakeoutSaaS.Application.App.Billings.Dto;
/// <summary>
/// 账单明细行项目 DTO。
/// </summary>
public sealed record BillingLineItemDto
{
/// <summary>
/// 明细类型(如:订阅费、配额包费用、其他费用)。
/// </summary>
public string ItemType { get; init; } = string.Empty;
/// <summary>
/// 描述。
/// </summary>
public string Description { get; init; } = string.Empty;
/// <summary>
/// 数量。
/// </summary>
public decimal Quantity { get; init; }
/// <summary>
/// 单价。
/// </summary>
public decimal UnitPrice { get; init; }
/// <summary>
/// 金额(数量 × 单价)。
/// </summary>
public decimal Amount { get; init; }
/// <summary>
/// 折扣率0-1可选
/// </summary>
public decimal? DiscountRate { get; init; }
}

View File

@@ -0,0 +1,114 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Billings.Dto;
/// <summary>
/// 账单列表 DTO管理员端列表展示
/// </summary>
public sealed record BillingListDto
{
/// <summary>
/// 账单 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 租户名称。
/// </summary>
public string TenantName { get; init; } = string.Empty;
/// <summary>
/// 订阅 ID可选
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? SubscriptionId { get; init; }
/// <summary>
/// 账单编号。
/// </summary>
public string StatementNo { get; init; } = string.Empty;
/// <summary>
/// 计费周期开始时间UTC
/// </summary>
public DateTime PeriodStart { get; init; }
/// <summary>
/// 计费周期结束时间UTC
/// </summary>
public DateTime PeriodEnd { get; init; }
/// <summary>
/// 账单类型。
/// </summary>
public BillingType BillingType { get; init; }
/// <summary>
/// 账单状态。
/// </summary>
public TenantBillingStatus Status { get; init; }
/// <summary>
/// 应付金额。
/// </summary>
public decimal AmountDue { get; init; }
/// <summary>
/// 已支付金额。
/// </summary>
public decimal AmountPaid { get; init; }
/// <summary>
/// 折扣金额。
/// </summary>
public decimal DiscountAmount { get; init; }
/// <summary>
/// 税费金额。
/// </summary>
public decimal TaxAmount { get; init; }
/// <summary>
/// 总金额(应付金额 - 折扣 + 税费)。
/// </summary>
public decimal TotalAmount { get; init; }
/// <summary>
/// 币种。
/// </summary>
public string Currency { get; init; } = "CNY";
/// <summary>
/// 到期日。
/// </summary>
public DateTime DueDate { get; init; }
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
/// <summary>
/// 更新时间。
/// </summary>
public DateTime? UpdatedAt { get; init; }
/// <summary>
/// 是否逾期。
/// </summary>
public bool IsOverdue { get; init; }
/// <summary>
/// 逾期天数(未逾期为 0
/// </summary>
public int OverdueDays { get; init; }
}

View File

@@ -0,0 +1,91 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Billings.Dto;
/// <summary>
/// 账单统计数据 DTO。
/// </summary>
public sealed record BillingStatisticsDto
{
/// <summary>
/// 租户 ID可选管理员可跨租户统计
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? TenantId { get; init; }
/// <summary>
/// 统计开始时间UTC
/// </summary>
public DateTime StartDate { get; init; }
/// <summary>
/// 统计结束时间UTC
/// </summary>
public DateTime EndDate { get; init; }
/// <summary>
/// 分组方式Day/Week/Month
/// </summary>
public string GroupBy { get; init; } = "Day";
/// <summary>
/// 总账单数量。
/// </summary>
public int TotalCount { get; init; }
/// <summary>
/// 待支付账单数量。
/// </summary>
public int PendingCount { get; init; }
/// <summary>
/// 已支付账单数量。
/// </summary>
public int PaidCount { get; init; }
/// <summary>
/// 逾期账单数量。
/// </summary>
public int OverdueCount { get; init; }
/// <summary>
/// 已取消账单数量。
/// </summary>
public int CancelledCount { get; init; }
/// <summary>
/// 总应收金额。
/// </summary>
public decimal TotalAmountDue { get; init; }
/// <summary>
/// 已收金额。
/// </summary>
public decimal TotalAmountPaid { get; init; }
/// <summary>
/// 未收金额。
/// </summary>
public decimal TotalAmountUnpaid { get; init; }
/// <summary>
/// 逾期金额。
/// </summary>
public decimal TotalOverdueAmount { get; init; }
/// <summary>
/// 应收金额趋势Key 为日期桶字符串)。
/// </summary>
public IReadOnlyDictionary<string, decimal> AmountDueTrend { get; init; } = new Dictionary<string, decimal>();
/// <summary>
/// 实收金额趋势Key 为日期桶字符串)。
/// </summary>
public IReadOnlyDictionary<string, decimal> AmountPaidTrend { get; init; } = new Dictionary<string, decimal>();
/// <summary>
/// 数量趋势Key 为日期桶字符串)。
/// </summary>
public IReadOnlyDictionary<string, int> CountTrend { get; init; } = new Dictionary<string, int>();
}

View File

@@ -0,0 +1,27 @@
namespace TakeoutSaaS.Application.App.Billings.Dto;
/// <summary>
/// 账单趋势数据点 DTO。
/// </summary>
public sealed record BillingTrendPointDto
{
/// <summary>
/// 分组时间点Day/Week/Month 对齐后的时间)。
/// </summary>
public DateTime Period { get; init; }
/// <summary>
/// 账单数量。
/// </summary>
public int Count { get; init; }
/// <summary>
/// 应收金额。
/// </summary>
public decimal AmountDue { get; init; }
/// <summary>
/// 实收金额。
/// </summary>
public decimal AmountPaid { get; init; }
}

View File

@@ -29,12 +29,12 @@ public sealed record PaymentDto
/// <summary>
/// 支付方式。
/// </summary>
public PaymentMethod Method { get; init; }
public TenantPaymentMethod Method { get; init; }
/// <summary>
/// 支付状态。
/// </summary>
public PaymentStatus Status { get; init; }
public TenantPaymentStatus Status { get; init; }
/// <summary>
/// 交易号。

View File

@@ -0,0 +1,95 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Billings.Dto;
/// <summary>
/// 支付记录 DTO管理员端
/// </summary>
public sealed record PaymentRecordDto
{
/// <summary>
/// 支付记录 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 关联的账单 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long BillingId { get; init; }
/// <summary>
/// 支付金额。
/// </summary>
public decimal Amount { get; init; }
/// <summary>
/// 支付方式。
/// </summary>
public TenantPaymentMethod Method { get; init; }
/// <summary>
/// 支付状态。
/// </summary>
public TenantPaymentStatus Status { get; init; }
/// <summary>
/// 交易号。
/// </summary>
public string? TransactionNo { get; init; }
/// <summary>
/// 支付凭证 URL。
/// </summary>
public string? ProofUrl { get; init; }
/// <summary>
/// 支付时间。
/// </summary>
public DateTime? PaidAt { get; init; }
/// <summary>
/// 是否已审核。
/// </summary>
public bool IsVerified { get; init; }
/// <summary>
/// 审核人 ID。
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? VerifiedBy { get; init; }
/// <summary>
/// 审核时间。
/// </summary>
public DateTime? VerifiedAt { get; init; }
/// <summary>
/// 退款原因。
/// </summary>
public string? RefundReason { get; init; }
/// <summary>
/// 退款时间。
/// </summary>
public DateTime? RefundedAt { get; init; }
/// <summary>
/// 备注信息。
/// </summary>
public string? Notes { get; init; }
/// <summary>
/// 创建时间UTC
/// </summary>
public DateTime CreatedAt { get; init; }
}

View File

@@ -0,0 +1,88 @@
using MediatR;
using TakeoutSaaS.Application.App.Billings.Commands;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.App.Billings.Handlers;
/// <summary>
/// 批量更新账单状态处理器。
/// </summary>
public sealed class BatchUpdateStatusCommandHandler(
ITenantBillingRepository billingRepository)
: IRequestHandler<BatchUpdateStatusCommand, int>
{
/// <summary>
/// 处理批量更新账单状态请求。
/// </summary>
/// <param name="request">批量更新状态命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>成功更新的账单数量。</returns>
public async Task<int> Handle(BatchUpdateStatusCommand request, CancellationToken cancellationToken)
{
// 1. 参数验证
if (request.BillingIds.Length == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "账单 ID 列表不能为空");
}
// 2. 查询所有账单
var billings = await billingRepository.GetByIdsAsync(request.BillingIds, cancellationToken);
if (billings.Count == 0)
{
throw new BusinessException(ErrorCodes.NotFound, "未找到任何匹配的账单");
}
// 3. 批量更新状态
var now = DateTime.UtcNow;
var updatedCount = 0;
foreach (var billing in billings)
{
// 业务规则检查:某些状态转换可能不允许
if (CanTransitionStatus(billing.Status, request.NewStatus))
{
billing.Status = request.NewStatus;
billing.UpdatedAt = now;
if (!string.IsNullOrWhiteSpace(request.Notes))
{
billing.Notes = string.IsNullOrWhiteSpace(billing.Notes)
? $"[批量操作] {request.Notes}"
: $"{billing.Notes}\n[批量操作] {request.Notes}";
}
await billingRepository.UpdateAsync(billing, cancellationToken);
updatedCount++;
}
}
// 4. 持久化变更
await billingRepository.SaveChangesAsync(cancellationToken);
return updatedCount;
}
/// <summary>
/// 检查状态转换是否允许。
/// </summary>
private static bool CanTransitionStatus(
TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus currentStatus,
TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus newStatus)
{
// 已支付的账单不能改为其他状态
if (currentStatus == TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus.Paid
&& newStatus != TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus.Paid)
{
return false;
}
// 已取消的账单不能改为其他状态
if (currentStatus == TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus.Cancelled)
{
return false;
}
return true;
}
}

View File

@@ -0,0 +1,36 @@
using MediatR;
using TakeoutSaaS.Application.App.Billings.Commands;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.App.Billings.Handlers;
/// <summary>
/// 取消账单命令处理器。
/// </summary>
public sealed class CancelBillingCommandHandler(
ITenantBillingRepository billingRepository)
: IRequestHandler<CancelBillingCommand, Unit>
{
/// <inheritdoc />
public async Task<Unit> Handle(CancelBillingCommand request, CancellationToken cancellationToken)
{
// 1. 查询账单
var billing = await billingRepository.FindByIdAsync(request.BillingId, cancellationToken);
if (billing is null)
{
throw new BusinessException(ErrorCodes.NotFound, "账单不存在");
}
// 2. (空行后) 取消账单(领域规则校验在实体方法内)
billing.Cancel(request.Reason);
// 3. (空行后) 持久化
await billingRepository.UpdateAsync(billing, cancellationToken);
await billingRepository.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}

View File

@@ -0,0 +1,65 @@
using MediatR;
using System.Text.Json;
using TakeoutSaaS.Application.App.Billings.Commands;
using TakeoutSaaS.Application.App.Billings.Dto;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Ids;
namespace TakeoutSaaS.Application.App.Billings.Handlers;
/// <summary>
/// 创建账单命令处理器。
/// </summary>
public sealed class CreateBillingCommandHandler(
ITenantRepository tenantRepository,
ITenantBillingRepository billingRepository,
IIdGenerator idGenerator)
: IRequestHandler<CreateBillingCommand, BillingDetailDto>
{
/// <inheritdoc />
public async Task<BillingDetailDto> Handle(CreateBillingCommand request, CancellationToken cancellationToken)
{
// 1. 校验租户存在
var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken);
if (tenant is null)
{
throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
}
// 2. (空行后) 构建账单实体
var now = DateTime.UtcNow;
var statementNo = $"BIL-{now:yyyyMMdd}-{idGenerator.NextId()}";
var lineItemsJson = JsonSerializer.Serialize(request.LineItems);
var billing = new TenantBillingStatement
{
TenantId = request.TenantId,
StatementNo = statementNo,
BillingType = request.BillingType,
SubscriptionId = null,
PeriodStart = now,
PeriodEnd = now,
AmountDue = request.AmountDue,
DiscountAmount = 0m,
TaxAmount = 0m,
AmountPaid = 0m,
Currency = "CNY",
Status = TenantBillingStatus.Pending,
DueDate = request.DueDate,
LineItemsJson = lineItemsJson,
Notes = request.Notes
};
// 3. (空行后) 持久化账单
await billingRepository.AddAsync(billing, cancellationToken);
await billingRepository.SaveChangesAsync(cancellationToken);
// 4. (空行后) 返回详情 DTO
return billing.ToBillingDetailDto([], tenant.Name);
}
}

View File

@@ -0,0 +1,44 @@
using MediatR;
using TakeoutSaaS.Application.App.Billings.Queries;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Domain.Tenants.Services;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.App.Billings.Handlers;
/// <summary>
/// 导出账单处理器。
/// </summary>
public sealed class ExportBillingsQueryHandler(
ITenantBillingRepository billingRepository,
IBillingExportService exportService)
: IRequestHandler<ExportBillingsQuery, byte[]>
{
/// <inheritdoc />
public async Task<byte[]> Handle(ExportBillingsQuery request, CancellationToken cancellationToken)
{
// 1. 参数验证
if (request.BillingIds.Length == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "账单 ID 列表不能为空");
}
// 2. (空行后) 查询账单数据
var billings = await billingRepository.GetByIdsAsync(request.BillingIds, cancellationToken);
if (billings.Count == 0)
{
throw new BusinessException(ErrorCodes.NotFound, "未找到任何匹配的账单");
}
// 3. (空行后) 根据格式导出
var format = (request.Format ?? string.Empty).Trim().ToLowerInvariant();
return format switch
{
"excel" or "xlsx" => await exportService.ExportToExcelAsync(billings, cancellationToken),
"pdf" => await exportService.ExportToPdfAsync(billings, cancellationToken),
"csv" => await exportService.ExportToCsvAsync(billings, cancellationToken),
_ => throw new BusinessException(ErrorCodes.BadRequest, $"不支持的导出格式: {request.Format}")
};
}
}

View File

@@ -0,0 +1,102 @@
using MediatR;
using System.Text.Json;
using TakeoutSaaS.Application.App.Billings.Commands;
using TakeoutSaaS.Application.App.Billings.Dto;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Ids;
namespace TakeoutSaaS.Application.App.Billings.Handlers;
/// <summary>
/// 生成订阅账单命令处理器。
/// </summary>
public sealed class GenerateSubscriptionBillingCommandHandler(
ISubscriptionRepository subscriptionRepository,
ITenantBillingRepository billingRepository,
IIdGenerator idGenerator)
: IRequestHandler<GenerateSubscriptionBillingCommand, BillingDetailDto>
{
/// <inheritdoc />
public async Task<BillingDetailDto> Handle(GenerateSubscriptionBillingCommand request, CancellationToken cancellationToken)
{
// 1. 查询订阅详情(含租户/套餐信息)
var detail = await subscriptionRepository.GetDetailAsync(request.SubscriptionId, cancellationToken);
if (detail is null)
{
throw new BusinessException(ErrorCodes.NotFound, "订阅不存在");
}
// 2. (空行后) 校验套餐价格信息
var subscription = detail.Subscription;
var package = detail.Package;
if (package is null)
{
throw new BusinessException(ErrorCodes.BusinessError, "订阅未关联有效套餐,无法生成账单");
}
// 3. (空行后) 按订阅周期选择价格(简化规则:优先按年/按月)
var billingPeriodDays = (subscription.EffectiveTo - subscription.EffectiveFrom).TotalDays;
var amountDue = billingPeriodDays >= 300
? package.YearlyPrice
: package.MonthlyPrice;
if (!amountDue.HasValue)
{
throw new BusinessException(ErrorCodes.BusinessError, "套餐价格未配置,无法生成账单");
}
// 4. (空行后) 幂等校验:同一周期开始时间仅允许存在一张未取消账单
var exists = await billingRepository.ExistsNotCancelledByPeriodStartAsync(subscription.TenantId, subscription.EffectiveFrom, cancellationToken);
if (exists)
{
throw new BusinessException(ErrorCodes.Conflict, "该订阅周期的账单已存在");
}
// 5. (空行后) 构建账单实体
var now = DateTime.UtcNow;
var statementNo = $"BIL-{now:yyyyMMdd}-{idGenerator.NextId()}";
var lineItems = new List<BillingLineItemDto>
{
new()
{
ItemType = "Subscription",
Description = $"套餐 {package.Name} 订阅费用",
Quantity = 1,
UnitPrice = amountDue.Value,
Amount = amountDue.Value,
DiscountRate = null
}
};
var billing = new TenantBillingStatement
{
TenantId = subscription.TenantId,
StatementNo = statementNo,
BillingType = BillingType.Subscription,
SubscriptionId = subscription.Id,
PeriodStart = subscription.EffectiveFrom,
PeriodEnd = subscription.EffectiveTo,
AmountDue = amountDue.Value,
DiscountAmount = 0m,
TaxAmount = 0m,
AmountPaid = 0m,
Currency = "CNY",
Status = TenantBillingStatus.Pending,
DueDate = now.AddDays(7),
LineItemsJson = JsonSerializer.Serialize(lineItems),
Notes = subscription.Notes
};
// 6. (空行后) 持久化账单
await billingRepository.AddAsync(billing, cancellationToken);
await billingRepository.SaveChangesAsync(cancellationToken);
// 7. (空行后) 返回详情 DTO
return billing.ToBillingDetailDto([], detail.TenantName);
}
}

View File

@@ -28,6 +28,8 @@ public sealed class GetBillListQueryHandler(
request.Status,
request.StartDate,
request.EndDate,
null,
null,
request.Keyword,
request.PageNumber,
request.PageSize,

View File

@@ -0,0 +1,218 @@
using MediatR;
using System.Data;
using System.Data.Common;
using System.Text.Json;
using TakeoutSaaS.Application.App.Billings.Dto;
using TakeoutSaaS.Application.App.Billings.Queries;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Data;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.App.Billings.Handlers;
/// <summary>
/// 查询账单详情处理器。
/// </summary>
public sealed class GetBillingDetailQueryHandler(
IDapperExecutor dapperExecutor)
: IRequestHandler<GetBillingDetailQuery, BillingDetailDto>
{
/// <summary>
/// 处理查询账单详情请求。
/// </summary>
/// <param name="request">查询命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>账单详情 DTO。</returns>
public async Task<BillingDetailDto> Handle(GetBillingDetailQuery request, CancellationToken cancellationToken)
{
// 1. 查询账单 + 支付记录(同一连接,避免多次往返)
return await dapperExecutor.QueryAsync(
DatabaseConstants.AppDataSource,
DatabaseConnectionRole.Read,
async (connection, token) =>
{
// 1.1 查询账单
await using var billCommand = CreateCommand(
connection,
BuildBillingSql(),
[
("billingId", request.BillingId)
]);
await using var billReader = await billCommand.ExecuteReaderAsync(token);
if (!await billReader.ReadAsync(token))
{
throw new BusinessException(ErrorCodes.NotFound, "账单不存在");
}
DateTime? reminderSentAt = billReader.IsDBNull(15) ? null : billReader.GetDateTime(15);
DateTime? overdueNotifiedAt = billReader.IsDBNull(16) ? null : billReader.GetDateTime(16);
var notes = billReader.IsDBNull(17) ? null : billReader.GetString(17);
var lineItemsJson = billReader.IsDBNull(18) ? null : billReader.GetString(18);
long? createdBy = billReader.IsDBNull(20) ? null : billReader.GetInt64(20);
long? updatedBy = billReader.IsDBNull(22) ? null : billReader.GetInt64(22);
// 1.2 (空行后) 反序列化账单明细
var lineItems = new List<BillingLineItemDto>();
if (!string.IsNullOrWhiteSpace(lineItemsJson))
{
try
{
lineItems = JsonSerializer.Deserialize<List<BillingLineItemDto>>(lineItemsJson) ?? [];
}
catch
{
lineItems = [];
}
}
// 1.3 (空行后) 查询支付记录
var payments = new List<PaymentRecordDto>();
await using var paymentCommand = CreateCommand(
connection,
BuildPaymentsSql(),
[
("billingId", request.BillingId)
]);
await using var paymentReader = await paymentCommand.ExecuteReaderAsync(token);
while (await paymentReader.ReadAsync(token))
{
payments.Add(new PaymentRecordDto
{
Id = paymentReader.GetInt64(0),
TenantId = paymentReader.GetInt64(1),
BillingId = paymentReader.GetInt64(2),
Amount = paymentReader.GetDecimal(3),
Method = (TenantPaymentMethod)paymentReader.GetInt32(4),
Status = (TenantPaymentStatus)paymentReader.GetInt32(5),
TransactionNo = paymentReader.IsDBNull(6) ? null : paymentReader.GetString(6),
ProofUrl = paymentReader.IsDBNull(7) ? null : paymentReader.GetString(7),
Notes = paymentReader.IsDBNull(8) ? null : paymentReader.GetString(8),
VerifiedBy = paymentReader.IsDBNull(9) ? null : paymentReader.GetInt64(9),
VerifiedAt = paymentReader.IsDBNull(10) ? null : paymentReader.GetDateTime(10),
RefundReason = paymentReader.IsDBNull(11) ? null : paymentReader.GetString(11),
RefundedAt = paymentReader.IsDBNull(12) ? null : paymentReader.GetDateTime(12),
PaidAt = paymentReader.IsDBNull(13) ? null : paymentReader.GetDateTime(13),
IsVerified = !paymentReader.IsDBNull(10),
CreatedAt = paymentReader.GetDateTime(14)
});
}
// 1.4 (空行后) 组装详情 DTO
var amountDue = billReader.GetDecimal(9);
var discountAmount = billReader.GetDecimal(10);
var taxAmount = billReader.GetDecimal(11);
var totalAmount = amountDue - discountAmount + taxAmount;
return new BillingDetailDto
{
Id = billReader.GetInt64(0),
TenantId = billReader.GetInt64(1),
TenantName = billReader.IsDBNull(2) ? string.Empty : billReader.GetString(2),
SubscriptionId = billReader.IsDBNull(3) ? null : billReader.GetInt64(3),
StatementNo = billReader.GetString(4),
BillingType = (BillingType)billReader.GetInt32(5),
Status = (TenantBillingStatus)billReader.GetInt32(6),
PeriodStart = billReader.GetDateTime(7),
PeriodEnd = billReader.GetDateTime(8),
AmountDue = billReader.GetDecimal(9),
DiscountAmount = billReader.GetDecimal(10),
TaxAmount = billReader.GetDecimal(11),
TotalAmount = totalAmount,
AmountPaid = billReader.GetDecimal(12),
Currency = billReader.IsDBNull(13) ? "CNY" : billReader.GetString(13),
DueDate = billReader.GetDateTime(14),
ReminderSentAt = reminderSentAt,
OverdueNotifiedAt = overdueNotifiedAt,
LineItemsJson = lineItemsJson,
LineItems = lineItems,
Payments = payments,
Notes = notes,
CreatedAt = billReader.GetDateTime(19),
CreatedBy = createdBy,
UpdatedAt = billReader.IsDBNull(21) ? null : billReader.GetDateTime(21),
UpdatedBy = updatedBy
};
},
cancellationToken);
}
private static string BuildBillingSql()
{
return """
select
b."Id",
b."TenantId",
t."Name" as "TenantName",
b."SubscriptionId",
b."StatementNo",
b."BillingType",
b."Status",
b."PeriodStart",
b."PeriodEnd",
b."AmountDue",
b."DiscountAmount",
b."TaxAmount",
b."AmountPaid",
b."Currency",
b."DueDate",
b."ReminderSentAt",
b."OverdueNotifiedAt",
b."Notes",
b."LineItemsJson",
b."CreatedAt",
b."CreatedBy",
b."UpdatedAt",
b."UpdatedBy"
from public.tenant_billing_statements b
join public.tenants t on t."Id" = b."TenantId" and t."DeletedAt" is null
where b."DeletedAt" is null
and b."Id" = @billingId
limit 1;
""";
}
private static string BuildPaymentsSql()
{
return """
select
p."Id",
p."TenantId",
p."BillingStatementId",
p."Amount",
p."Method",
p."Status",
p."TransactionNo",
p."ProofUrl",
p."Notes",
p."VerifiedBy",
p."VerifiedAt",
p."RefundReason",
p."RefundedAt",
p."PaidAt",
p."CreatedAt"
from public.tenant_payments p
where p."DeletedAt" is null
and p."BillingStatementId" = @billingId
order by p."CreatedAt" desc;
""";
}
private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters)
{
var command = connection.CreateCommand();
command.CommandText = sql;
foreach (var (name, value) in parameters)
{
var p = command.CreateParameter();
p.ParameterName = name;
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);
}
return (DbCommand)command;
}
}

View File

@@ -0,0 +1,233 @@
using MediatR;
using System.Data;
using System.Data.Common;
using TakeoutSaaS.Application.App.Billings.Dto;
using TakeoutSaaS.Application.App.Billings.Queries;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Data;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Billings.Handlers;
/// <summary>
/// 分页查询账单列表处理器。
/// </summary>
public sealed class GetBillingListQueryHandler(
IDapperExecutor dapperExecutor)
: IRequestHandler<GetBillingListQuery, PagedResult<BillingListDto>>
{
/// <summary>
/// 处理分页查询账单列表请求。
/// </summary>
/// <param name="request">查询命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>分页账单列表 DTO。</returns>
public async Task<PagedResult<BillingListDto>> Handle(GetBillingListQuery request, CancellationToken cancellationToken)
{
// 1. 参数规范化
var page = request.PageNumber <= 0 ? 1 : request.PageNumber;
var pageSize = request.PageSize is <= 0 or > 200 ? 20 : request.PageSize;
var keyword = string.IsNullOrWhiteSpace(request.Keyword) ? null : request.Keyword.Trim();
var minAmount = request.MinAmount;
var maxAmount = request.MaxAmount;
var offset = (page - 1) * pageSize;
// 1.1 (空行后) 金额区间规范化(避免 min > max 导致结果为空)
if (minAmount.HasValue && maxAmount.HasValue && minAmount.Value > maxAmount.Value)
{
(minAmount, maxAmount) = (maxAmount, minAmount);
}
// 2. (空行后) 排序白名单(防 SQL 注入)
var orderBy = request.SortBy?.Trim() switch
{
"DueDate" => "b.\"DueDate\"",
"AmountDue" => "b.\"AmountDue\"",
"PeriodStart" => "b.\"PeriodStart\"",
"PeriodEnd" => "b.\"PeriodEnd\"",
"CreatedAt" => "b.\"CreatedAt\"",
_ => "b.\"CreatedAt\""
};
// 3. (空行后) 查询总数 + 列表
return await dapperExecutor.QueryAsync(
DatabaseConstants.AppDataSource,
DatabaseConnectionRole.Read,
async (connection, token) =>
{
// 3.1 统计总数
var total = await ExecuteScalarIntAsync(
connection,
BuildCountSql(),
[
("tenantId", request.TenantId),
("status", request.Status.HasValue ? (int)request.Status.Value : null),
("billingType", request.BillingType.HasValue ? (int)request.BillingType.Value : null),
("startDate", request.StartDate),
("endDate", request.EndDate),
("minAmount", minAmount),
("maxAmount", maxAmount),
("keyword", keyword)
],
token);
// 3.2 (空行后) 查询列表
var listSql = BuildListSql(orderBy, request.SortDesc);
await using var listCommand = CreateCommand(
connection,
listSql,
[
("tenantId", request.TenantId),
("status", request.Status.HasValue ? (int)request.Status.Value : null),
("billingType", request.BillingType.HasValue ? (int)request.BillingType.Value : null),
("startDate", request.StartDate),
("endDate", request.EndDate),
("minAmount", minAmount),
("maxAmount", maxAmount),
("keyword", keyword),
("offset", offset),
("limit", pageSize)
]);
await using var reader = await listCommand.ExecuteReaderAsync(token);
var now = DateTime.UtcNow;
var items = new List<BillingListDto>();
while (await reader.ReadAsync(token))
{
var dueDate = reader.GetDateTime(13);
var status = (TenantBillingStatus)reader.GetInt32(12);
var amountDue = reader.GetDecimal(8);
var discountAmount = reader.GetDecimal(9);
var taxAmount = reader.GetDecimal(10);
var totalAmount = amountDue - discountAmount + taxAmount;
// 3.2.1 (空行后) 逾期辅助字段
var isOverdue = status is TenantBillingStatus.Overdue
|| (status is TenantBillingStatus.Pending && dueDate < now);
var overdueDays = dueDate < now ? (int)(now - dueDate).TotalDays : 0;
items.Add(new BillingListDto
{
Id = reader.GetInt64(0),
TenantId = reader.GetInt64(1),
TenantName = reader.IsDBNull(2) ? string.Empty : reader.GetString(2),
SubscriptionId = reader.IsDBNull(3) ? null : reader.GetInt64(3),
StatementNo = reader.GetString(4),
BillingType = (BillingType)reader.GetInt32(5),
PeriodStart = reader.GetDateTime(6),
PeriodEnd = reader.GetDateTime(7),
AmountDue = amountDue,
DiscountAmount = discountAmount,
TaxAmount = taxAmount,
TotalAmount = totalAmount,
AmountPaid = reader.GetDecimal(11),
Currency = reader.IsDBNull(14) ? "CNY" : reader.GetString(14),
Status = status,
DueDate = dueDate,
IsOverdue = isOverdue,
OverdueDays = overdueDays,
CreatedAt = reader.GetDateTime(15),
UpdatedAt = reader.IsDBNull(16) ? null : reader.GetDateTime(16)
});
}
// 3.3 (空行后) 返回分页
return new PagedResult<BillingListDto>(items, page, pageSize, total);
},
cancellationToken);
}
private static string BuildCountSql()
{
return """
select count(*)
from public.tenant_billing_statements b
join public.tenants t on t."Id" = b."TenantId" and t."DeletedAt" is null
where b."DeletedAt" is null
and (@tenantId::bigint is null or b."TenantId" = @tenantId)
and (@status::int is null or b."Status" = @status)
and (@billingType::int is null or b."BillingType" = @billingType)
and (@startDate::timestamp with time zone is null or b."PeriodStart" >= @startDate)
and (@endDate::timestamp with time zone is null or b."PeriodEnd" <= @endDate)
and (@minAmount::numeric is null or b."AmountDue" >= @minAmount)
and (@maxAmount::numeric is null or b."AmountDue" <= @maxAmount)
and (
@keyword::text is null
or b."StatementNo" ilike ('%' || @keyword::text || '%')
or t."Name" ilike ('%' || @keyword::text || '%')
);
""";
}
private static string BuildListSql(string orderBy, bool sortDesc)
{
var direction = sortDesc ? "desc" : "asc";
return $"""
select
b."Id",
b."TenantId",
t."Name" as "TenantName",
b."SubscriptionId",
b."StatementNo",
b."BillingType",
b."PeriodStart",
b."PeriodEnd",
b."AmountDue",
b."DiscountAmount",
b."TaxAmount",
b."AmountPaid",
b."Status",
b."DueDate",
b."Currency",
b."CreatedAt",
b."UpdatedAt"
from public.tenant_billing_statements b
join public.tenants t on t."Id" = b."TenantId" and t."DeletedAt" is null
where b."DeletedAt" is null
and (@tenantId::bigint is null or b."TenantId" = @tenantId)
and (@status::int is null or b."Status" = @status)
and (@billingType::int is null or b."BillingType" = @billingType)
and (@startDate::timestamp with time zone is null or b."PeriodStart" >= @startDate)
and (@endDate::timestamp with time zone is null or b."PeriodEnd" <= @endDate)
and (@minAmount::numeric is null or b."AmountDue" >= @minAmount)
and (@maxAmount::numeric is null or b."AmountDue" <= @maxAmount)
and (
@keyword::text is null
or b."StatementNo" ilike ('%' || @keyword::text || '%')
or t."Name" ilike ('%' || @keyword::text || '%')
)
order by {orderBy} {direction}
offset @offset
limit @limit;
""";
}
private static async Task<int> ExecuteScalarIntAsync(
IDbConnection connection,
string sql,
(string Name, object? Value)[] parameters,
CancellationToken cancellationToken)
{
await using var command = CreateCommand(connection, sql, parameters);
var result = await command.ExecuteScalarAsync(cancellationToken);
return result is null or DBNull ? 0 : Convert.ToInt32(result);
}
private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters)
{
var command = connection.CreateCommand();
command.CommandText = sql;
foreach (var (name, value) in parameters)
{
var p = command.CreateParameter();
p.ParameterName = name;
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);
}
return (DbCommand)command;
}
}

View File

@@ -0,0 +1,139 @@
using MediatR;
using System.Data;
using System.Data.Common;
using TakeoutSaaS.Application.App.Billings.Dto;
using TakeoutSaaS.Application.App.Billings.Queries;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Data;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.Application.App.Billings.Handlers;
/// <summary>
/// 查询账单支付记录处理器。
/// </summary>
public sealed class GetBillingPaymentsQueryHandler(
IDapperExecutor dapperExecutor)
: IRequestHandler<GetBillingPaymentsQuery, List<PaymentRecordDto>>
{
/// <summary>
/// 处理查询账单支付记录请求。
/// </summary>
/// <param name="request">查询命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>支付记录列表 DTO。</returns>
public async Task<List<PaymentRecordDto>> Handle(GetBillingPaymentsQuery request, CancellationToken cancellationToken)
{
// 1. 校验账单是否存在
return await dapperExecutor.QueryAsync(
DatabaseConstants.AppDataSource,
DatabaseConnectionRole.Read,
async (connection, token) =>
{
// 1.1 校验账单存在
var exists = await ExecuteScalarIntAsync(
connection,
"""
select 1
from public.tenant_billing_statements b
where b."DeletedAt" is null
and b."Id" = @billingId
limit 1;
""",
[
("billingId", request.BillingId)
],
token);
if (exists == 0)
{
throw new BusinessException(ErrorCodes.NotFound, "账单不存在");
}
// 1.2 (空行后) 查询支付记录
await using var command = CreateCommand(
connection,
"""
select
p."Id",
p."TenantId",
p."BillingStatementId",
p."Amount",
p."Method",
p."Status",
p."TransactionNo",
p."ProofUrl",
p."Notes",
p."VerifiedBy",
p."VerifiedAt",
p."RefundReason",
p."RefundedAt",
p."PaidAt",
p."CreatedAt"
from public.tenant_payments p
where p."DeletedAt" is null
and p."BillingStatementId" = @billingId
order by p."CreatedAt" desc;
""",
[
("billingId", request.BillingId)
]);
await using var reader = await command.ExecuteReaderAsync(token);
var results = new List<PaymentRecordDto>();
while (await reader.ReadAsync(token))
{
results.Add(new PaymentRecordDto
{
Id = reader.GetInt64(0),
TenantId = reader.GetInt64(1),
BillingId = reader.GetInt64(2),
Amount = reader.GetDecimal(3),
Method = (TenantPaymentMethod)reader.GetInt32(4),
Status = (TenantPaymentStatus)reader.GetInt32(5),
TransactionNo = reader.IsDBNull(6) ? null : reader.GetString(6),
ProofUrl = reader.IsDBNull(7) ? null : reader.GetString(7),
Notes = reader.IsDBNull(8) ? null : reader.GetString(8),
VerifiedBy = reader.IsDBNull(9) ? null : reader.GetInt64(9),
VerifiedAt = reader.IsDBNull(10) ? null : reader.GetDateTime(10),
RefundReason = reader.IsDBNull(11) ? null : reader.GetString(11),
RefundedAt = reader.IsDBNull(12) ? null : reader.GetDateTime(12),
PaidAt = reader.IsDBNull(13) ? null : reader.GetDateTime(13),
IsVerified = !reader.IsDBNull(10),
CreatedAt = reader.GetDateTime(14)
});
}
return results;
},
cancellationToken);
}
private static async Task<int> ExecuteScalarIntAsync(
IDbConnection connection,
string sql,
(string Name, object? Value)[] parameters,
CancellationToken cancellationToken)
{
await using var command = CreateCommand(connection, sql, parameters);
var result = await command.ExecuteScalarAsync(cancellationToken);
return result is null or DBNull ? 0 : Convert.ToInt32(result);
}
private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters)
{
var command = connection.CreateCommand();
command.CommandText = sql;
foreach (var (name, value) in parameters)
{
var p = command.CreateParameter();
p.ParameterName = name;
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);
}
return (DbCommand)command;
}
}

View File

@@ -0,0 +1,187 @@
using MediatR;
using System.Data;
using System.Data.Common;
using TakeoutSaaS.Application.App.Billings.Dto;
using TakeoutSaaS.Application.App.Billings.Queries;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Data;
namespace TakeoutSaaS.Application.App.Billings.Handlers;
/// <summary>
/// 查询账单统计数据处理器。
/// </summary>
public sealed class GetBillingStatisticsQueryHandler(
IDapperExecutor dapperExecutor)
: IRequestHandler<GetBillingStatisticsQuery, BillingStatisticsDto>
{
/// <summary>
/// 处理查询账单统计数据请求。
/// </summary>
/// <param name="request">查询命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>账单统计数据 DTO。</returns>
public async Task<BillingStatisticsDto> Handle(GetBillingStatisticsQuery request, CancellationToken cancellationToken)
{
// 1. 参数规范化
var startDate = request.StartDate ?? DateTime.UtcNow.AddMonths(-1);
var endDate = request.EndDate ?? DateTime.UtcNow;
var groupBy = NormalizeGroupBy(request.GroupBy);
// 2. (空行后) 查询统计数据(总览 + 趋势)
return await dapperExecutor.QueryAsync(
DatabaseConstants.AppDataSource,
DatabaseConnectionRole.Read,
async (connection, token) =>
{
// 2.1 总览统计
await using var summaryCommand = CreateCommand(
connection,
BuildSummarySql(),
[
("tenantId", request.TenantId),
("startDate", startDate),
("endDate", endDate),
("now", DateTime.UtcNow)
]);
await using var summaryReader = await summaryCommand.ExecuteReaderAsync(token);
await summaryReader.ReadAsync(token);
var totalCount = summaryReader.IsDBNull(0) ? 0 : summaryReader.GetInt32(0);
var pendingCount = summaryReader.IsDBNull(1) ? 0 : summaryReader.GetInt32(1);
var paidCount = summaryReader.IsDBNull(2) ? 0 : summaryReader.GetInt32(2);
var overdueCount = summaryReader.IsDBNull(3) ? 0 : summaryReader.GetInt32(3);
var cancelledCount = summaryReader.IsDBNull(4) ? 0 : summaryReader.GetInt32(4);
var totalAmountDue = summaryReader.IsDBNull(5) ? 0m : summaryReader.GetDecimal(5);
var totalAmountPaid = summaryReader.IsDBNull(6) ? 0m : summaryReader.GetDecimal(6);
var totalAmountUnpaid = summaryReader.IsDBNull(7) ? 0m : summaryReader.GetDecimal(7);
var totalOverdueAmount = summaryReader.IsDBNull(8) ? 0m : summaryReader.GetDecimal(8);
// 2.2 (空行后) 趋势数据
await using var trendCommand = CreateCommand(
connection,
BuildTrendSql(groupBy),
[
("tenantId", request.TenantId),
("startDate", startDate),
("endDate", endDate)
]);
await using var trendReader = await trendCommand.ExecuteReaderAsync(token);
var amountDueTrend = new Dictionary<string, decimal>();
var amountPaidTrend = new Dictionary<string, decimal>();
var countTrend = new Dictionary<string, int>();
while (await trendReader.ReadAsync(token))
{
var bucket = trendReader.GetDateTime(0);
var key = bucket.ToString("yyyy-MM-dd");
amountDueTrend[key] = trendReader.IsDBNull(1) ? 0m : trendReader.GetDecimal(1);
amountPaidTrend[key] = trendReader.IsDBNull(2) ? 0m : trendReader.GetDecimal(2);
countTrend[key] = trendReader.IsDBNull(3) ? 0 : trendReader.GetInt32(3);
}
// 2.3 (空行后) 组装 DTO
return new BillingStatisticsDto
{
TenantId = request.TenantId,
StartDate = startDate,
EndDate = endDate,
GroupBy = groupBy,
TotalCount = totalCount,
PendingCount = pendingCount,
PaidCount = paidCount,
OverdueCount = overdueCount,
CancelledCount = cancelledCount,
TotalAmountDue = totalAmountDue,
TotalAmountPaid = totalAmountPaid,
TotalAmountUnpaid = totalAmountUnpaid,
TotalOverdueAmount = totalOverdueAmount,
AmountDueTrend = amountDueTrend,
AmountPaidTrend = amountPaidTrend,
CountTrend = countTrend
};
},
cancellationToken);
}
private static string NormalizeGroupBy(string? groupBy)
{
return groupBy?.Trim() switch
{
"Week" => "Week",
"Month" => "Month",
_ => "Day"
};
}
private static string BuildSummarySql()
{
return """
select
count(*)::int as "TotalCount",
coalesce(sum(case when b."Status" = 0 then 1 else 0 end), 0)::int as "PendingCount",
coalesce(sum(case when b."Status" = 1 then 1 else 0 end), 0)::int as "PaidCount",
coalesce(sum(case when b."Status" = 2 then 1 else 0 end), 0)::int as "OverdueCount",
coalesce(sum(case when b."Status" = 3 then 1 else 0 end), 0)::int as "CancelledCount",
coalesce(sum(b."AmountDue"), 0)::numeric as "TotalAmountDue",
coalesce(sum(b."AmountPaid"), 0)::numeric as "TotalAmountPaid",
coalesce(sum((b."AmountDue" - b."DiscountAmount" + b."TaxAmount") - b."AmountPaid"), 0)::numeric as "TotalAmountUnpaid",
coalesce(sum(
case
when b."Status" in (0, 2) and b."DueDate" < @now
then (b."AmountDue" - b."DiscountAmount" + b."TaxAmount") - b."AmountPaid"
else 0
end
), 0)::numeric as "TotalOverdueAmount"
from public.tenant_billing_statements b
where b."DeletedAt" is null
and (@tenantId::bigint is null or b."TenantId" = @tenantId)
and b."PeriodStart" >= @startDate
and b."PeriodEnd" <= @endDate;
""";
}
private static string BuildTrendSql(string groupBy)
{
var dateTrunc = groupBy switch
{
"Week" => "week",
"Month" => "month",
_ => "day"
};
return $"""
select
date_trunc('{dateTrunc}', b."PeriodStart") as "Bucket",
coalesce(sum(b."AmountDue"), 0)::numeric as "AmountDue",
coalesce(sum(b."AmountPaid"), 0)::numeric as "AmountPaid",
count(*)::int as "Count"
from public.tenant_billing_statements b
where b."DeletedAt" is null
and (@tenantId::bigint is null or b."TenantId" = @tenantId)
and b."PeriodStart" >= @startDate
and b."PeriodEnd" <= @endDate
group by 1
order by 1 asc;
""";
}
private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters)
{
var command = connection.CreateCommand();
command.CommandText = sql;
foreach (var (name, value) in parameters)
{
var p = command.CreateParameter();
p.ParameterName = name;
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);
}
return (DbCommand)command;
}
}

View File

@@ -0,0 +1,172 @@
using MediatR;
using System.Data;
using System.Data.Common;
using TakeoutSaaS.Application.App.Billings.Dto;
using TakeoutSaaS.Application.App.Billings.Queries;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Data;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Billings.Handlers;
/// <summary>
/// 查询逾期账单列表处理器。
/// </summary>
public sealed class GetOverdueBillingsQueryHandler(
IDapperExecutor dapperExecutor)
: IRequestHandler<GetOverdueBillingsQuery, PagedResult<BillingListDto>>
{
/// <summary>
/// 处理查询逾期账单列表请求。
/// </summary>
/// <param name="request">查询命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>分页逾期账单列表 DTO。</returns>
public async Task<PagedResult<BillingListDto>> Handle(GetOverdueBillingsQuery request, CancellationToken cancellationToken)
{
// 1. 参数规范化
var page = request.PageNumber <= 0 ? 1 : request.PageNumber;
var pageSize = request.PageSize is <= 0 or > 200 ? 20 : request.PageSize;
var offset = (page - 1) * pageSize;
var now = DateTime.UtcNow;
// 2. (空行后) 查询总数 + 列表
return await dapperExecutor.QueryAsync(
DatabaseConstants.AppDataSource,
DatabaseConnectionRole.Read,
async (connection, token) =>
{
// 2.1 统计总数
var total = await ExecuteScalarIntAsync(
connection,
BuildCountSql(),
[
("now", now)
],
token);
// 2.2 (空行后) 查询列表
await using var listCommand = CreateCommand(
connection,
BuildListSql(),
[
("now", now),
("offset", offset),
("limit", pageSize)
]);
await using var reader = await listCommand.ExecuteReaderAsync(token);
var items = new List<BillingListDto>();
while (await reader.ReadAsync(token))
{
var dueDate = reader.GetDateTime(13);
var status = (TenantBillingStatus)reader.GetInt32(12);
var amountDue = reader.GetDecimal(8);
var discountAmount = reader.GetDecimal(9);
var taxAmount = reader.GetDecimal(10);
var totalAmount = amountDue - discountAmount + taxAmount;
var overdueDays = dueDate < now ? (int)(now - dueDate).TotalDays : 0;
items.Add(new BillingListDto
{
Id = reader.GetInt64(0),
TenantId = reader.GetInt64(1),
TenantName = reader.IsDBNull(2) ? string.Empty : reader.GetString(2),
SubscriptionId = reader.IsDBNull(3) ? null : reader.GetInt64(3),
StatementNo = reader.GetString(4),
BillingType = (BillingType)reader.GetInt32(5),
PeriodStart = reader.GetDateTime(6),
PeriodEnd = reader.GetDateTime(7),
AmountDue = amountDue,
DiscountAmount = discountAmount,
TaxAmount = taxAmount,
TotalAmount = totalAmount,
AmountPaid = reader.GetDecimal(11),
Status = status,
DueDate = dueDate,
Currency = reader.IsDBNull(14) ? "CNY" : reader.GetString(14),
IsOverdue = true,
OverdueDays = overdueDays,
CreatedAt = reader.GetDateTime(15),
UpdatedAt = reader.IsDBNull(16) ? null : reader.GetDateTime(16)
});
}
// 2.3 (空行后) 返回分页
return new PagedResult<BillingListDto>(items, page, pageSize, total);
},
cancellationToken);
}
private static string BuildCountSql()
{
return """
select count(*)
from public.tenant_billing_statements b
join public.tenants t on t."Id" = b."TenantId" and t."DeletedAt" is null
where b."DeletedAt" is null
and b."DueDate" < @now
and b."Status" in (0, 2);
""";
}
private static string BuildListSql()
{
return """
select
b."Id",
b."TenantId",
t."Name" as "TenantName",
b."SubscriptionId",
b."StatementNo",
b."BillingType",
b."PeriodStart",
b."PeriodEnd",
b."AmountDue",
b."DiscountAmount",
b."TaxAmount",
b."AmountPaid",
b."Status",
b."DueDate",
b."Currency",
b."CreatedAt",
b."UpdatedAt"
from public.tenant_billing_statements b
join public.tenants t on t."Id" = b."TenantId" and t."DeletedAt" is null
where b."DeletedAt" is null
and b."DueDate" < @now
and b."Status" in (0, 2)
order by b."DueDate" asc
offset @offset
limit @limit;
""";
}
private static async Task<int> ExecuteScalarIntAsync(
IDbConnection connection,
string sql,
(string Name, object? Value)[] parameters,
CancellationToken cancellationToken)
{
await using var command = CreateCommand(connection, sql, parameters);
var result = await command.ExecuteScalarAsync(cancellationToken);
return result is null or DBNull ? 0 : Convert.ToInt32(result);
}
private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters)
{
var command = connection.CreateCommand();
command.CommandText = sql;
foreach (var (name, value) in parameters)
{
var p = command.CreateParameter();
p.ParameterName = name;
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);
}
return (DbCommand)command;
}
}

View File

@@ -0,0 +1,49 @@
using MediatR;
using TakeoutSaaS.Application.App.Billings.Commands;
using TakeoutSaaS.Domain.Tenants.Repositories;
namespace TakeoutSaaS.Application.App.Billings.Handlers;
/// <summary>
/// 处理逾期账单命令处理器(后台任务)。
/// </summary>
public sealed class ProcessOverdueBillingsCommandHandler(
ITenantBillingRepository billingRepository)
: IRequestHandler<ProcessOverdueBillingsCommand, int>
{
/// <inheritdoc />
public async Task<int> Handle(ProcessOverdueBillingsCommand request, CancellationToken cancellationToken)
{
// 1. 查询逾期账单(到期日已过且未支付)
var overdueBillings = await billingRepository.GetOverdueBillingsAsync(cancellationToken);
if (overdueBillings.Count == 0)
{
return 0;
}
// 2. (空行后) 标记为逾期并更新通知时间
var now = DateTime.UtcNow;
var updatedCount = 0;
foreach (var billing in overdueBillings)
{
var before = billing.Status;
billing.MarkAsOverdue();
if (before != billing.Status)
{
billing.OverdueNotifiedAt ??= now;
await billingRepository.UpdateAsync(billing, cancellationToken);
updatedCount++;
}
}
// 3. (空行后) 持久化
if (updatedCount > 0)
{
await billingRepository.SaveChangesAsync(cancellationToken);
}
return updatedCount;
}
}

View File

@@ -6,6 +6,7 @@ using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Ids;
namespace TakeoutSaaS.Application.App.Billings.Handlers;
@@ -14,8 +15,9 @@ namespace TakeoutSaaS.Application.App.Billings.Handlers;
/// </summary>
public sealed class RecordPaymentCommandHandler(
ITenantBillingRepository billingRepository,
ITenantPaymentRepository paymentRepository)
: IRequestHandler<RecordPaymentCommand, PaymentDto>
ITenantPaymentRepository paymentRepository,
IIdGenerator idGenerator)
: IRequestHandler<RecordPaymentCommand, PaymentRecordDto>
{
/// <summary>
/// 处理记录支付请求。
@@ -23,44 +25,57 @@ public sealed class RecordPaymentCommandHandler(
/// <param name="request">记录支付命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>支付 DTO。</returns>
public async Task<PaymentDto> Handle(RecordPaymentCommand request, CancellationToken cancellationToken)
public async Task<PaymentRecordDto> Handle(RecordPaymentCommand request, CancellationToken cancellationToken)
{
// 1. 查询账单
var bill = await billingRepository.FindByIdAsync(request.BillId, cancellationToken);
if (bill is null)
var billing = await billingRepository.FindByIdAsync(request.BillingId, cancellationToken);
if (billing is null)
{
throw new BusinessException(ErrorCodes.NotFound, "账单不存在");
}
// 2. 构建支付记录
// 2. (空行后) 业务规则检查
if (billing.Status == TenantBillingStatus.Paid)
{
throw new BusinessException(ErrorCodes.BusinessError, "已支付账单不允许重复收款");
}
if (billing.Status == TenantBillingStatus.Cancelled)
{
throw new BusinessException(ErrorCodes.BusinessError, "已取消账单不允许收款");
}
// 3. (空行后) 幂等校验:交易号唯一
if (!string.IsNullOrWhiteSpace(request.TransactionNo))
{
var exists = await paymentRepository.GetByTransactionNoAsync(request.TransactionNo.Trim(), cancellationToken);
if (exists is not null)
{
throw new BusinessException(ErrorCodes.Conflict, "交易号已存在,疑似重复提交");
}
}
// 4. (空行后) 构建支付记录(默认待审核)
var now = DateTime.UtcNow;
var payment = new TenantPayment
{
TenantId = bill.TenantId,
BillingStatementId = request.BillId,
Id = idGenerator.NextId(),
TenantId = billing.TenantId,
BillingStatementId = request.BillingId,
Amount = request.Amount,
Method = request.Method,
Status = PaymentStatus.Success,
TransactionNo = request.TransactionNo,
Status = TenantPaymentStatus.Pending,
TransactionNo = string.IsNullOrWhiteSpace(request.TransactionNo) ? null : request.TransactionNo.Trim(),
ProofUrl = request.ProofUrl,
PaidAt = DateTime.UtcNow,
PaidAt = now,
Notes = request.Notes
};
// 3. 更新账单已付金额
bill.AmountPaid += request.Amount;
// 4. 如果已付金额 >= 应付金额,标记为已支付
if (bill.AmountPaid >= bill.AmountDue)
{
bill.Status = TenantBillingStatus.Paid;
}
// 5. 持久化变更
// 5. (空行后) 持久化变更
await paymentRepository.AddAsync(payment, cancellationToken);
await billingRepository.UpdateAsync(bill, cancellationToken);
await paymentRepository.SaveChangesAsync(cancellationToken);
// 6. 返回 DTO
return payment.ToDto();
// 6. (空行后) 返回 DTO
return payment.ToPaymentRecordDto();
}
}

View File

@@ -0,0 +1,54 @@
using MediatR;
using TakeoutSaaS.Application.App.Billings.Commands;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.App.Billings.Handlers;
/// <summary>
/// 更新账单状态命令处理器。
/// </summary>
public sealed class UpdateBillingStatusCommandHandler(
ITenantBillingRepository billingRepository)
: IRequestHandler<UpdateBillingStatusCommand, Unit>
{
/// <inheritdoc />
public async Task<Unit> Handle(UpdateBillingStatusCommand request, CancellationToken cancellationToken)
{
// 1. 查询账单
var billing = await billingRepository.FindByIdAsync(request.BillingId, cancellationToken);
if (billing is null)
{
throw new BusinessException(ErrorCodes.NotFound, "账单不存在");
}
// 2. (空行后) 状态转换规则校验
if (billing.Status == TenantBillingStatus.Paid && request.NewStatus != TenantBillingStatus.Paid)
{
throw new BusinessException(ErrorCodes.BusinessError, "已支付账单不允许改为其他状态");
}
if (billing.Status == TenantBillingStatus.Cancelled)
{
throw new BusinessException(ErrorCodes.BusinessError, "已取消账单不允许变更状态");
}
// 3. (空行后) 更新状态与备注
billing.Status = request.NewStatus;
if (!string.IsNullOrWhiteSpace(request.Notes))
{
billing.Notes = string.IsNullOrWhiteSpace(billing.Notes)
? $"[状态变更] {request.Notes}"
: $"{billing.Notes}\n[状态变更] {request.Notes}";
}
// 4. (空行后) 持久化
await billingRepository.UpdateAsync(billing, cancellationToken);
await billingRepository.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}

View File

@@ -0,0 +1,73 @@
using MediatR;
using TakeoutSaaS.Application.App.Billings.Commands;
using TakeoutSaaS.Application.App.Billings.Dto;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Security;
namespace TakeoutSaaS.Application.App.Billings.Handlers;
/// <summary>
/// 审核支付命令处理器。
/// </summary>
public sealed class VerifyPaymentCommandHandler(
ITenantPaymentRepository paymentRepository,
ITenantBillingRepository billingRepository,
ICurrentUserAccessor currentUserAccessor)
: IRequestHandler<VerifyPaymentCommand, PaymentRecordDto>
{
/// <inheritdoc />
public async Task<PaymentRecordDto> Handle(VerifyPaymentCommand request, CancellationToken cancellationToken)
{
// 1. 校验操作者身份
if (!currentUserAccessor.IsAuthenticated || currentUserAccessor.UserId <= 0)
{
throw new BusinessException(ErrorCodes.Unauthorized, "未登录或无效的操作者身份");
}
// 2. (空行后) 查询支付记录
var payment = await paymentRepository.FindByIdAsync(request.PaymentId, cancellationToken);
if (payment is null)
{
throw new BusinessException(ErrorCodes.NotFound, "支付记录不存在");
}
// 3. (空行后) 查询关联账单
var billing = await billingRepository.FindByIdAsync(payment.BillingStatementId, cancellationToken);
if (billing is null)
{
throw new BusinessException(ErrorCodes.NotFound, "关联账单不存在");
}
// 4. (空行后) 归一化审核备注
var normalizedNotes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim();
// 5. (空行后) 根据审核结果更新支付与账单状态
if (request.Approved)
{
payment.Verify(currentUserAccessor.UserId);
payment.Notes = normalizedNotes;
billing.MarkAsPaid(payment.Amount, payment.TransactionNo ?? string.Empty);
}
else
{
payment.Reject(currentUserAccessor.UserId, normalizedNotes ?? string.Empty);
payment.Notes = normalizedNotes;
}
// 6. (空行后) 持久化更新状态
await paymentRepository.UpdateAsync(payment, cancellationToken);
if (request.Approved)
{
await billingRepository.UpdateAsync(billing, cancellationToken);
}
// 7. (空行后) 保存数据库更改
await paymentRepository.SaveChangesAsync(cancellationToken);
// 8. (空行后) 返回 DTO
return payment.ToPaymentRecordDto();
}
}

View File

@@ -0,0 +1,66 @@
using AutoMapper;
using System.Text.Json;
using TakeoutSaaS.Application.App.Billings.Dto;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.Application.App.Billings.Mappings;
/// <summary>
/// 账单模块 AutoMapper Profile。
/// </summary>
public sealed class BillingProfile : Profile
{
/// <summary>
/// 初始化映射配置。
/// </summary>
public BillingProfile()
{
// 1. 账单实体 -> 列表 DTO
CreateMap<TenantBillingStatement, BillingListDto>()
.ForMember(x => x.TenantName, opt => opt.Ignore())
.ForMember(x => x.TotalAmount, opt => opt.MapFrom(src => src.CalculateTotalAmount()))
.ForMember(x => x.IsOverdue, opt => opt.MapFrom(src =>
src.Status == TenantBillingStatus.Overdue
|| (src.Status == TenantBillingStatus.Pending && src.DueDate < DateTime.UtcNow)))
.ForMember(x => x.OverdueDays, opt => opt.MapFrom(src =>
src.DueDate < DateTime.UtcNow ? (int)(DateTime.UtcNow - src.DueDate).TotalDays : 0));
// 2. (空行后) 账单实体 -> 详情 DTO
CreateMap<TenantBillingStatement, BillingDetailDto>()
.ForMember(x => x.TenantName, opt => opt.Ignore())
.ForMember(x => x.TotalAmount, opt => opt.MapFrom(src => src.CalculateTotalAmount()))
.ForMember(x => x.LineItemsJson, opt => opt.MapFrom(src => src.LineItemsJson))
.ForMember(x => x.LineItems, opt => opt.MapFrom(src => DeserializeLineItems(src.LineItemsJson)))
.ForMember(x => x.Payments, opt => opt.Ignore());
// 3. (空行后) 账单实体 -> 导出 DTO
CreateMap<TenantBillingStatement, BillingExportDto>()
.ForMember(x => x.TenantName, opt => opt.Ignore())
.ForMember(x => x.TotalAmount, opt => opt.MapFrom(src => src.CalculateTotalAmount()))
.ForMember(x => x.LineItems, opt => opt.MapFrom(src => DeserializeLineItems(src.LineItemsJson)));
// 4. (空行后) 支付实体 -> 支付记录 DTO
CreateMap<TenantPayment, PaymentRecordDto>()
.ForMember(x => x.BillingId, opt => opt.MapFrom(src => src.BillingStatementId))
.ForMember(x => x.IsVerified, opt => opt.MapFrom(src => src.VerifiedAt.HasValue));
}
private static IReadOnlyList<BillingLineItemDto> DeserializeLineItems(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return [];
}
try
{
return JsonSerializer.Deserialize<List<BillingLineItemDto>>(json) ?? [];
}
catch
{
return [];
}
}
}

View File

@@ -0,0 +1,19 @@
using MediatR;
namespace TakeoutSaaS.Application.App.Billings.Queries;
/// <summary>
/// 导出账单Excel/PDF/CSV
/// </summary>
public sealed record ExportBillingsQuery : IRequest<byte[]>
{
/// <summary>
/// 要导出的账单 ID 列表。
/// </summary>
public long[] BillingIds { get; init; } = [];
/// <summary>
/// 导出格式Excel/Pdf/Csv
/// </summary>
public string Format { get; init; } = "Excel";
}

View File

@@ -0,0 +1,15 @@
using MediatR;
using TakeoutSaaS.Application.App.Billings.Dto;
namespace TakeoutSaaS.Application.App.Billings.Queries;
/// <summary>
/// 查询账单详情(含明细项)。
/// </summary>
public sealed record GetBillingDetailQuery : IRequest<BillingDetailDto>
{
/// <summary>
/// 账单 ID雪花算法
/// </summary>
public long BillingId { get; init; }
}

View File

@@ -0,0 +1,72 @@
using MediatR;
using TakeoutSaaS.Application.App.Billings.Dto;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Billings.Queries;
/// <summary>
/// 分页查询账单列表。
/// </summary>
public sealed record GetBillingListQuery : IRequest<PagedResult<BillingListDto>>
{
/// <summary>
/// 租户 ID可选管理员可查询所有租户
/// </summary>
public long? TenantId { get; init; }
/// <summary>
/// 账单状态筛选。
/// </summary>
public TenantBillingStatus? Status { get; init; }
/// <summary>
/// 账单类型筛选。
/// </summary>
public BillingType? BillingType { get; init; }
/// <summary>
/// 账单起始时间UTC筛选。
/// </summary>
public DateTime? StartDate { get; init; }
/// <summary>
/// 账单结束时间UTC筛选。
/// </summary>
public DateTime? EndDate { get; init; }
/// <summary>
/// 关键词搜索(账单编号)。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 最小应付金额筛选(包含)。
/// </summary>
public decimal? MinAmount { get; init; }
/// <summary>
/// 最大应付金额筛选(包含)。
/// </summary>
public decimal? MaxAmount { get; init; }
/// <summary>
/// 页码(从 1 开始)。
/// </summary>
public int PageNumber { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 20;
/// <summary>
/// 排序字段DueDate/CreatedAt/AmountDue
/// </summary>
public string? SortBy { get; init; }
/// <summary>
/// 是否降序排序。
/// </summary>
public bool SortDesc { get; init; }
}

View File

@@ -0,0 +1,15 @@
using MediatR;
using TakeoutSaaS.Application.App.Billings.Dto;
namespace TakeoutSaaS.Application.App.Billings.Queries;
/// <summary>
/// 查询账单的支付记录。
/// </summary>
public sealed record GetBillingPaymentsQuery : IRequest<List<PaymentRecordDto>>
{
/// <summary>
/// 账单 ID雪花算法
/// </summary>
public long BillingId { get; init; }
}

View File

@@ -0,0 +1,30 @@
using MediatR;
using TakeoutSaaS.Application.App.Billings.Dto;
namespace TakeoutSaaS.Application.App.Billings.Queries;
/// <summary>
/// 查询账单统计数据。
/// </summary>
public sealed record GetBillingStatisticsQuery : IRequest<BillingStatisticsDto>
{
/// <summary>
/// 租户 ID可选管理员可查询所有租户
/// </summary>
public long? TenantId { get; init; }
/// <summary>
/// 统计开始时间UTC
/// </summary>
public DateTime? StartDate { get; init; }
/// <summary>
/// 统计结束时间UTC
/// </summary>
public DateTime? EndDate { get; init; }
/// <summary>
/// 分组方式Day/Week/Month
/// </summary>
public string? GroupBy { get; init; }
}

View File

@@ -0,0 +1,21 @@
using MediatR;
using TakeoutSaaS.Application.App.Billings.Dto;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Billings.Queries;
/// <summary>
/// 查询逾期账单列表。
/// </summary>
public sealed record GetOverdueBillingsQuery : IRequest<PagedResult<BillingListDto>>
{
/// <summary>
/// 页码(从 1 开始)。
/// </summary>
public int PageNumber { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 20;
}

View File

@@ -0,0 +1,73 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Billings.Commands;
namespace TakeoutSaaS.Application.App.Billings.Validators;
/// <summary>
/// 创建账单命令验证器。
/// </summary>
public sealed class CreateBillingCommandValidator : AbstractValidator<CreateBillingCommand>
{
public CreateBillingCommandValidator()
{
// 1. 租户 ID 必填
RuleFor(x => x.TenantId)
.GreaterThan(0)
.WithMessage("租户 ID 必须大于 0");
// 2. (空行后) 账单类型必填
RuleFor(x => x.BillingType)
.IsInEnum()
.WithMessage("账单类型无效");
// 3. (空行后) 应付金额必须大于 0
RuleFor(x => x.AmountDue)
.GreaterThan(0)
.WithMessage("应付金额必须大于 0");
// 4. (空行后) 到期日必须是未来时间
RuleFor(x => x.DueDate)
.GreaterThan(DateTime.UtcNow)
.WithMessage("到期日必须是未来时间");
// 5. (空行后) 账单明细至少包含一项
RuleFor(x => x.LineItems)
.NotEmpty()
.WithMessage("账单明细不能为空");
// 6. (空行后) 账单明细项验证
RuleForEach(x => x.LineItems)
.ChildRules(lineItem =>
{
lineItem.RuleFor(x => x.ItemType)
.NotEmpty()
.WithMessage("账单明细类型不能为空")
.MaximumLength(50)
.WithMessage("账单明细类型不能超过 50 个字符");
lineItem.RuleFor(x => x.Description)
.NotEmpty()
.WithMessage("账单明细描述不能为空")
.MaximumLength(200)
.WithMessage("账单明细描述不能超过 200 个字符");
lineItem.RuleFor(x => x.Quantity)
.GreaterThan(0)
.WithMessage("账单明细数量必须大于 0");
lineItem.RuleFor(x => x.UnitPrice)
.GreaterThanOrEqualTo(0)
.WithMessage("账单明细单价不能为负数");
lineItem.RuleFor(x => x.Amount)
.GreaterThanOrEqualTo(0)
.WithMessage("账单明细金额不能为负数");
});
// 7. (空行后) 备注长度限制(可选)
RuleFor(x => x.Notes)
.MaximumLength(500)
.WithMessage("备注不能超过 500 个字符")
.When(x => !string.IsNullOrWhiteSpace(x.Notes));
}
}

View File

@@ -0,0 +1,49 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Billings.Commands;
namespace TakeoutSaaS.Application.App.Billings.Validators;
/// <summary>
/// 记录支付命令验证器。
/// </summary>
public sealed class RecordPaymentCommandValidator : AbstractValidator<RecordPaymentCommand>
{
public RecordPaymentCommandValidator()
{
// 1. 账单 ID 必填
RuleFor(x => x.BillingId)
.GreaterThan(0)
.WithMessage("账单 ID 必须大于 0");
// 2. (空行后) 支付金额必须大于 0
RuleFor(x => x.Amount)
.GreaterThan(0)
.WithMessage("支付金额必须大于 0")
.LessThanOrEqualTo(1_000_000_000)
.WithMessage("支付金额不能超过 10 亿");
// 3. (空行后) 支付方式必填
RuleFor(x => x.Method)
.IsInEnum()
.WithMessage("支付方式无效");
// 4. (空行后) 交易号必填
RuleFor(x => x.TransactionNo)
.NotEmpty()
.WithMessage("交易号不能为空")
.MaximumLength(64)
.WithMessage("交易号不能超过 64 个字符");
// 5. (空行后) 支付凭证 URL可选
RuleFor(x => x.ProofUrl)
.MaximumLength(500)
.WithMessage("支付凭证 URL 不能超过 500 个字符")
.When(x => !string.IsNullOrWhiteSpace(x.ProofUrl));
// 6. (空行后) 备注(可选)
RuleFor(x => x.Notes)
.MaximumLength(500)
.WithMessage("备注不能超过 500 个字符")
.When(x => !string.IsNullOrWhiteSpace(x.Notes));
}
}

View File

@@ -0,0 +1,30 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Billings.Commands;
namespace TakeoutSaaS.Application.App.Billings.Validators;
/// <summary>
/// 更新账单状态命令验证器。
/// </summary>
public sealed class UpdateBillingStatusCommandValidator : AbstractValidator<UpdateBillingStatusCommand>
{
public UpdateBillingStatusCommandValidator()
{
// 1. 账单 ID 必填
RuleFor(x => x.BillingId)
.GreaterThan(0)
.WithMessage("账单 ID 必须大于 0");
// 2. (空行后) 状态枚举校验
RuleFor(x => x.NewStatus)
.IsInEnum()
.WithMessage("新状态无效");
// 3. (空行后) 备注长度限制(可选)
RuleFor(x => x.Notes)
.MaximumLength(500)
.WithMessage("备注不能超过 500 个字符")
.When(x => !string.IsNullOrWhiteSpace(x.Notes));
}
}

View File

@@ -20,6 +20,9 @@ public static class AppApplicationServiceCollectionExtensions
{
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()));
services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
// (空行后) 注册 AutoMapper Profile
services.AddAutoMapper(Assembly.GetExecutingAssembly());
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
return services;