using System.Globalization; using System.Security.Cryptography; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Commerce; using Tiku.Application.Security; using Tiku.Application.Tenancy; using Tiku.Domain.Catalog; using Tiku.Domain.Commerce; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Commerce; internal sealed class CommerceAdminService( TikuDbContext dbContext, ITenantSecretProtector tenantSecretProtector, ITenantExternalProviderConfigService providerConfigService, ICurrentAccessContext currentAccessContext) : ICommerceAdminService { public async Task> GetPaymentAccountsAsync( CommerceAdminActor actor, CommerceAdminQuery query, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var accounts = await providerConfigService.GetProvidersAsync( actor.TenantId, TenantExternalProviderCapability.Payment, string.IsNullOrWhiteSpace(query.Provider) ? null : NormalizeProvider(query.Provider), query.Limit, cancellationToken); return accounts.Select(ToPaymentAccountItem).ToArray(); } public async Task UpsertPaymentAccountAsync( CommerceAdminActor actor, UpsertPaymentAccountCommand command, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var account = await providerConfigService.UpsertProviderAsync( actor.TenantId, new UpsertTenantExternalProviderCommand( TenantExternalProviderCapability.Payment, command.Provider, command.Status, command.DisplayName, command.SecretRef, command.Priority, WithPaymentMode(command.ConfigPublic, command.Mode), JsonObjectOrDefault(default)), cancellationToken); return ToPaymentAccountItem(account); } public async Task UpsertTenantSecretAsync( CommerceAdminActor actor, UpsertTenantSecretCommand command, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var secretRef = string.IsNullOrWhiteSpace(command.SecretRef) ? $"tenant_secrets:{command.Purpose}:{NormalizeProvider(command.Provider)}:{command.SecretKey}" : command.SecretRef.Trim(); var provider = NormalizeProvider(command.Provider); var secret = await dbContext.TenantSecrets .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.SecretRef == secretRef, cancellationToken); if (secret is null) { secret = new TenantSecret { TenantId = actor.TenantId, SecretRef = secretRef }; dbContext.TenantSecrets.Add(secret); } else { secret.RotatedAt = DateTimeOffset.UtcNow; } var protectedPayload = tenantSecretProtector.Protect( actor.TenantId, secretRef, JsonObjectOrDefault(command.SecretPayload)); secret.Purpose = command.Purpose.Trim(); secret.Provider = provider; secret.SecretKey = command.SecretKey.Trim(); secret.Status = command.Status; secret.EncryptionKeyId = protectedPayload.KeyId; secret.EncryptedPayload = protectedPayload.Ciphertext; secret.EncryptionNonce = protectedPayload.Nonce; secret.EncryptionTag = protectedPayload.Tag; secret.ExpiresAt = command.ExpiresAt; await dbContext.SaveChangesAsync(cancellationToken); return ToSecretItem(secret); } public async Task GetOrdersAsync( CommerceAdminActor actor, CommerceAdminQuery query, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var orders = dbContext.Orders.AsNoTracking() .Where(item => item.TenantId == actor.TenantId) .ApplyDataScope( scope, item => item.UserId == actor.UserId, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); if (!string.IsNullOrWhiteSpace(query.Status)) { orders = orders.Where(item => item.Status == ParseOrderStatus(query.Status)); } var items = await orders .OrderByDescending(item => item.CreatedAt) .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) .ToArrayAsync(cancellationToken); return new AdminOrderList(items.Select(ToOrderItem).ToArray()); } public async Task GetPaymentsAsync( CommerceAdminActor actor, CommerceAdminQuery query, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var scopedOrders = dbContext.Orders.AsNoTracking() .Where(order => order.TenantId == actor.TenantId) .ApplyDataScope( scope, order => order.UserId == actor.UserId, order => order.RegionId.HasValue && regionIds.Contains(order.RegionId.Value)); var payments = from payment in dbContext.Payments.AsNoTracking() join order in scopedOrders on new { payment.TenantId, payment.OrderId } equals new { order.TenantId, OrderId = order.Id } where payment.TenantId == actor.TenantId select new { payment, order.OrderNo }; if (!string.IsNullOrWhiteSpace(query.Provider)) { var provider = NormalizeProvider(query.Provider); payments = payments.Where(item => item.payment.Provider == provider); } if (!string.IsNullOrWhiteSpace(query.Status)) { payments = payments.Where(item => item.payment.Status == ParsePaymentStatus(query.Status)); } var rows = await payments .OrderByDescending(item => item.payment.CreatedAt) .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) .ToArrayAsync(cancellationToken); return new AdminPaymentList(rows.Select(item => ToPaymentItem(item.payment, item.OrderNo)).ToArray()); } public async Task CreateCodeBatchAsync( CommerceAdminActor actor, CreateCodeBatchCommand command, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); if (command.TotalCount is < 1 or > 1000) { throw new CommerceException("Code batch total count must be between 1 and 1000.", "invalid_code_batch_count"); } if (command.Days <= 0) { throw new CommerceException("Activation code days must be positive.", "invalid_activation_days"); } if (command.RegionId.HasValue) { var regionExists = await dbContext.Regions .AnyAsync(item => item.TenantId == actor.TenantId && item.Id == command.RegionId.Value, cancellationToken); if (!regionExists) { throw new CommerceException("Region was not found.", "region_not_found"); } } var batch = new CodeBatch { TenantId = actor.TenantId, RegionId = command.RegionId, CreatedBy = actor.UserId, Name = command.Name.Trim(), SaleType = command.SaleType?.Trim(), Channel = command.Channel?.Trim(), DefaultUnitPriceCents = command.DefaultUnitPriceCents ?? 0, CostPriceCents = command.CostPriceCents ?? 0, TotalCount = command.TotalCount, Days = command.Days, IssuedAt = DateTimeOffset.UtcNow, Remark = command.Remark }; dbContext.CodeBatches.Add(batch); for (var index = 0; index < command.TotalCount; index++) { dbContext.ActivationCodes.Add(new ActivationCode { TenantId = actor.TenantId, BatchId = batch.Id, Code = GenerateActivationCode(), Days = command.Days, SaleType = batch.SaleType, UnitPriceCents = batch.DefaultUnitPriceCents, Remark = batch.Remark }); } await dbContext.SaveChangesAsync(cancellationToken); return ToCodeBatchItem(batch); } public async Task GetActivationCodesAsync( CommerceAdminActor actor, CommerceAdminQuery query, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var codes = dbContext.ActivationCodes.AsNoTracking() .Where(item => item.TenantId == actor.TenantId); if (!string.IsNullOrWhiteSpace(query.Status)) { var used = string.Equals(query.Status, "used", StringComparison.OrdinalIgnoreCase); codes = codes.Where(item => item.IsUsed == used); } var items = await codes .OrderByDescending(item => item.CreatedAt) .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) .ToArrayAsync(cancellationToken); return new ActivationCodeList(items.Select(ToActivationCodeItem).ToArray()); } public async Task RedeemActivationCodeAsync( CommerceAdminActor actor, RedeemActivationCodeCommand command, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var code = await dbContext.ActivationCodes .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Code == command.Code.Trim(), cancellationToken) ?? throw new CommerceException("Activation code was not found.", "activation_code_not_found"); if (code.IsUsed) { throw new CommerceException("Activation code has already been used.", "activation_code_used"); } var userIsMember = await dbContext.TenantMemberships.AnyAsync(item => item.TenantId == actor.TenantId && item.UserId == command.UserId && item.Status == MembershipStatus.Active, cancellationToken); if (!userIsMember) { throw new CommerceException("Target user is not a tenant member.", "tenant_member_not_found"); } code.IsUsed = true; code.UsedBy = command.UserId; code.UsedRegionId = command.RegionId; code.UsedAt = DateTimeOffset.UtcNow; dbContext.Entitlements.Add(new Entitlement { TenantId = actor.TenantId, UserId = command.UserId, EntitlementType = "svip", SourceType = "activation_code", SourceId = code.Id, StartsAt = DateTimeOffset.UtcNow, ExpiresAt = DateTimeOffset.UtcNow.AddDays(code.Days), Status = EntitlementStatus.Active, Metadata = JsonSerializer.SerializeToElement(new { code.Code, code.BatchId }) }); await dbContext.SaveChangesAsync(cancellationToken); return ToActivationCodeItem(code); } public async Task 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 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 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 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 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 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 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; } public async Task GetCouponsAsync( CommerceAdminActor actor, CommerceAdminQuery query, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var coupons = dbContext.Coupons.AsNoTracking() .Where(item => item.TenantId == actor.TenantId); var items = await coupons .OrderByDescending(item => item.CreatedAt) .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) .ToArrayAsync(cancellationToken); return new TenantCouponList(items); } public async Task UpsertCouponAsync( CommerceAdminActor actor, UpsertCouponCommand command, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var coupon = command.Id.HasValue ? await dbContext.Coupons.SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.Id == command.Id.Value, cancellationToken) : await dbContext.Coupons.SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.Code == command.Code.Trim(), cancellationToken); if (coupon is null) { coupon = new Coupon { TenantId = actor.TenantId }; dbContext.Coupons.Add(coupon); } coupon.Code = command.Code.Trim(); coupon.PlanId = command.PlanId; coupon.DiscountType = command.DiscountType; coupon.DiscountValue = command.DiscountValue; coupon.ValidFrom = command.ValidFrom; coupon.ValidTo = command.ValidTo; coupon.MaxUses = command.MaxUses; coupon.Source = command.Source?.Trim(); coupon.Remark = command.Remark; await dbContext.SaveChangesAsync(cancellationToken); return coupon; } public async Task GetCouponRedemptionsAsync( CommerceAdminActor actor, CommerceAdminQuery query, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var redemptions = dbContext.CouponRedemptions.AsNoTracking() .Where(item => item.TenantId == actor.TenantId); if (!string.IsNullOrWhiteSpace(query.Status)) { redemptions = redemptions.Where(item => item.Status == ParseCouponRedemptionStatus(query.Status)); } var items = await redemptions .OrderByDescending(item => item.CreatedAt) .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) .ToArrayAsync(cancellationToken); return new TenantCouponRedemptionList(items); } public async Task GetCouponReportAsync( CommerceAdminActor actor, CommerceAdminQuery query, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var couponCount = await dbContext.Coupons.CountAsync(item => item.TenantId == actor.TenantId, cancellationToken); var redemptions = dbContext.CouponRedemptions.AsNoTracking() .Where(item => item.TenantId == actor.TenantId); var claimedCount = await redemptions.CountAsync(cancellationToken); var usedCount = await redemptions.CountAsync(item => item.Status == CouponRedemptionStatus.Used, cancellationToken); var discountApplied = await redemptions .Where(item => item.Status == CouponRedemptionStatus.Used) .SumAsync(item => item.DiscountAppliedCents, cancellationToken) ?? 0; return new TenantCouponReport(couponCount, claimedCount, usedCount, discountApplied); } public async Task GetRefundsAsync( CommerceAdminActor actor, CommerceAdminQuery query, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var refunds = dbContext.CommerceRefundRequests.AsNoTracking() .Where(item => item.TenantId == actor.TenantId) .ApplyDataScope( scope, item => item.RequestedBy == actor.UserId || dbContext.Orders.Any(order => order.TenantId == actor.TenantId && order.Id == item.OrderId && order.UserId == actor.UserId), item => dbContext.Orders.Any(order => order.TenantId == actor.TenantId && order.Id == item.OrderId && order.RegionId.HasValue && regionIds.Contains(order.RegionId.Value))); 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 CreateRefundRequestAsync( CommerceAdminActor actor, CreateRefundRequestCommand command, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var order = await dbContext.Orders .Where(item => item.TenantId == actor.TenantId && item.Id == command.OrderId) .ApplyDataScope( scope, item => item.UserId == actor.UserId, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)) .SingleOrDefaultAsync(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 UpdateRefundStatusAsync( CommerceAdminActor actor, UpdateRefundStatusCommand command, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var refund = await dbContext.CommerceRefundRequests .Where(item => item.TenantId == actor.TenantId && item.Id == command.RefundRequestId) .ApplyDataScope( scope, item => item.RequestedBy == actor.UserId || dbContext.Orders.Any(order => order.TenantId == actor.TenantId && order.Id == item.OrderId && order.UserId == actor.UserId), item => dbContext.Orders.Any(order => order.TenantId == actor.TenantId && order.Id == item.OrderId && order.RegionId.HasValue && regionIds.Contains(order.RegionId.Value))) .SingleOrDefaultAsync(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 GetRefundEventsAsync( CommerceAdminActor actor, Guid refundRequestId, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var refundExists = await dbContext.CommerceRefundRequests .Where(item => item.TenantId == actor.TenantId && item.Id == refundRequestId) .ApplyDataScope( scope, item => item.RequestedBy == actor.UserId || dbContext.Orders.Any(order => order.TenantId == actor.TenantId && order.Id == item.OrderId && order.UserId == actor.UserId), item => dbContext.Orders.Any(order => order.TenantId == actor.TenantId && order.Id == item.OrderId && order.RegionId.HasValue && regionIds.Contains(order.RegionId.Value))) .AnyAsync(cancellationToken); if (!refundExists) { throw new CommerceException("Refund request was not found.", "refund_not_found"); } 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 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 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 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 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 access = await currentAccessContext.GetAsync(cancellationToken); if (!access.IsCurrentTenantMember || access.UserId != actor.UserId || access.TenantId != actor.TenantId || !access.HasTenantPermission(BackendPermissions.TenantCommerceOperate)) { throw new CommerceException("Tenant admin access is required.", "tenant_admin_access_denied"); } } private async Task RequireDataScopeAsync( CommerceAdminActor actor, CancellationToken cancellationToken) { await AssertAdminAsync(actor, cancellationToken); return (await currentAccessContext.GetAsync(cancellationToken)).DataScope; } private static TenantPaymentProviderItem ToPaymentAccountItem(TenantExternalProviderItem item) => new( item.Id, item.Provider, GetJsonString(item.ConfigPublic, "mode") ?? "TenantCollect", item.DisplayName, item.Status, item.SecretRef, item.Priority, item.ConfigPublic, item.CreatedAt, item.UpdatedAt); private static TenantSecretItem ToSecretItem(TenantSecret item) => new(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) => new(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) => new(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) => new(order.Id, order.OrderNo, order.Status.ToString(), order.PlanId, order.RegionId, order.ProductType, order.ProductName, order.AmountCents, FormatCny(order.AmountCents), order.PayMethod, order.PayProvider, order.TradeNo, order.Days, order.PaidAt, order.CreatedAt, order.RawPayload); private static CommercePaymentItem ToPaymentItem(Payment payment, string orderNo) => new(payment.Id, payment.OrderId, orderNo, payment.Provider, payment.Method, payment.Status.ToString(), payment.AmountCents, FormatCny(payment.AmountCents), payment.ProviderTradeNo, payment.PaidAt, JsonSerializer.SerializeToElement(new { }), payment.RawPayload); private static OrderStatus ParseOrderStatus(string? status) => Enum.TryParse(NormalizeEnum(status), true, out var parsed) ? parsed : throw new CommerceException("Order status is invalid.", "invalid_order_status"); private static PaymentStatus ParsePaymentStatus(string? status) => Enum.TryParse(NormalizeEnum(status), true, out var parsed) ? parsed : throw new CommerceException("Payment status is invalid.", "invalid_payment_status"); private static PointActivityTaskStatus ParsePointTaskStatus(string? status) => Enum.TryParse(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) => Enum.TryParse(NormalizeEnum(status), true, out var parsed) ? parsed : throw new CommerceException("Point exchange item status is invalid.", "invalid_point_exchange_item_status"); private static PointExchangeOrderStatus ParsePointExchangeOrderStatus(string? status) => Enum.TryParse(NormalizeEnum(status), true, out var parsed) ? parsed : throw new CommerceException("Point exchange order status is invalid.", "invalid_point_exchange_order_status"); private static CouponRedemptionStatus ParseCouponRedemptionStatus(string? status) => Enum.TryParse(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) => Enum.TryParse(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(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(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)); private static string NormalizeProvider(string? provider) { var normalized = (provider ?? string.Empty).Trim().ToLowerInvariant().Replace("-", "_", StringComparison.Ordinal); return normalized switch { "wechat" or "wechatpay" or "wxpay" or "wx_pay" => PaymentProviders.WechatPay, "ali_pay" => PaymentProviders.Alipay, "" => throw new CommerceException("Provider is required.", "provider_required"), _ => normalized }; } private static JsonElement WithPaymentMode(JsonElement element, string? mode) { var values = new Dictionary(StringComparer.Ordinal); if (element.ValueKind == JsonValueKind.Object) { foreach (var property in element.EnumerateObject()) { values[property.Name] = property.Value.Clone(); } } values["mode"] = JsonSerializer.SerializeToElement( string.IsNullOrWhiteSpace(mode) ? "TenantCollect" : mode.Trim()); return JsonSerializer.SerializeToElement(values); } private static string? GetJsonString(JsonElement element, params string[] keys) { if (element.ValueKind != JsonValueKind.Object) { return null; } foreach (var key in keys) { if (element.TryGetProperty(key, out var value) && value.ValueKind == JsonValueKind.String) { return value.GetString(); } } return null; } private static JsonElement JsonObjectOrDefault(JsonElement element) => element.ValueKind == JsonValueKind.Object ? element.Clone() : JsonSerializer.SerializeToElement(new { }); private static void AssertNoSecrets(JsonElement element, string path) { if (element.ValueKind != JsonValueKind.Object) { return; } foreach (var property in element.EnumerateObject()) { var key = property.Name.ToLowerInvariant(); if (key is "secretref" or "secret_ref") { continue; } if (key.Contains("secret", StringComparison.Ordinal) || key.Contains("privatekey", StringComparison.Ordinal) || key is "appsecret" or "apiv3key" or "api_v3_key" or "accesskeysecret") { throw new CommerceException($"{path} cannot contain secrets.", "public_config_contains_secret"); } AssertNoSecrets(property.Value, $"{path}.{property.Name}"); } } private static string GenerateActivationCode() { Span bytes = stackalloc byte[8]; RandomNumberGenerator.Fill(bytes); return $"TKU{Convert.ToHexString(bytes)}"; } private static string FormatCny(int cents) => (cents / 100m).ToString("0.00", CultureInfo.InvariantCulture); }