forked from xiongyuxing/tiku-backend.net
feat: add phase five operations foundation
This commit is contained in:
@@ -562,6 +562,249 @@ internal sealed class CommerceAdminService(
|
||||
return new TenantCouponReport(couponCount, claimedCount, usedCount, discountApplied);
|
||||
}
|
||||
|
||||
public async Task<TenantRefundList> GetRefundsAsync(
|
||||
CommerceAdminActor actor,
|
||||
CommerceAdminQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var refunds = dbContext.CommerceRefundRequests.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
refunds = refunds.Where(item => item.Status == ParseRefundStatus(query.Status));
|
||||
}
|
||||
|
||||
var items = await refunds
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new TenantRefundList(items);
|
||||
}
|
||||
|
||||
public async Task<CommerceRefundRequest> CreateRefundRequestAsync(
|
||||
CommerceAdminActor actor,
|
||||
CreateRefundRequestCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var order = await dbContext.Orders.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == command.OrderId,
|
||||
cancellationToken) ?? throw new CommerceException("Order was not found.", "order_not_found");
|
||||
if (order.Status is not (OrderStatus.Paid or OrderStatus.PartiallyRefunded))
|
||||
{
|
||||
throw new CommerceException("Only paid orders can be refunded.", "order_not_refundable");
|
||||
}
|
||||
|
||||
if (command.AmountCents <= 0 || command.AmountCents > order.AmountCents - order.RefundedAmountCents)
|
||||
{
|
||||
throw new CommerceException("Refund amount is invalid.", "invalid_refund_amount");
|
||||
}
|
||||
|
||||
if (command.PaymentId.HasValue)
|
||||
{
|
||||
var paymentExists = await dbContext.Payments.AnyAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == command.PaymentId.Value && item.OrderId == order.Id,
|
||||
cancellationToken);
|
||||
if (!paymentExists)
|
||||
{
|
||||
throw new CommerceException("Payment was not found.", "payment_not_found");
|
||||
}
|
||||
}
|
||||
|
||||
var refund = new CommerceRefundRequest
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
OrderId = order.Id,
|
||||
PaymentId = command.PaymentId,
|
||||
RequestedBy = actor.UserId,
|
||||
RefundNo = $"RF{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{RandomNumberGenerator.GetInt32(1000, 9999)}",
|
||||
Provider = order.PayProvider,
|
||||
Status = CommerceRefundStatus.Requested,
|
||||
AmountCents = command.AmountCents,
|
||||
Reason = command.Reason?.Trim(),
|
||||
EntitlementAction = command.EntitlementAction,
|
||||
Metadata = JsonObjectOrDefault(command.Metadata)
|
||||
};
|
||||
dbContext.CommerceRefundRequests.Add(refund);
|
||||
AddRefundEvent(refund, null, CommerceRefundStatus.Requested, "created", actor.UserId, new { refund.AmountCents, refund.Reason });
|
||||
await AddAuditAsync(actor, "commerce.refund.created", "commerce_refund_requests", refund.Id, new { refund.RefundNo, refund.AmountCents }, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return refund;
|
||||
}
|
||||
|
||||
public async Task<CommerceRefundRequest> UpdateRefundStatusAsync(
|
||||
CommerceAdminActor actor,
|
||||
UpdateRefundStatusCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var refund = await dbContext.CommerceRefundRequests.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == command.RefundRequestId,
|
||||
cancellationToken) ?? throw new CommerceException("Refund request was not found.", "refund_not_found");
|
||||
var fromStatus = refund.Status;
|
||||
if (!IsAllowedRefundTransition(fromStatus, command.Status))
|
||||
{
|
||||
throw new CommerceException("Refund status transition is invalid.", "invalid_refund_transition");
|
||||
}
|
||||
|
||||
refund.Status = command.Status;
|
||||
refund.ProviderRefundNo = string.IsNullOrWhiteSpace(command.ProviderRefundNo)
|
||||
? refund.ProviderRefundNo
|
||||
: command.ProviderRefundNo.Trim();
|
||||
switch (command.Status)
|
||||
{
|
||||
case CommerceRefundStatus.Approved:
|
||||
refund.ReviewedBy = actor.UserId;
|
||||
refund.ReviewedAt = DateTimeOffset.UtcNow;
|
||||
break;
|
||||
case CommerceRefundStatus.Processing:
|
||||
refund.ProcessedBy = actor.UserId;
|
||||
refund.ProcessedAt = DateTimeOffset.UtcNow;
|
||||
break;
|
||||
case CommerceRefundStatus.Succeeded:
|
||||
refund.SucceededAt = DateTimeOffset.UtcNow;
|
||||
await ApplyRefundToOrderAsync(refund, cancellationToken);
|
||||
break;
|
||||
case CommerceRefundStatus.Failed:
|
||||
refund.FailedAt = DateTimeOffset.UtcNow;
|
||||
refund.FailureReason = command.Reason;
|
||||
break;
|
||||
case CommerceRefundStatus.Cancelled or CommerceRefundStatus.Rejected:
|
||||
refund.CancelledAt = DateTimeOffset.UtcNow;
|
||||
break;
|
||||
}
|
||||
|
||||
AddRefundEvent(refund, fromStatus, command.Status, "status_changed", actor.UserId, new { command.Reason, command.ProviderRefundNo });
|
||||
await AddAuditAsync(actor, "commerce.refund.status_changed", "commerce_refund_requests", refund.Id, new { refund.RefundNo, From = fromStatus, To = command.Status }, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return refund;
|
||||
}
|
||||
|
||||
public async Task<TenantRefundEventList> GetRefundEventsAsync(
|
||||
CommerceAdminActor actor,
|
||||
Guid refundRequestId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var items = await dbContext.CommerceRefundEvents.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.RefundRequestId == refundRequestId)
|
||||
.OrderBy(item => item.CreatedAt)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new TenantRefundEventList(items);
|
||||
}
|
||||
|
||||
public async Task<TenantReconciliationBatchList> GetReconciliationBatchesAsync(
|
||||
CommerceAdminActor actor,
|
||||
CommerceAdminQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var batches = dbContext.CommerceReconciliationBatches.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
if (!string.IsNullOrWhiteSpace(query.Provider))
|
||||
{
|
||||
var provider = NormalizeProvider(query.Provider);
|
||||
batches = batches.Where(item => item.Provider == provider);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
batches = batches.Where(item => item.Status == ParseReconciliationBatchStatus(query.Status));
|
||||
}
|
||||
|
||||
var items = await batches.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new TenantReconciliationBatchList(items);
|
||||
}
|
||||
|
||||
public async Task<CommerceReconciliationBatch> CreateReconciliationBatchAsync(
|
||||
CommerceAdminActor actor,
|
||||
CreateReconciliationBatchCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var batch = new CommerceReconciliationBatch
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
CreatedBy = actor.UserId,
|
||||
Provider = NormalizeProvider(command.Provider),
|
||||
BillDate = command.BillDate,
|
||||
BillType = command.BillType,
|
||||
Source = command.Source,
|
||||
SourceName = command.SourceName?.Trim(),
|
||||
SourceHash = command.SourceHash.Trim(),
|
||||
Status = ReconciliationBatchStatus.Pending,
|
||||
Metadata = JsonObjectOrDefault(command.Metadata)
|
||||
};
|
||||
dbContext.CommerceReconciliationBatches.Add(batch);
|
||||
await AddAuditAsync(actor, "commerce.reconciliation_batch.created", "commerce_reconciliation_batches", batch.Id, new { batch.Provider, batch.BillDate }, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return batch;
|
||||
}
|
||||
|
||||
public async Task<TenantReconciliationIssueList> GetReconciliationIssuesAsync(
|
||||
CommerceAdminActor actor,
|
||||
CommerceAdminQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var issues = dbContext.CommerceReconciliationIssues.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
if (!string.IsNullOrWhiteSpace(query.Provider))
|
||||
{
|
||||
var provider = NormalizeProvider(query.Provider);
|
||||
issues = issues.Where(item => item.Provider == provider);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
issues = issues.Where(item => item.Status == ParseReconciliationIssueStatus(query.Status));
|
||||
}
|
||||
|
||||
var items = await issues.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new TenantReconciliationIssueList(items);
|
||||
}
|
||||
|
||||
public async Task<CommerceReconciliationIssue> UpdateReconciliationIssueAsync(
|
||||
CommerceAdminActor actor,
|
||||
UpdateReconciliationIssueCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var issue = await dbContext.CommerceReconciliationIssues.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == command.IssueId,
|
||||
cancellationToken) ?? throw new CommerceException("Reconciliation issue was not found.", "reconciliation_issue_not_found");
|
||||
var fromStatus = issue.Status;
|
||||
issue.Status = command.Status;
|
||||
issue.ResolutionType = command.ResolutionType;
|
||||
issue.ResolutionNote = command.Note?.Trim();
|
||||
issue.AssignedTo = command.AssignedTo ?? issue.AssignedTo;
|
||||
if (command.Status is ReconciliationIssueStatus.Resolved or ReconciliationIssueStatus.Ignored)
|
||||
{
|
||||
issue.ResolvedBy = actor.UserId;
|
||||
issue.ResolvedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
dbContext.CommerceReconciliationIssueEvents.Add(new CommerceReconciliationIssueEvent
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
IssueId = issue.Id,
|
||||
FromStatus = fromStatus,
|
||||
ToStatus = command.Status,
|
||||
EventType = "status_changed",
|
||||
ActorUserId = actor.UserId,
|
||||
Note = command.Note,
|
||||
Details = JsonSerializer.SerializeToElement(new { command.ResolutionType, command.AssignedTo })
|
||||
});
|
||||
await AddAuditAsync(actor, "commerce.reconciliation_issue.status_changed", "commerce_reconciliation_issues", issue.Id, new { issue.IssueNo, From = fromStatus, To = command.Status }, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return issue;
|
||||
}
|
||||
|
||||
private async Task AssertAdminAsync(CommerceAdminActor actor, CancellationToken cancellationToken)
|
||||
{
|
||||
var isAdmin = await dbContext.TenantMemberships.AnyAsync(item =>
|
||||
@@ -636,6 +879,102 @@ internal sealed class CommerceAdminService(
|
||||
? parsed
|
||||
: throw new CommerceException("Coupon redemption status is invalid.", "invalid_coupon_redemption_status");
|
||||
|
||||
private static CommerceRefundStatus ParseRefundStatus(string? status) =>
|
||||
Enum.TryParse<CommerceRefundStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
? parsed
|
||||
: throw new CommerceException("Refund status is invalid.", "invalid_refund_status");
|
||||
|
||||
private static ReconciliationBatchStatus ParseReconciliationBatchStatus(string? status) =>
|
||||
Enum.TryParse<ReconciliationBatchStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
? parsed
|
||||
: throw new CommerceException("Reconciliation batch status is invalid.", "invalid_reconciliation_batch_status");
|
||||
|
||||
private static ReconciliationIssueStatus ParseReconciliationIssueStatus(string? status) =>
|
||||
Enum.TryParse<ReconciliationIssueStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
? parsed
|
||||
: throw new CommerceException("Reconciliation issue status is invalid.", "invalid_reconciliation_issue_status");
|
||||
|
||||
private async Task ApplyRefundToOrderAsync(CommerceRefundRequest refund, CancellationToken cancellationToken)
|
||||
{
|
||||
var order = await dbContext.Orders.SingleAsync(
|
||||
item => item.TenantId == refund.TenantId && item.Id == refund.OrderId,
|
||||
cancellationToken);
|
||||
if (order.RefundedAmountCents < order.AmountCents)
|
||||
{
|
||||
order.RefundedAmountCents = Math.Min(order.AmountCents, order.RefundedAmountCents + refund.AmountCents);
|
||||
order.Status = order.RefundedAmountCents >= order.AmountCents
|
||||
? OrderStatus.Refunded
|
||||
: OrderStatus.PartiallyRefunded;
|
||||
}
|
||||
|
||||
if (refund.PaymentId.HasValue)
|
||||
{
|
||||
var payment = await dbContext.Payments.SingleOrDefaultAsync(
|
||||
item => item.TenantId == refund.TenantId && item.Id == refund.PaymentId.Value,
|
||||
cancellationToken);
|
||||
if (payment is not null)
|
||||
{
|
||||
payment.RefundedAmountCents = Math.Min(payment.AmountCents, payment.RefundedAmountCents + refund.AmountCents);
|
||||
payment.Status = payment.RefundedAmountCents >= payment.AmountCents
|
||||
? PaymentStatus.Refunded
|
||||
: PaymentStatus.PartiallyRefunded;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsAllowedRefundTransition(CommerceRefundStatus from, CommerceRefundStatus to)
|
||||
{
|
||||
return from switch
|
||||
{
|
||||
CommerceRefundStatus.Requested => to is CommerceRefundStatus.Approved or CommerceRefundStatus.Rejected or CommerceRefundStatus.Cancelled,
|
||||
CommerceRefundStatus.Approved => to is CommerceRefundStatus.Processing or CommerceRefundStatus.Cancelled,
|
||||
CommerceRefundStatus.Processing => to is CommerceRefundStatus.Succeeded or CommerceRefundStatus.Failed,
|
||||
CommerceRefundStatus.Failed => to is CommerceRefundStatus.Processing or CommerceRefundStatus.Cancelled,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private void AddRefundEvent(
|
||||
CommerceRefundRequest refund,
|
||||
CommerceRefundStatus? fromStatus,
|
||||
CommerceRefundStatus toStatus,
|
||||
string eventType,
|
||||
Guid actorUserId,
|
||||
object details)
|
||||
{
|
||||
dbContext.CommerceRefundEvents.Add(new CommerceRefundEvent
|
||||
{
|
||||
TenantId = refund.TenantId,
|
||||
RefundRequestId = refund.Id,
|
||||
FromStatus = fromStatus,
|
||||
ToStatus = toStatus,
|
||||
EventType = eventType,
|
||||
ActorUserId = actorUserId,
|
||||
Details = JsonSerializer.SerializeToElement(details)
|
||||
});
|
||||
}
|
||||
|
||||
private Task AddAuditAsync(
|
||||
CommerceAdminActor actor,
|
||||
string action,
|
||||
string targetType,
|
||||
Guid targetId,
|
||||
object details,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
dbContext.AuditLogs.Add(new Tiku.Domain.Operations.AuditLog
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
ActorUserId = actor.UserId,
|
||||
Action = action,
|
||||
TargetType = targetType,
|
||||
TargetId = targetId.ToString(),
|
||||
Details = JsonSerializer.SerializeToElement(details)
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static string NormalizeEnum(string? value) =>
|
||||
string.Concat((value ?? string.Empty).Split(['_', '-', ' '], StringSplitOptions.RemoveEmptyEntries));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user