feat: complete phase six backoffice operations
This commit is contained in:
@@ -3,6 +3,7 @@ using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Catalog;
|
||||
@@ -17,7 +18,8 @@ internal sealed class CommerceAdminService(
|
||||
TikuDbContext dbContext,
|
||||
ITenantSecretProtector tenantSecretProtector,
|
||||
ITenantExternalProviderConfigService providerConfigService,
|
||||
ICurrentAccessContext currentAccessContext) : ICommerceAdminService
|
||||
ICurrentAccessContext currentAccessContext,
|
||||
IBackgroundJobService backgroundJobService) : ICommerceAdminService
|
||||
{
|
||||
public async Task<IReadOnlyCollection<TenantPaymentProviderItem>> GetPaymentAccountsAsync(
|
||||
CommerceAdminActor actor,
|
||||
@@ -871,6 +873,426 @@ internal sealed class CommerceAdminService(
|
||||
return issue;
|
||||
}
|
||||
|
||||
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 backgroundJobService.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 backgroundJobService.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;
|
||||
}
|
||||
|
||||
private async Task AssertAdminAsync(CommerceAdminActor actor, CancellationToken cancellationToken)
|
||||
{
|
||||
var access = await currentAccessContext.GetAsync(cancellationToken);
|
||||
@@ -964,6 +1386,33 @@ internal sealed class CommerceAdminService(
|
||||
? parsed
|
||||
: throw new CommerceException("Reconciliation issue status is invalid.", "invalid_reconciliation_issue_status");
|
||||
|
||||
private static CommerceAdjustmentVoucherStatus ParseAdjustmentVoucherStatus(string? status) =>
|
||||
Enum.TryParse<CommerceAdjustmentVoucherStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
? parsed
|
||||
: throw new CommerceException("Adjustment voucher status is invalid.", "invalid_adjustment_voucher_status");
|
||||
|
||||
private async Task AssertOptionalReferenceAsync<TEntity>(
|
||||
DbSet<TEntity> set,
|
||||
Guid tenantId,
|
||||
Guid? id,
|
||||
string code,
|
||||
CancellationToken cancellationToken)
|
||||
where TEntity : class
|
||||
{
|
||||
if (!id.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var exists = await set.AnyAsync(
|
||||
item => EF.Property<Guid>(item, "TenantId") == tenantId && EF.Property<Guid>(item, "Id") == id.Value,
|
||||
cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
throw new CommerceException("Referenced commerce entity was not found.", code);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ApplyRefundToOrderAsync(CommerceRefundRequest refund, CancellationToken cancellationToken)
|
||||
{
|
||||
var order = await dbContext.Orders.SingleAsync(
|
||||
@@ -1004,6 +1453,17 @@ internal sealed class CommerceAdminService(
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsAllowedAdjustmentTransition(CommerceAdjustmentVoucherStatus from, CommerceAdjustmentVoucherStatus to)
|
||||
{
|
||||
return from switch
|
||||
{
|
||||
CommerceAdjustmentVoucherStatus.Draft => to is CommerceAdjustmentVoucherStatus.PendingReview or CommerceAdjustmentVoucherStatus.Void,
|
||||
CommerceAdjustmentVoucherStatus.PendingReview => to is CommerceAdjustmentVoucherStatus.Approved or CommerceAdjustmentVoucherStatus.Rejected or CommerceAdjustmentVoucherStatus.Void,
|
||||
CommerceAdjustmentVoucherStatus.Approved => to is CommerceAdjustmentVoucherStatus.Closed,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private void AddRefundEvent(
|
||||
CommerceRefundRequest refund,
|
||||
CommerceRefundStatus? fromStatus,
|
||||
@@ -1125,6 +1585,164 @@ internal sealed class CommerceAdminService(
|
||||
}
|
||||
}
|
||||
|
||||
private static ReconciliationImportPreview BuildImportPreview(string provider, JsonElement rows)
|
||||
{
|
||||
var normalizedProvider = NormalizeProvider(provider);
|
||||
var parsedRows = EnumerateImportRows(rows).ToArray();
|
||||
var paymentCount = parsedRows.Count(row => row.TransactionType == ReconciliationTransactionType.Payment);
|
||||
var refundCount = parsedRows.Count(row => row.TransactionType == ReconciliationTransactionType.Refund);
|
||||
var invalidCount = parsedRows.Count(row => row.MatchStatus != ReconciliationMatchStatus.Matched);
|
||||
var amountCents = parsedRows.Sum(row => row.AmountCents);
|
||||
var refundAmountCents = parsedRows.Sum(row => row.RefundAmountCents);
|
||||
var sourceHash = Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes($"{normalizedProvider}:{rows.GetRawText()}"))).ToLowerInvariant();
|
||||
return new ReconciliationImportPreview(
|
||||
parsedRows.Length,
|
||||
paymentCount,
|
||||
refundCount,
|
||||
invalidCount,
|
||||
amountCents,
|
||||
refundAmountCents,
|
||||
sourceHash);
|
||||
}
|
||||
|
||||
private sealed record ReconciliationImportRow(
|
||||
ReconciliationTransactionType TransactionType,
|
||||
string? ProviderTradeNo,
|
||||
string? ProviderRefundNo,
|
||||
string? OrderNo,
|
||||
string? RefundNo,
|
||||
int AmountCents,
|
||||
int RefundAmountCents,
|
||||
string? ProviderStatus,
|
||||
string? LocalStatus,
|
||||
ReconciliationMatchStatus MatchStatus,
|
||||
string? IssueCode,
|
||||
JsonElement Details);
|
||||
|
||||
private static IEnumerable<ReconciliationImportRow> EnumerateImportRows(JsonElement rows)
|
||||
{
|
||||
if (rows.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
throw new CommerceException("Reconciliation rows must be an array.", "invalid_reconciliation_rows");
|
||||
}
|
||||
|
||||
foreach (var row in rows.EnumerateArray())
|
||||
{
|
||||
if (row.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
yield return InvalidImportRow("row_not_object", row);
|
||||
continue;
|
||||
}
|
||||
|
||||
var transactionType = Enum.TryParse<ReconciliationTransactionType>(
|
||||
NormalizeEnum(GetJsonString(row, "transactionType", "transaction_type") ?? "payment"),
|
||||
true,
|
||||
out var parsedTransactionType)
|
||||
? parsedTransactionType
|
||||
: ReconciliationTransactionType.Payment;
|
||||
var amountCents = GetJsonInt(row, "amountCents", "amount_cents", "amount");
|
||||
var refundAmountCents = GetJsonInt(row, "refundAmountCents", "refund_amount_cents", "refundAmount");
|
||||
var providerTradeNo = GetJsonString(row, "providerTradeNo", "provider_trade_no", "tradeNo");
|
||||
var providerRefundNo = GetJsonString(row, "providerRefundNo", "provider_refund_no");
|
||||
var orderNo = GetJsonString(row, "orderNo", "order_no");
|
||||
var refundNo = GetJsonString(row, "refundNo", "refund_no");
|
||||
var issueCode = GetJsonString(row, "issueCode", "issue_code");
|
||||
var matchStatus = Enum.TryParse<ReconciliationMatchStatus>(
|
||||
NormalizeEnum(GetJsonString(row, "matchStatus", "match_status") ?? "matched"),
|
||||
true,
|
||||
out var parsedMatchStatus)
|
||||
? parsedMatchStatus
|
||||
: ReconciliationMatchStatus.AmountMismatch;
|
||||
if (string.IsNullOrWhiteSpace(providerTradeNo) &&
|
||||
string.IsNullOrWhiteSpace(providerRefundNo) &&
|
||||
string.IsNullOrWhiteSpace(orderNo) &&
|
||||
string.IsNullOrWhiteSpace(refundNo))
|
||||
{
|
||||
matchStatus = ReconciliationMatchStatus.MissingLocal;
|
||||
issueCode ??= "missing_business_identifier";
|
||||
}
|
||||
|
||||
yield return new ReconciliationImportRow(
|
||||
transactionType,
|
||||
providerTradeNo,
|
||||
providerRefundNo,
|
||||
orderNo,
|
||||
refundNo,
|
||||
Math.Max(0, amountCents),
|
||||
Math.Max(0, refundAmountCents),
|
||||
GetJsonString(row, "providerStatus", "provider_status"),
|
||||
GetJsonString(row, "localStatus", "local_status"),
|
||||
matchStatus,
|
||||
issueCode,
|
||||
row.Clone());
|
||||
}
|
||||
}
|
||||
|
||||
private static ReconciliationImportRow InvalidImportRow(string issueCode, JsonElement row) =>
|
||||
new(
|
||||
ReconciliationTransactionType.Payment,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
ReconciliationMatchStatus.AmountMismatch,
|
||||
issueCode,
|
||||
row.Clone());
|
||||
|
||||
private static CommerceReconciliationItem CreateReconciliationItem(
|
||||
Guid tenantId,
|
||||
Guid batchId,
|
||||
int rowNo,
|
||||
string provider,
|
||||
ReconciliationImportRow row) =>
|
||||
new()
|
||||
{
|
||||
TenantId = tenantId,
|
||||
BatchId = batchId,
|
||||
RowNo = rowNo,
|
||||
Provider = provider,
|
||||
TransactionType = row.TransactionType,
|
||||
ProviderTradeNo = row.ProviderTradeNo,
|
||||
ProviderRefundNo = row.ProviderRefundNo,
|
||||
OrderNo = row.OrderNo,
|
||||
RefundNo = row.RefundNo,
|
||||
AmountCents = row.AmountCents,
|
||||
RefundAmountCents = row.RefundAmountCents,
|
||||
ProviderStatus = row.ProviderStatus,
|
||||
LocalStatus = row.LocalStatus,
|
||||
MatchStatus = row.MatchStatus,
|
||||
Severity = row.MatchStatus == ReconciliationMatchStatus.Matched ? NotificationSeverity.Info : NotificationSeverity.Warning,
|
||||
IssueCode = row.IssueCode,
|
||||
Details = row.Details
|
||||
};
|
||||
|
||||
private static int GetJsonInt(JsonElement element, params string[] keys)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (!element.TryGetProperty(key, out var value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number))
|
||||
{
|
||||
return number;
|
||||
}
|
||||
|
||||
if (value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), CultureInfo.InvariantCulture, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static string GenerateActivationCode()
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[8];
|
||||
|
||||
Reference in New Issue
Block a user