Files
TakeoutSaaS.TenantApi/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantBillingStatement.cs
MSuMshk 4b53862ded 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>
2025-12-18 11:24:44 +08:00

201 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 TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Tenants.Entities;
/// <summary>
/// 租户账单,用于呈现周期性收费。
/// </summary>
public sealed class TenantBillingStatement : MultiTenantEntityBase
{
/// <summary>
/// 账单编号,供对账查询。
/// </summary>
public string StatementNo { get; set; } = string.Empty;
/// <summary>
/// 账单类型(订阅账单/配额包账单/手动账单/续费账单)。
/// </summary>
public BillingType BillingType { get; set; } = BillingType.Subscription;
/// <summary>
/// 关联的订阅 ID仅当 BillingType 为 Subscription 或 Renewal 时有值)。
/// </summary>
public long? SubscriptionId { get; set; }
/// <summary>
/// 账单周期开始时间。
/// </summary>
public DateTime PeriodStart { get; set; }
/// <summary>
/// 账单周期结束时间。
/// </summary>
public DateTime PeriodEnd { get; set; }
/// <summary>
/// 应付金额(原始金额)。
/// </summary>
public decimal AmountDue { get; set; }
/// <summary>
/// 折扣金额。
/// </summary>
public decimal DiscountAmount { get; set; }
/// <summary>
/// 税费金额。
/// </summary>
public decimal TaxAmount { get; set; }
/// <summary>
/// 实付金额。
/// </summary>
public decimal AmountPaid { get; set; }
/// <summary>
/// 货币类型(默认 CNY
/// </summary>
public string Currency { get; set; } = "CNY";
/// <summary>
/// 当前付款状态。
/// </summary>
public TenantBillingStatus Status { get; set; } = TenantBillingStatus.Pending;
/// <summary>
/// 到期日。
/// </summary>
public DateTime DueDate { get; set; }
/// <summary>
/// 提醒发送时间(续费提醒、逾期提醒等)。
/// </summary>
public DateTime? ReminderSentAt { get; set; }
/// <summary>
/// 逾期通知时间。
/// </summary>
public DateTime? OverdueNotifiedAt { get; set; }
/// <summary>
/// 账单明细 JSON记录各项费用。
/// </summary>
public string? LineItemsJson { get; set; }
/// <summary>
/// 备注信息(如:人工备注、取消原因等)。
/// </summary>
public string? Notes { get; set; }
/// <summary>
/// 计算总金额(应付金额 - 折扣 + 税费)。
/// </summary>
/// <returns>总金额。</returns>
public decimal CalculateTotalAmount()
{
return AmountDue - DiscountAmount + TaxAmount;
}
/// <summary>
/// 标记为已支付(直接结清)。
/// </summary>
public void MarkAsPaid()
{
// 1. 计算剩余应付金额
var remainingAmount = CalculateTotalAmount() - AmountPaid;
// 2. 若已结清则直接返回
if (remainingAmount <= 0)
{
Status = TenantBillingStatus.Paid;
return;
}
// 3. 补足剩余金额并标记为已支付
MarkAsPaid(remainingAmount, string.Empty);
}
/// <summary>
/// 标记为已支付。
/// </summary>
/// <param name="amount">支付金额。</param>
/// <param name="transactionNo">交易号。</param>
public void MarkAsPaid(decimal amount, string transactionNo)
{
if (Status == TenantBillingStatus.Paid)
{
throw new InvalidOperationException("账单已经处于已支付状态,不能重复标记。");
}
if (Status == TenantBillingStatus.Cancelled)
{
throw new InvalidOperationException("已取消的账单不能标记为已支付。");
}
// 1. 累加支付金额
AmountPaid += amount;
// 2. 如果实付金额大于等于应付总额,则标记为已支付
if (AmountPaid >= CalculateTotalAmount())
{
Status = TenantBillingStatus.Paid;
}
}
/// <summary>
/// 标记为逾期。
/// </summary>
public void MarkAsOverdue()
{
// 1. 仅待支付账单允许标记逾期
if (Status != TenantBillingStatus.Pending)
{
return;
}
// 2. 未超过到期日则不处理
if (DateTime.UtcNow <= DueDate)
{
return;
}
// 3. 标记为逾期(通知时间由外部流程在发送通知时写入)
Status = TenantBillingStatus.Overdue;
}
/// <summary>
/// 取消账单。
/// </summary>
public void Cancel()
{
Cancel(null);
}
/// <summary>
/// 取消账单。
/// </summary>
/// <param name="reason">取消原因。</param>
public void Cancel(string? reason)
{
if (Status == TenantBillingStatus.Paid)
{
throw new InvalidOperationException("已支付的账单不能取消。");
}
if (Status == TenantBillingStatus.Cancelled)
{
throw new InvalidOperationException("账单已经处于取消状态。");
}
// 1. 变更状态
Status = TenantBillingStatus.Cancelled;
// 2. 记录取消原因(可选)
if (!string.IsNullOrWhiteSpace(reason))
{
Notes = string.IsNullOrWhiteSpace(Notes) ? $"[取消原因] {reason}" : $"{Notes}\n[取消原因] {reason}";
}
}
}