refactor(commerce): split administration services

This commit is contained in:
2026-08-04 09:29:20 +08:00
parent 4d85463a87
commit 0bd075e20d
29 changed files with 1187 additions and 952 deletions

View File

@@ -6,7 +6,8 @@ using Tiku.Domain.Tenancy;
namespace Tiku.Infrastructure.Commerce;
internal sealed partial class CommerceAdminService
internal sealed class ActivationCodeAdministrationService(CommerceAdministrationDependencies dependencies)
: CommerceAdministrationServiceBase(dependencies), IActivationCodeAdministrationService
{
public async Task<CodeBatchItem> CreateCodeBatchAsync(
CommerceAdminActor actor,
@@ -124,4 +125,4 @@ internal sealed partial class CommerceAdminService
await dbContext.SaveChangesAsync(cancellationToken);
return ToActivationCodeItem(code);
}
}
}

View File

@@ -6,7 +6,8 @@ using Tiku.Domain.Commerce;
namespace Tiku.Infrastructure.Commerce;
internal sealed partial class CommerceAdminService
internal sealed class CommerceAdjustmentService(CommerceAdministrationDependencies dependencies)
: CommerceAdministrationServiceBase(dependencies), ICommerceAdjustmentService
{
public async Task<TenantAdjustmentVoucherList> GetAdjustmentVouchersAsync(
CommerceAdminActor actor,
@@ -182,46 +183,7 @@ internal sealed partial class CommerceAdminService
.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(
public async Task<TenantCommerceAnomalySummary> GetAnomalySummaryAsync(
CommerceAdminActor actor,
CancellationToken cancellationToken = default)
{
@@ -256,187 +218,4 @@ internal sealed partial class CommerceAdminService
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;
}
}
}

View File

@@ -1,17 +0,0 @@
using Tiku.Application.Commerce;
using Tiku.Application.Jobs;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Commerce;
internal sealed partial class CommerceAdminService(
TikuDbContext dbContext,
ITenantSecretProtector tenantSecretProtector,
ITenantExternalProviderConfigService providerConfigService,
ICurrentAccessContext currentAccessContext,
IBackgroundJobQueue backgroundJobQueue,
IBackgroundJobOperations backgroundJobOperations) : ICommerceAdminService
{
}

View File

@@ -4,7 +4,8 @@ using Tiku.Domain.Commerce;
namespace Tiku.Infrastructure.Commerce;
internal sealed partial class CommerceAdminService
internal sealed class CouponAdministrationService(CommerceAdministrationDependencies dependencies)
: CommerceAdministrationServiceBase(dependencies), ICouponAdministrationService
{
public async Task<TenantCouponList> GetCouponsAsync(
CommerceAdminActor actor,
@@ -90,4 +91,4 @@ internal sealed partial class CommerceAdminService
.SumAsync(item => item.DiscountAppliedCents, cancellationToken) ?? 0;
return new TenantCouponReport(couponCount, claimedCount, usedCount, discountApplied);
}
}
}

View File

@@ -13,9 +13,9 @@ using NotificationSeverity = Tiku.Domain.Commerce.NotificationSeverity;
namespace Tiku.Infrastructure.Commerce;
internal sealed partial class CommerceAdminService
internal abstract partial class CommerceAdministrationServiceBase
{
private async Task AssertAdminAsync(CommerceAdminActor actor, CancellationToken cancellationToken)
protected async Task AssertAdminAsync(CommerceAdminActor actor, CancellationToken cancellationToken)
{
var access = await currentAccessContext.GetAsync(cancellationToken);
if (!access.IsCurrentTenantMember ||
@@ -25,7 +25,7 @@ internal sealed partial class CommerceAdminService
throw new CommerceException("Tenant admin access is required.", "tenant_admin_access_denied");
}
private async Task<CurrentDataScope> RequireDataScopeAsync(
protected async Task<CurrentDataScope> RequireDataScopeAsync(
CommerceAdminActor actor,
CancellationToken cancellationToken)
{
@@ -33,7 +33,7 @@ internal sealed partial class CommerceAdminService
return (await currentAccessContext.GetAsync(cancellationToken)).DataScope;
}
private static TenantPaymentProviderItem ToPaymentAccountItem(TenantExternalProviderItem item)
protected static TenantPaymentProviderItem ToPaymentAccountItem(TenantExternalProviderItem item)
{
return new TenantPaymentProviderItem(
item.Id,
@@ -48,28 +48,28 @@ internal sealed partial class CommerceAdminService
item.UpdatedAt);
}
private static TenantSecretItem ToSecretItem(TenantSecret item)
protected static TenantSecretItem ToSecretItem(TenantSecret item)
{
return new TenantSecretItem(item.Id, item.Purpose, item.Provider, item.SecretKey, item.SecretRef,
item.Status.ToString(),
item.RotatedAt, item.ExpiresAt, item.UpdatedAt);
}
private static CodeBatchItem ToCodeBatchItem(CodeBatch item)
protected static CodeBatchItem ToCodeBatchItem(CodeBatch item)
{
return new CodeBatchItem(item.Id, item.Name, item.TotalCount, item.Days ?? 0, item.RegionId, item.SaleType,
item.Channel,
item.DefaultUnitPriceCents, item.CostPriceCents, item.IssuedAt, item.Remark, item.CreatedAt);
}
private static ActivationCodeItem ToActivationCodeItem(ActivationCode item)
protected static ActivationCodeItem ToActivationCodeItem(ActivationCode item)
{
return new ActivationCodeItem(item.Id, item.BatchId, item.Code, item.Days, item.IsUsed, item.UsedBy,
item.UsedAt, item.SaleType,
item.SoldTo, item.Remark, item.CreatedAt);
}
private static CommerceOrderItem ToOrderItem(Order order)
protected static CommerceOrderItem ToOrderItem(Order order)
{
return new CommerceOrderItem(order.Id, order.OrderNo, order.Status.ToString(), order.PlanId, order.RegionId,
order.ProductType,
@@ -77,7 +77,7 @@ internal sealed partial class CommerceAdminService
order.TradeNo, order.Days, order.PaidAt, order.CreatedAt, order.RawPayload);
}
private static CommercePaymentItem ToPaymentItem(Payment payment, string orderNo)
protected static CommercePaymentItem ToPaymentItem(Payment payment, string orderNo)
{
return new CommercePaymentItem(payment.Id, payment.OrderId, orderNo, payment.Provider, payment.Method,
payment.Status.ToString(),
@@ -85,28 +85,28 @@ internal sealed partial class CommerceAdminService
JsonSerializer.SerializeToElement(new { }), payment.RawPayload);
}
private static OrderStatus ParseOrderStatus(string? status)
protected static OrderStatus ParseOrderStatus(string? status)
{
return Enum.TryParse<OrderStatus>(NormalizeEnum(status), true, out var parsed)
? parsed
: throw new CommerceException("Order status is invalid.", "invalid_order_status");
}
private static PaymentStatus ParsePaymentStatus(string? status)
protected static PaymentStatus ParsePaymentStatus(string? status)
{
return Enum.TryParse<PaymentStatus>(NormalizeEnum(status), true, out var parsed)
? parsed
: throw new CommerceException("Payment status is invalid.", "invalid_payment_status");
}
private static PointActivityTaskStatus ParsePointTaskStatus(string? status)
protected static PointActivityTaskStatus ParsePointTaskStatus(string? status)
{
return Enum.TryParse<PointActivityTaskStatus>(NormalizeEnum(status), true, out var parsed)
? parsed
: throw new CommerceException("Point task status is invalid.", "invalid_point_task_status");
}
private static PointExchangeItemStatus ParsePointExchangeItemStatus(string? status)
protected static PointExchangeItemStatus ParsePointExchangeItemStatus(string? status)
{
return Enum.TryParse<PointExchangeItemStatus>(NormalizeEnum(status), true, out var parsed)
? parsed
@@ -114,7 +114,7 @@ internal sealed partial class CommerceAdminService
"invalid_point_exchange_item_status");
}
private static PointExchangeOrderStatus ParsePointExchangeOrderStatus(string? status)
protected static PointExchangeOrderStatus ParsePointExchangeOrderStatus(string? status)
{
return Enum.TryParse<PointExchangeOrderStatus>(NormalizeEnum(status), true, out var parsed)
? parsed
@@ -122,21 +122,21 @@ internal sealed partial class CommerceAdminService
"invalid_point_exchange_order_status");
}
private static CouponRedemptionStatus ParseCouponRedemptionStatus(string? status)
protected static CouponRedemptionStatus ParseCouponRedemptionStatus(string? status)
{
return Enum.TryParse<CouponRedemptionStatus>(NormalizeEnum(status), true, out var parsed)
? parsed
: throw new CommerceException("Coupon redemption status is invalid.", "invalid_coupon_redemption_status");
}
private static CommerceRefundStatus ParseRefundStatus(string? status)
protected static CommerceRefundStatus ParseRefundStatus(string? status)
{
return 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)
protected static ReconciliationBatchStatus ParseReconciliationBatchStatus(string? status)
{
return Enum.TryParse<ReconciliationBatchStatus>(NormalizeEnum(status), true, out var parsed)
? parsed
@@ -144,7 +144,7 @@ internal sealed partial class CommerceAdminService
"invalid_reconciliation_batch_status");
}
private static ReconciliationIssueStatus ParseReconciliationIssueStatus(string? status)
protected static ReconciliationIssueStatus ParseReconciliationIssueStatus(string? status)
{
return Enum.TryParse<ReconciliationIssueStatus>(NormalizeEnum(status), true, out var parsed)
? parsed
@@ -152,14 +152,14 @@ internal sealed partial class CommerceAdminService
"invalid_reconciliation_issue_status");
}
private static CommerceAdjustmentVoucherStatus ParseAdjustmentVoucherStatus(string? status)
protected static CommerceAdjustmentVoucherStatus ParseAdjustmentVoucherStatus(string? status)
{
return 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>(
protected async Task AssertOptionalReferenceAsync<TEntity>(
DbSet<TEntity> set,
Guid tenantId,
Guid? id,
@@ -175,7 +175,7 @@ internal sealed partial class CommerceAdminService
if (!exists) throw new CommerceException("Referenced commerce entity was not found.", code);
}
private async Task ApplyRefundToOrderAsync(CommerceRefundRequest refund, CancellationToken cancellationToken)
protected async Task ApplyRefundToOrderAsync(CommerceRefundRequest refund, CancellationToken cancellationToken)
{
var order = await dbContext.Orders.SingleAsync(
item => item.TenantId == refund.TenantId && item.Id == refund.OrderId,
@@ -204,7 +204,7 @@ internal sealed partial class CommerceAdminService
}
}
private static bool IsAllowedRefundTransition(CommerceRefundStatus from, CommerceRefundStatus to)
protected static bool IsAllowedRefundTransition(CommerceRefundStatus from, CommerceRefundStatus to)
{
return from switch
{
@@ -217,7 +217,7 @@ internal sealed partial class CommerceAdminService
};
}
private static bool IsAllowedAdjustmentTransition(CommerceAdjustmentVoucherStatus from,
protected static bool IsAllowedAdjustmentTransition(CommerceAdjustmentVoucherStatus from,
CommerceAdjustmentVoucherStatus to)
{
return from switch
@@ -231,7 +231,7 @@ internal sealed partial class CommerceAdminService
};
}
private void AddRefundEvent(
protected void AddRefundEvent(
CommerceRefundRequest refund,
CommerceRefundStatus? fromStatus,
CommerceRefundStatus toStatus,
@@ -251,7 +251,7 @@ internal sealed partial class CommerceAdminService
});
}
private Task AddAuditAsync(
protected Task AddAuditAsync(
CommerceAdminActor actor,
string action,
string targetType,
@@ -272,12 +272,12 @@ internal sealed partial class CommerceAdminService
return Task.CompletedTask;
}
private static string NormalizeEnum(string? value)
protected static string NormalizeEnum(string? value)
{
return string.Concat((value ?? string.Empty).Split(['_', '-', ' '], StringSplitOptions.RemoveEmptyEntries));
}
private static string NormalizeProvider(string? provider)
protected static string NormalizeProvider(string? provider)
{
var normalized = (provider ?? string.Empty).Trim().ToLowerInvariant()
.Replace("-", "_", StringComparison.Ordinal);
@@ -290,7 +290,7 @@ internal sealed partial class CommerceAdminService
};
}
private static JsonElement WithPaymentMode(JsonElement element, string? mode)
protected static JsonElement WithPaymentMode(JsonElement element, string? mode)
{
var values = new Dictionary<string, JsonElement>(StringComparer.Ordinal);
if (element.ValueKind == JsonValueKind.Object)
@@ -302,7 +302,7 @@ internal sealed partial class CommerceAdminService
return JsonSerializer.SerializeToElement(values);
}
private static string? GetJsonString(JsonElement element, params string[] keys)
protected static string? GetJsonString(JsonElement element, params string[] keys)
{
if (element.ValueKind != JsonValueKind.Object) return null;
@@ -313,14 +313,14 @@ internal sealed partial class CommerceAdminService
return null;
}
private static JsonElement JsonObjectOrDefault(JsonElement element)
protected static JsonElement JsonObjectOrDefault(JsonElement element)
{
return element.ValueKind == JsonValueKind.Object
? element.Clone()
: JsonSerializer.SerializeToElement(new { });
}
private static void AssertNoSecrets(JsonElement element, string path)
protected static void AssertNoSecrets(JsonElement element, string path)
{
if (element.ValueKind != JsonValueKind.Object) return;
@@ -338,7 +338,7 @@ internal sealed partial class CommerceAdminService
}
}
private static ReconciliationImportPreview BuildImportPreview(string provider, JsonElement rows)
protected static ReconciliationImportPreview BuildImportPreview(string provider, JsonElement rows)
{
var normalizedProvider = NormalizeProvider(provider);
var parsedRows = EnumerateImportRows(rows).ToArray();
@@ -360,7 +360,7 @@ internal sealed partial class CommerceAdminService
sourceHash);
}
private static IEnumerable<ReconciliationImportRow> EnumerateImportRows(JsonElement rows)
protected static IEnumerable<ReconciliationImportRow> EnumerateImportRows(JsonElement rows)
{
if (rows.ValueKind != JsonValueKind.Array)
throw new CommerceException("Reconciliation rows must be an array.", "invalid_reconciliation_rows");
@@ -417,7 +417,7 @@ internal sealed partial class CommerceAdminService
}
}
private static ReconciliationImportRow InvalidImportRow(string issueCode, JsonElement row)
protected static ReconciliationImportRow InvalidImportRow(string issueCode, JsonElement row)
{
return new ReconciliationImportRow(
ReconciliationTransactionType.Payment,
@@ -434,7 +434,7 @@ internal sealed partial class CommerceAdminService
row.Clone());
}
private static CommerceReconciliationItem CreateReconciliationItem(
protected static CommerceReconciliationItem CreateReconciliationItem(
Guid tenantId,
Guid batchId,
int rowNo,
@@ -465,7 +465,7 @@ internal sealed partial class CommerceAdminService
};
}
private static int GetJsonInt(JsonElement element, params string[] keys)
protected static int GetJsonInt(JsonElement element, params string[] keys)
{
foreach (var key in keys)
{
@@ -480,19 +480,19 @@ internal sealed partial class CommerceAdminService
return 0;
}
private static string GenerateActivationCode()
protected static string GenerateActivationCode()
{
Span<byte> bytes = stackalloc byte[8];
RandomNumberGenerator.Fill(bytes);
return $"TKU{Convert.ToHexString(bytes)}";
}
private static string FormatCny(int cents)
protected static string FormatCny(int cents)
{
return (cents / 100m).ToString("0.00", CultureInfo.InvariantCulture);
}
private sealed record ReconciliationImportRow(
protected sealed record ReconciliationImportRow(
ReconciliationTransactionType TransactionType,
string? ProviderTradeNo,
string? ProviderRefundNo,
@@ -505,4 +505,4 @@ internal sealed partial class CommerceAdminService
ReconciliationMatchStatus MatchStatus,
string? IssueCode,
JsonElement Details);
}
}

View File

@@ -0,0 +1,25 @@
using Tiku.Application.Commerce;
using Tiku.Application.Jobs;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Commerce;
internal sealed record CommerceAdministrationDependencies(
TikuDbContext DbContext,
ITenantSecretProtector TenantSecretProtector,
ITenantExternalProviderConfigService ProviderConfigService,
ICurrentAccessContext CurrentAccessContext,
IBackgroundJobQueue BackgroundJobQueue,
IBackgroundJobOperations BackgroundJobOperations);
internal abstract partial class CommerceAdministrationServiceBase(CommerceAdministrationDependencies dependencies)
{
protected TikuDbContext dbContext { get; } = dependencies.DbContext;
protected ITenantSecretProtector tenantSecretProtector { get; } = dependencies.TenantSecretProtector;
protected ITenantExternalProviderConfigService providerConfigService { get; } = dependencies.ProviderConfigService;
protected ICurrentAccessContext currentAccessContext { get; } = dependencies.CurrentAccessContext;
protected IBackgroundJobQueue backgroundJobQueue { get; } = dependencies.BackgroundJobQueue;
protected IBackgroundJobOperations backgroundJobOperations { get; } = dependencies.BackgroundJobOperations;
}

View File

@@ -4,7 +4,8 @@ using Tiku.Infrastructure.Security;
namespace Tiku.Infrastructure.Commerce;
internal sealed partial class CommerceAdminService
internal sealed class CommerceOrderAdministrationService(CommerceAdministrationDependencies dependencies)
: CommerceAdministrationServiceBase(dependencies), ICommerceOrderAdministrationService
{
public async Task<AdminOrderList> GetOrdersAsync(
CommerceAdminActor actor,
@@ -64,4 +65,4 @@ internal sealed partial class CommerceAdminService
.ToArrayAsync(cancellationToken);
return new AdminPaymentList(rows.Select(item => ToPaymentItem(item.payment, item.OrderNo)).ToArray());
}
}
}

View File

@@ -5,7 +5,8 @@ using Tiku.Domain.Tenancy;
namespace Tiku.Infrastructure.Commerce;
internal sealed partial class CommerceAdminService
internal sealed class PaymentConfigurationService(CommerceAdministrationDependencies dependencies)
: CommerceAdministrationServiceBase(dependencies), IPaymentConfigurationService
{
public async Task<IReadOnlyCollection<TenantPaymentProviderItem>> GetPaymentAccountsAsync(
CommerceAdminActor actor,
@@ -90,4 +91,4 @@ internal sealed partial class CommerceAdminService
await dbContext.SaveChangesAsync(cancellationToken);
return ToSecretItem(secret);
}
}
}

View File

@@ -1,192 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Commerce;
using Tiku.Domain.Commerce;
namespace Tiku.Infrastructure.Commerce;
internal sealed partial class CommerceAdminService
{
public async Task<TenantPointTaskList> GetPointTasksAsync(
CommerceAdminActor actor,
TenantPointQuery query,
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var tasks = dbContext.PointActivityTasks.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId);
if (!string.IsNullOrWhiteSpace(query.Status))
tasks = tasks.Where(item => item.Status == ParsePointTaskStatus(query.Status));
var items = await tasks
.OrderBy(item => item.SortOrder)
.ThenByDescending(item => item.CreatedAt)
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
.ToArrayAsync(cancellationToken);
return new TenantPointTaskList(items);
}
public async Task<PointActivityTask> UpsertPointTaskAsync(
CommerceAdminActor actor,
UpsertPointTaskCommand command,
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
if (command.Points <= 0 || command.MaxClaimsPerUser <= 0)
throw new CommerceException("Point task points and claim limit must be positive.", "invalid_point_task");
var task = command.Id.HasValue
? await dbContext.PointActivityTasks.SingleOrDefaultAsync(
item => item.TenantId == actor.TenantId && item.Id == command.Id.Value,
cancellationToken)
: await dbContext.PointActivityTasks.SingleOrDefaultAsync(
item => item.TenantId == actor.TenantId && item.TaskKey == command.TaskKey.Trim(),
cancellationToken);
if (task is null)
{
task = new PointActivityTask { TenantId = actor.TenantId };
dbContext.PointActivityTasks.Add(task);
}
task.TaskKey = command.TaskKey.Trim();
task.Title = command.Title.Trim();
task.Description = command.Description?.Trim();
task.TaskType = command.TaskType;
task.Status = command.Status;
task.Points = command.Points;
task.MaxClaimsPerUser = command.MaxClaimsPerUser;
task.StartsAt = command.StartsAt;
task.EndsAt = command.EndsAt;
task.SortOrder = command.SortOrder;
task.Rules = JsonObjectOrDefault(command.Rules);
task.Metadata = JsonObjectOrDefault(command.Metadata);
await dbContext.SaveChangesAsync(cancellationToken);
return task;
}
public async Task<TenantPointClaimList> GetPointClaimsAsync(
CommerceAdminActor actor,
TenantPointQuery query,
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var claims = dbContext.PointActivityClaims.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId);
if (query.UserId.HasValue) claims = claims.Where(item => item.UserId == query.UserId.Value);
var items = await claims
.OrderByDescending(item => item.CreatedAt)
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
.ToArrayAsync(cancellationToken);
return new TenantPointClaimList(items);
}
public async Task<TenantPointExchangeItemList> GetPointExchangeItemsAsync(
CommerceAdminActor actor,
TenantPointQuery query,
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var items = dbContext.PointExchangeItems.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId);
if (!string.IsNullOrWhiteSpace(query.Status))
items = items.Where(item => item.Status == ParsePointExchangeItemStatus(query.Status));
if (query.RegionId.HasValue)
items = items.Where(item => item.RegionId == null || item.RegionId == query.RegionId.Value);
var result = await items
.OrderBy(item => item.SortOrder)
.ThenByDescending(item => item.CreatedAt)
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
.ToArrayAsync(cancellationToken);
return new TenantPointExchangeItemList(result);
}
public async Task<PointExchangeItem> UpsertPointExchangeItemAsync(
CommerceAdminActor actor,
UpsertPointExchangeItemCommand command,
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
if (command.PointsCost <= 0)
throw new CommerceException("Point exchange item cost must be positive.", "invalid_point_exchange_item");
var item = command.Id.HasValue
? await dbContext.PointExchangeItems.SingleOrDefaultAsync(
entry => entry.TenantId == actor.TenantId && entry.Id == command.Id.Value,
cancellationToken)
: await dbContext.PointExchangeItems.SingleOrDefaultAsync(
entry => entry.TenantId == actor.TenantId && entry.ItemKey == command.ItemKey.Trim(),
cancellationToken);
if (item is null)
{
item = new PointExchangeItem { TenantId = actor.TenantId };
dbContext.PointExchangeItems.Add(item);
}
item.RegionId = command.RegionId;
item.ItemKey = command.ItemKey.Trim();
item.Name = command.Name.Trim();
item.Description = command.Description?.Trim();
item.ItemType = command.ItemType;
item.Status = command.Status;
item.PointsCost = command.PointsCost;
item.Stock = command.Stock;
item.Days = command.Days;
item.SortOrder = command.SortOrder;
item.StartsAt = command.StartsAt;
item.EndsAt = command.EndsAt;
item.FulfillmentPayload = JsonObjectOrDefault(command.FulfillmentPayload);
item.Metadata = JsonObjectOrDefault(command.Metadata);
await dbContext.SaveChangesAsync(cancellationToken);
return item;
}
public async Task<TenantPointExchangeOrderList> GetPointExchangeOrdersAsync(
CommerceAdminActor actor,
TenantPointQuery query,
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var orders = dbContext.PointExchangeOrders.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId);
if (query.UserId.HasValue) orders = orders.Where(item => item.UserId == query.UserId.Value);
if (!string.IsNullOrWhiteSpace(query.Status))
orders = orders.Where(item => item.Status == ParsePointExchangeOrderStatus(query.Status));
var result = await orders
.OrderByDescending(item => item.CreatedAt)
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
.ToArrayAsync(cancellationToken);
return new TenantPointExchangeOrderList(result);
}
public async Task<PointExchangeOrder> UpdatePointExchangeOrderStatusAsync(
CommerceAdminActor actor,
UpdatePointExchangeOrderStatusCommand command,
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var order = await dbContext.PointExchangeOrders
.SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Id == command.OrderId,
cancellationToken)
?? throw new CommerceException("Point exchange order was not found.",
"point_exchange_order_not_found");
order.Status = command.Status;
if (command.Status == PointExchangeOrderStatus.Completed)
{
order.CompletedAt ??= DateTimeOffset.UtcNow;
order.CancelledAt = null;
}
else if (command.Status == PointExchangeOrderStatus.Cancelled)
{
order.CancelledAt ??= DateTimeOffset.UtcNow;
}
await dbContext.SaveChangesAsync(cancellationToken);
return order;
}
}

View File

@@ -0,0 +1,170 @@
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 ReconciliationAdministrationService
{
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<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);
}
}

View File

@@ -5,7 +5,8 @@ using Tiku.Domain.Commerce;
namespace Tiku.Infrastructure.Commerce;
internal sealed partial class CommerceAdminService
internal sealed partial class ReconciliationAdministrationService(CommerceAdministrationDependencies dependencies)
: CommerceAdministrationServiceBase(dependencies), IReconciliationAdministrationService
{
public async Task<TenantReconciliationBatchList> GetReconciliationBatchesAsync(
CommerceAdminActor actor,
@@ -117,4 +118,4 @@ internal sealed partial class CommerceAdminService
await dbContext.SaveChangesAsync(cancellationToken);
return issue;
}
}
}

View File

@@ -0,0 +1,72 @@
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 RefundAdministrationService
{
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;
}
}

View File

@@ -6,7 +6,8 @@ using Tiku.Infrastructure.Security;
namespace Tiku.Infrastructure.Commerce;
internal sealed partial class CommerceAdminService
internal sealed partial class RefundAdministrationService(CommerceAdministrationDependencies dependencies)
: CommerceAdministrationServiceBase(dependencies), IRefundAdministrationService
{
public async Task<TenantRefundList> GetRefundsAsync(
CommerceAdminActor actor,
@@ -180,4 +181,4 @@ internal sealed partial class CommerceAdminService
.ToArrayAsync(cancellationToken);
return new TenantRefundEventList(items);
}
}
}