442 lines
21 KiB
C#
442 lines
21 KiB
C#
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Tiku.Application.Commerce;
|
|
using Tiku.Application.Jobs;
|
|
using Tiku.Domain.Commerce;
|
|
|
|
namespace Tiku.Infrastructure.Commerce;
|
|
|
|
internal sealed partial class CommerceAdminService
|
|
{
|
|
public async Task<TenantAdjustmentVoucherList> GetAdjustmentVouchersAsync(
|
|
CommerceAdminActor actor,
|
|
CommerceAdminQuery query,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await AssertAdminAsync(actor, cancellationToken);
|
|
var vouchers = dbContext.CommerceAdjustmentVouchers.AsNoTracking()
|
|
.Where(item => item.TenantId == actor.TenantId);
|
|
if (!string.IsNullOrWhiteSpace(query.Status))
|
|
vouchers = vouchers.Where(item => item.Status == ParseAdjustmentVoucherStatus(query.Status));
|
|
|
|
var items = await vouchers
|
|
.OrderByDescending(item => item.CreatedAt)
|
|
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
|
|
.ToArrayAsync(cancellationToken);
|
|
return new TenantAdjustmentVoucherList(items);
|
|
}
|
|
|
|
public async Task<CommerceAdjustmentVoucher> GetAdjustmentVoucherAsync(
|
|
CommerceAdminActor actor,
|
|
Guid voucherId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await AssertAdminAsync(actor, cancellationToken);
|
|
return await dbContext.CommerceAdjustmentVouchers.AsNoTracking()
|
|
.SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Id == voucherId,
|
|
cancellationToken)
|
|
?? throw new CommerceException("Adjustment voucher was not found.", "adjustment_voucher_not_found");
|
|
}
|
|
|
|
public async Task<CommerceAdjustmentVoucher> CreateAdjustmentVoucherAsync(
|
|
CommerceAdminActor actor,
|
|
CreateAdjustmentVoucherCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await AssertAdminAsync(actor, cancellationToken);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(command.Reason);
|
|
await AssertOptionalReferenceAsync(dbContext.CommerceReconciliationIssues, actor.TenantId, command.IssueId,
|
|
"reconciliation_issue_not_found", cancellationToken);
|
|
await AssertOptionalReferenceAsync(dbContext.CommerceReconciliationBatches, actor.TenantId, command.BatchId,
|
|
"reconciliation_batch_not_found", cancellationToken);
|
|
await AssertOptionalReferenceAsync(dbContext.CommerceReconciliationItems, actor.TenantId, command.ItemId,
|
|
"reconciliation_item_not_found", cancellationToken);
|
|
await AssertOptionalReferenceAsync(dbContext.Orders, actor.TenantId, command.OrderId, "order_not_found",
|
|
cancellationToken);
|
|
await AssertOptionalReferenceAsync(dbContext.Payments, actor.TenantId, command.PaymentId, "payment_not_found",
|
|
cancellationToken);
|
|
await AssertOptionalReferenceAsync(dbContext.CommerceRefundRequests, actor.TenantId, command.RefundRequestId,
|
|
"refund_not_found", cancellationToken);
|
|
var voucher = new CommerceAdjustmentVoucher
|
|
{
|
|
TenantId = actor.TenantId,
|
|
IssueId = command.IssueId,
|
|
BatchId = command.BatchId,
|
|
ItemId = command.ItemId,
|
|
OrderId = command.OrderId,
|
|
PaymentId = command.PaymentId,
|
|
RefundRequestId = command.RefundRequestId,
|
|
CreatedBy = actor.UserId,
|
|
VoucherNo = $"ADJ{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Random.Shared.Next(1000, 9999)}",
|
|
Status = CommerceAdjustmentVoucherStatus.Draft,
|
|
Direction = command.Direction,
|
|
AmountCents = command.AmountCents,
|
|
Currency = string.IsNullOrWhiteSpace(command.Currency) ? "CNY" : command.Currency.Trim().ToUpperInvariant(),
|
|
Reason = command.Reason.Trim(),
|
|
ProofAssetKey = string.IsNullOrWhiteSpace(command.ProofAssetKey) ? null : command.ProofAssetKey.Trim(),
|
|
Metadata = JsonObjectOrDefault(command.Metadata)
|
|
};
|
|
dbContext.CommerceAdjustmentVouchers.Add(voucher);
|
|
dbContext.CommerceAdjustmentVoucherEvents.Add(new CommerceAdjustmentVoucherEvent
|
|
{
|
|
TenantId = actor.TenantId,
|
|
VoucherId = voucher.Id,
|
|
ToStatus = voucher.Status,
|
|
ActorUserId = actor.UserId,
|
|
Note = voucher.Reason,
|
|
Details = JsonSerializer.SerializeToElement(new { voucher.Direction, voucher.AmountCents })
|
|
});
|
|
await AddAuditAsync(actor, "commerce.adjustment_voucher.created", "commerce_adjustment_vouchers", voucher.Id,
|
|
new { voucher.VoucherNo, voucher.Direction, voucher.AmountCents }, cancellationToken);
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
return voucher;
|
|
}
|
|
|
|
public async Task<CommerceAdjustmentVoucher> UpdateAdjustmentVoucherStatusAsync(
|
|
CommerceAdminActor actor,
|
|
UpdateAdjustmentVoucherStatusCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await AssertAdminAsync(actor, cancellationToken);
|
|
var voucher = await dbContext.CommerceAdjustmentVouchers.SingleOrDefaultAsync(
|
|
item => item.TenantId == actor.TenantId && item.Id == command.VoucherId,
|
|
cancellationToken) ??
|
|
throw new CommerceException("Adjustment voucher was not found.", "adjustment_voucher_not_found");
|
|
var fromStatus = voucher.Status;
|
|
if (fromStatus != command.Status && !IsAllowedAdjustmentTransition(fromStatus, command.Status))
|
|
throw new CommerceException("Adjustment voucher status transition is invalid.",
|
|
"invalid_adjustment_status_transition");
|
|
|
|
voucher.Status = command.Status;
|
|
if (command.Status is CommerceAdjustmentVoucherStatus.Approved or CommerceAdjustmentVoucherStatus.Rejected)
|
|
{
|
|
voucher.ReviewedBy = actor.UserId;
|
|
voucher.ReviewedAt ??= DateTimeOffset.UtcNow;
|
|
}
|
|
else if (command.Status is CommerceAdjustmentVoucherStatus.Closed or CommerceAdjustmentVoucherStatus.Void)
|
|
{
|
|
voucher.ClosedAt ??= DateTimeOffset.UtcNow;
|
|
}
|
|
|
|
dbContext.CommerceAdjustmentVoucherEvents.Add(new CommerceAdjustmentVoucherEvent
|
|
{
|
|
TenantId = actor.TenantId,
|
|
VoucherId = voucher.Id,
|
|
FromStatus = fromStatus,
|
|
ToStatus = command.Status,
|
|
ActorUserId = actor.UserId,
|
|
Note = command.Note,
|
|
Details = JsonSerializer.SerializeToElement(new { })
|
|
});
|
|
await AddAuditAsync(actor, "commerce.adjustment_voucher.status_changed", "commerce_adjustment_vouchers",
|
|
voucher.Id, new { voucher.VoucherNo, From = fromStatus, To = command.Status }, cancellationToken);
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
return voucher;
|
|
}
|
|
|
|
public async Task<TenantAdjustmentVoucherEventList> GetAdjustmentVoucherEventsAsync(
|
|
CommerceAdminActor actor,
|
|
Guid voucherId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await AssertAdminAsync(actor, cancellationToken);
|
|
var exists = await dbContext.CommerceAdjustmentVouchers.AnyAsync(
|
|
item => item.TenantId == actor.TenantId && item.Id == voucherId,
|
|
cancellationToken);
|
|
if (!exists) throw new CommerceException("Adjustment voucher was not found.", "adjustment_voucher_not_found");
|
|
|
|
var events = await dbContext.CommerceAdjustmentVoucherEvents.AsNoTracking()
|
|
.Where(item => item.TenantId == actor.TenantId && item.VoucherId == voucherId)
|
|
.OrderBy(item => item.CreatedAt)
|
|
.ToArrayAsync(cancellationToken);
|
|
return new TenantAdjustmentVoucherEventList(events);
|
|
}
|
|
|
|
public async Task<TenantAdjustmentReport> GetAdjustmentReportAsync(
|
|
CommerceAdminActor actor,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await AssertAdminAsync(actor, cancellationToken);
|
|
return new TenantAdjustmentReport(
|
|
await dbContext.CommerceAdjustmentVouchers.CountAsync(
|
|
item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Draft,
|
|
cancellationToken),
|
|
await dbContext.CommerceAdjustmentVouchers.CountAsync(
|
|
item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.PendingReview,
|
|
cancellationToken),
|
|
await dbContext.CommerceAdjustmentVouchers.CountAsync(
|
|
item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved,
|
|
cancellationToken),
|
|
await dbContext.CommerceAdjustmentVouchers.CountAsync(
|
|
item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Closed,
|
|
cancellationToken),
|
|
await dbContext.CommerceAdjustmentVouchers
|
|
.Where(item =>
|
|
item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved &&
|
|
item.Direction == CommerceAdjustmentDirection.IncreaseRevenue)
|
|
.SumAsync(item => item.AmountCents, cancellationToken),
|
|
await dbContext.CommerceAdjustmentVouchers
|
|
.Where(item =>
|
|
item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved &&
|
|
item.Direction == CommerceAdjustmentDirection.DecreaseRevenue)
|
|
.SumAsync(item => item.AmountCents, cancellationToken));
|
|
}
|
|
|
|
public async Task<TenantReconciliationItemList> GetReconciliationItemsAsync(
|
|
CommerceAdminActor actor,
|
|
Guid batchId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await AssertAdminAsync(actor, cancellationToken);
|
|
var batchExists = await dbContext.CommerceReconciliationBatches.AnyAsync(
|
|
item => item.TenantId == actor.TenantId && item.Id == batchId,
|
|
cancellationToken);
|
|
if (!batchExists)
|
|
throw new CommerceException("Reconciliation batch was not found.", "reconciliation_batch_not_found");
|
|
|
|
var items = await dbContext.CommerceReconciliationItems.AsNoTracking()
|
|
.Where(item => item.TenantId == actor.TenantId && item.BatchId == batchId)
|
|
.OrderBy(item => item.RowNo)
|
|
.Take(500)
|
|
.ToArrayAsync(cancellationToken);
|
|
return new TenantReconciliationItemList(items);
|
|
}
|
|
|
|
public async Task<TenantReconciliationIssueEventList> GetReconciliationIssueEventsAsync(
|
|
CommerceAdminActor actor,
|
|
Guid issueId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await AssertAdminAsync(actor, cancellationToken);
|
|
var issueExists = await dbContext.CommerceReconciliationIssues.AnyAsync(
|
|
item => item.TenantId == actor.TenantId && item.Id == issueId,
|
|
cancellationToken);
|
|
if (!issueExists)
|
|
throw new CommerceException("Reconciliation issue was not found.", "reconciliation_issue_not_found");
|
|
|
|
var events = await dbContext.CommerceReconciliationIssueEvents.AsNoTracking()
|
|
.Where(item => item.TenantId == actor.TenantId && item.IssueId == issueId)
|
|
.OrderBy(item => item.CreatedAt)
|
|
.ToArrayAsync(cancellationToken);
|
|
return new TenantReconciliationIssueEventList(events);
|
|
}
|
|
|
|
public async Task<TenantCommerceAnomalySummary> GetAnomalySummaryAsync(
|
|
CommerceAdminActor actor,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await AssertAdminAsync(actor, cancellationToken);
|
|
var openRefunds = await dbContext.CommerceRefundRequests.CountAsync(
|
|
item => item.TenantId == actor.TenantId && item.Status == CommerceRefundStatus.Requested,
|
|
cancellationToken);
|
|
var processingRefunds = await dbContext.CommerceRefundRequests.CountAsync(
|
|
item => item.TenantId == actor.TenantId && item.Status == CommerceRefundStatus.Processing,
|
|
cancellationToken);
|
|
var openIssues = await dbContext.CommerceReconciliationIssues.CountAsync(
|
|
item => item.TenantId == actor.TenantId && item.Status != ReconciliationIssueStatus.Resolved &&
|
|
item.Status != ReconciliationIssueStatus.Ignored,
|
|
cancellationToken);
|
|
var failedBatches = await dbContext.CommerceReconciliationBatches.CountAsync(
|
|
item => item.TenantId == actor.TenantId && item.Status == ReconciliationBatchStatus.Failed,
|
|
cancellationToken);
|
|
var pendingPayments = await dbContext.Payments.CountAsync(
|
|
item => item.TenantId == actor.TenantId && item.Status == PaymentStatus.Pending,
|
|
cancellationToken);
|
|
var mismatchCount = await dbContext.CommerceReconciliationItems.CountAsync(
|
|
item => item.TenantId == actor.TenantId &&
|
|
(item.MatchStatus == ReconciliationMatchStatus.AmountMismatch ||
|
|
item.MatchStatus == ReconciliationMatchStatus.StatusMismatch),
|
|
cancellationToken);
|
|
|
|
return new TenantCommerceAnomalySummary(
|
|
openRefunds,
|
|
processingRefunds,
|
|
openIssues,
|
|
failedBatches,
|
|
pendingPayments,
|
|
mismatchCount);
|
|
}
|
|
|
|
public async Task<ReconciliationImportPreview> PreviewReconciliationImportAsync(
|
|
CommerceAdminActor actor,
|
|
PreviewReconciliationImportCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await AssertAdminAsync(actor, cancellationToken);
|
|
return BuildImportPreview(command.Provider, command.Rows);
|
|
}
|
|
|
|
public async Task<CommerceReconciliationBatch> ImportReconciliationAsync(
|
|
CommerceAdminActor actor,
|
|
ImportReconciliationCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await AssertAdminAsync(actor, cancellationToken);
|
|
var preview = BuildImportPreview(command.Provider, command.Rows);
|
|
var batch = new CommerceReconciliationBatch
|
|
{
|
|
TenantId = actor.TenantId,
|
|
CreatedBy = actor.UserId,
|
|
Provider = NormalizeProvider(command.Provider),
|
|
BillDate = command.BillDate,
|
|
BillType = command.BillType,
|
|
Source = ReconciliationSource.ManualUpload,
|
|
SourceName = command.SourceName.Trim(),
|
|
SourceHash = preview.SourceHash,
|
|
Status = preview.InvalidCount == 0
|
|
? ReconciliationBatchStatus.Completed
|
|
: ReconciliationBatchStatus.CompletedWithIssues,
|
|
TotalCount = preview.TotalCount,
|
|
MatchedCount = preview.TotalCount - preview.InvalidCount,
|
|
MismatchCount = preview.InvalidCount,
|
|
AmountCents = preview.AmountCents,
|
|
RefundAmountCents = preview.RefundAmountCents,
|
|
CompletedAt = DateTimeOffset.UtcNow,
|
|
Metadata = JsonSerializer.SerializeToElement(new
|
|
{
|
|
preview.PaymentCount,
|
|
preview.RefundCount,
|
|
preview.InvalidCount
|
|
})
|
|
};
|
|
dbContext.CommerceReconciliationBatches.Add(batch);
|
|
var rowNo = 0;
|
|
foreach (var row in EnumerateImportRows(command.Rows))
|
|
{
|
|
rowNo++;
|
|
var item = CreateReconciliationItem(actor.TenantId, batch.Id, rowNo, NormalizeProvider(command.Provider),
|
|
row);
|
|
dbContext.CommerceReconciliationItems.Add(item);
|
|
if (item.MatchStatus != ReconciliationMatchStatus.Matched)
|
|
dbContext.CommerceReconciliationIssues.Add(new CommerceReconciliationIssue
|
|
{
|
|
TenantId = actor.TenantId,
|
|
BatchId = batch.Id,
|
|
Provider = item.Provider,
|
|
TransactionType = item.TransactionType,
|
|
IssueNo = $"RC{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{rowNo:0000}",
|
|
MatchStatus = item.MatchStatus switch
|
|
{
|
|
ReconciliationMatchStatus.MissingLocal => ReconciliationIssueMatchStatus.MissingLocal,
|
|
ReconciliationMatchStatus.MissingProvider => ReconciliationIssueMatchStatus.MissingProvider,
|
|
ReconciliationMatchStatus.Duplicate => ReconciliationIssueMatchStatus.Duplicate,
|
|
ReconciliationMatchStatus.StatusMismatch => ReconciliationIssueMatchStatus.StatusMismatch,
|
|
_ => ReconciliationIssueMatchStatus.AmountMismatch
|
|
},
|
|
Severity = item.Severity,
|
|
Status = ReconciliationIssueStatus.Open,
|
|
OrderNo = item.OrderNo,
|
|
RefundNo = item.RefundNo,
|
|
ProviderTradeNo = item.ProviderTradeNo,
|
|
ProviderRefundNo = item.ProviderRefundNo,
|
|
AmountCents = item.AmountCents,
|
|
RefundAmountCents = item.RefundAmountCents,
|
|
Summary = item.IssueCode,
|
|
CreatedBy = actor.UserId,
|
|
Metadata = item.Details
|
|
});
|
|
}
|
|
|
|
await AddAuditAsync(actor, "commerce.reconciliation.imported", "commerce_reconciliation_batches", batch.Id,
|
|
new { batch.Provider, batch.BillDate, batch.TotalCount }, cancellationToken);
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
return batch;
|
|
}
|
|
|
|
public async Task<BackgroundJobItem> RequestProviderBillJobAsync(
|
|
CommerceAdminActor actor,
|
|
RequestProviderBillJobCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await AssertAdminAsync(actor, cancellationToken);
|
|
var job = await backgroundJobQueue.EnqueueAsync(
|
|
new CreateBackgroundJobCommand(
|
|
actor.TenantId,
|
|
"commerce_reconciliation",
|
|
JsonSerializer.SerializeToElement(new
|
|
{
|
|
provider = NormalizeProvider(command.Provider),
|
|
command.BillDate,
|
|
billType = command.BillType.ToString()
|
|
}),
|
|
command.RunAfter,
|
|
5),
|
|
cancellationToken);
|
|
await AddAuditAsync(actor, "commerce.reconciliation.provider_bill_requested", "background_jobs", job.Id,
|
|
new { command.Provider, command.BillDate, command.BillType }, cancellationToken);
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
return job;
|
|
}
|
|
|
|
public async Task<IReadOnlyCollection<BackgroundJobItem>> GetProviderBillJobsAsync(
|
|
CommerceAdminActor actor,
|
|
CommerceAdminQuery query,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await AssertAdminAsync(actor, cancellationToken);
|
|
return await backgroundJobOperations.ListAsync(actor.TenantId, "commerce_reconciliation",
|
|
Math.Clamp(query.Limit ?? 50, 1, 200), cancellationToken);
|
|
}
|
|
|
|
public async Task<CommerceRefundRequest> ProcessRefundNotificationAsync(
|
|
Guid tenantId,
|
|
RefundNotificationCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var provider = NormalizeProvider(command.Provider);
|
|
var refund = await dbContext.CommerceRefundRequests.SingleOrDefaultAsync(
|
|
item => item.TenantId == tenantId && item.RefundNo == command.RefundNo,
|
|
cancellationToken) ?? throw new CommerceException("Refund request was not found.", "refund_not_found");
|
|
var eventId = string.IsNullOrWhiteSpace(command.EventId)
|
|
? $"{provider}:{command.RefundNo}:{command.Status}"
|
|
: command.EventId.Trim();
|
|
var duplicate = await dbContext.PaymentEvents.AnyAsync(
|
|
item => item.TenantId == tenantId &&
|
|
item.Provider == provider &&
|
|
item.EventType == "refund" &&
|
|
item.EventId == eventId,
|
|
cancellationToken);
|
|
if (duplicate) return refund;
|
|
|
|
dbContext.PaymentEvents.Add(new PaymentEvent
|
|
{
|
|
TenantId = tenantId,
|
|
Provider = provider,
|
|
EventType = "refund",
|
|
EventId = eventId,
|
|
SignatureValid = true,
|
|
Payload = JsonObjectOrDefault(command.Payload),
|
|
ProcessedAt = DateTimeOffset.UtcNow
|
|
});
|
|
var fromStatus = refund.Status;
|
|
if (fromStatus != command.Status && IsAllowedRefundTransition(fromStatus, command.Status))
|
|
{
|
|
refund.Status = command.Status;
|
|
refund.ProviderRefundNo = string.IsNullOrWhiteSpace(command.ProviderRefundNo)
|
|
? refund.ProviderRefundNo
|
|
: command.ProviderRefundNo.Trim();
|
|
if (command.Status == CommerceRefundStatus.Succeeded)
|
|
{
|
|
refund.SucceededAt = DateTimeOffset.UtcNow;
|
|
await ApplyRefundToOrderAsync(refund, cancellationToken);
|
|
}
|
|
else if (command.Status == CommerceRefundStatus.Failed)
|
|
{
|
|
refund.FailedAt = DateTimeOffset.UtcNow;
|
|
}
|
|
|
|
dbContext.CommerceRefundEvents.Add(new CommerceRefundEvent
|
|
{
|
|
TenantId = tenantId,
|
|
RefundRequestId = refund.Id,
|
|
FromStatus = fromStatus,
|
|
ToStatus = command.Status,
|
|
EventType = "provider_notify",
|
|
Details = JsonObjectOrDefault(command.Payload)
|
|
});
|
|
}
|
|
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
return refund;
|
|
}
|
|
} |