diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/BillingsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/BillingsController.cs index 8ead5d2..e1083c5 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/BillingsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/BillingsController.cs @@ -157,6 +157,29 @@ public sealed class BillingsController(IMediator mediator) : BaseApiController return ApiResponse.Ok(result); } + /// + /// 一键确认收款(记录支付 + 立即审核通过)。 + /// + /// 账单 ID。 + /// 确认收款命令。 + /// 取消标记。 + /// 确认后的支付记录。 + [HttpPost("{id:long}/payments/confirm")] + [PermissionAuthorize("bill:pay")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> ConfirmPayment(long id, [FromBody, Required] ConfirmPaymentCommand command, CancellationToken cancellationToken) + { + // 1. 绑定账单标识 + command = command with { BillingId = id }; + + // 2. (空行后) 一键确认收款(含:写入 VerifiedBy/VerifiedAt,并同步更新账单已收金额/状态) + var result = await mediator.Send(command, cancellationToken); + + // 3. (空行后) 返回结果 + return ApiResponse.Ok(result); + } + /// /// 审核支付记录。 /// diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Commands/ConfirmPaymentCommand.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Commands/ConfirmPaymentCommand.cs new file mode 100644 index 0000000..db00c49 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Commands/ConfirmPaymentCommand.cs @@ -0,0 +1,42 @@ +using MediatR; +using TakeoutSaaS.Application.App.Billings.Dto; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Application.App.Billings.Commands; + +/// +/// 一键确认收款命令(记录支付 + 立即审核通过)。 +/// +public sealed record ConfirmPaymentCommand : IRequest +{ + /// + /// 账单 ID(雪花算法)。 + /// + public long BillingId { get; init; } + + /// + /// 支付金额。 + /// + public decimal Amount { get; init; } + + /// + /// 支付方式。 + /// + public TenantPaymentMethod Method { get; init; } + + /// + /// 交易号。 + /// + public string? TransactionNo { get; init; } + + /// + /// 支付凭证 URL。 + /// + public string? ProofUrl { get; init; } + + /// + /// 备注信息。 + /// + public string? Notes { get; init; } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/ConfirmPaymentCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/ConfirmPaymentCommandHandler.cs new file mode 100644 index 0000000..c6ebbda --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/ConfirmPaymentCommandHandler.cs @@ -0,0 +1,99 @@ +using MediatR; +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; +using TakeoutSaaS.Shared.Abstractions.Security; + +namespace TakeoutSaaS.Application.App.Billings.Handlers; + +/// +/// 一键确认收款处理器(记录支付 + 立即审核通过 + 同步更新账单已收金额/状态)。 +/// +public sealed class ConfirmPaymentCommandHandler( + ITenantBillingRepository billingRepository, + ITenantPaymentRepository paymentRepository, + IIdGenerator idGenerator, + ICurrentUserAccessor currentUserAccessor) + : IRequestHandler +{ + /// + public async Task Handle(ConfirmPaymentCommand request, CancellationToken cancellationToken) + { + // 1. 校验操作者身份(用于写入 VerifiedBy) + if (!currentUserAccessor.IsAuthenticated || currentUserAccessor.UserId <= 0) + { + throw new BusinessException(ErrorCodes.Unauthorized, "未登录或无效的操作者身份"); + } + + // 2. (空行后) 查询账单 + var billing = await billingRepository.FindByIdAsync(request.BillingId, cancellationToken); + if (billing is null) + { + throw new BusinessException(ErrorCodes.NotFound, "账单不存在"); + } + + // 3. (空行后) 业务规则检查 + if (billing.Status == TenantBillingStatus.Paid) + { + throw new BusinessException(ErrorCodes.BusinessError, "已支付账单不允许重复收款"); + } + + if (billing.Status == TenantBillingStatus.Cancelled) + { + throw new BusinessException(ErrorCodes.BusinessError, "已取消账单不允许收款"); + } + + // 4. (空行后) 金额边界:不允许超过剩余应收(与前端校验保持一致) + var totalAmount = billing.CalculateTotalAmount(); + var remainingAmount = totalAmount - billing.AmountPaid; + if (request.Amount > remainingAmount) + { + throw new BusinessException(ErrorCodes.BadRequest, "支付金额不能超过剩余应收"); + } + + // 5. (空行后) 幂等校验:交易号唯一 + if (!string.IsNullOrWhiteSpace(request.TransactionNo)) + { + var exists = await paymentRepository.GetByTransactionNoAsync(request.TransactionNo.Trim(), cancellationToken); + if (exists is not null) + { + throw new BusinessException(ErrorCodes.Conflict, "交易号已存在,疑似重复提交"); + } + } + + // 6. (空行后) 构建支付记录并立即审核通过 + var now = DateTime.UtcNow; + var payment = new TenantPayment + { + Id = idGenerator.NextId(), + TenantId = billing.TenantId, + BillingStatementId = request.BillingId, + Amount = request.Amount, + Method = request.Method, + Status = TenantPaymentStatus.Pending, + TransactionNo = string.IsNullOrWhiteSpace(request.TransactionNo) ? null : request.TransactionNo.Trim(), + ProofUrl = request.ProofUrl, + PaidAt = now, + Notes = request.Notes + }; + + payment.Verify(currentUserAccessor.UserId); + + // 7. (空行后) 同步更新账单已收金额/状态(支持分次收款) + billing.MarkAsPaid(payment.Amount, payment.TransactionNo ?? string.Empty); + + // 8. (空行后) 持久化变更(同一 DbContext 下单次 SaveChanges 可提交两张表) + await paymentRepository.AddAsync(payment, cancellationToken); + await billingRepository.UpdateAsync(billing, cancellationToken); + await paymentRepository.SaveChangesAsync(cancellationToken); + + // 9. (空行后) 返回 DTO + return payment.ToPaymentRecordDto(); + } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Validators/ConfirmPaymentCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Validators/ConfirmPaymentCommandValidator.cs new file mode 100644 index 0000000..a1cc4f5 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Validators/ConfirmPaymentCommandValidator.cs @@ -0,0 +1,50 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Billings.Commands; + +namespace TakeoutSaaS.Application.App.Billings.Validators; + +/// +/// 一键确认收款命令验证器。 +/// +public sealed class ConfirmPaymentCommandValidator : AbstractValidator +{ + public ConfirmPaymentCommandValidator() + { + // 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)); + } +} +