Files
tiku-backend.net/Tiku.Infrastructure/Commerce/CommerceAdminService.cs

1756 lines
77 KiB
C#

using System.Globalization;
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;
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,
IBackgroundJobService backgroundJobService) : ICommerceAdminService
{
public async Task<IReadOnlyCollection<TenantPaymentProviderItem>> 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<TenantPaymentProviderItem> 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<TenantSecretItem> 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<AdminOrderList> 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<AdminPaymentList> 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<CodeBatchItem> 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<ActivationCodeList> 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<ActivationCodeItem> 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<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;
}
public async Task<TenantCouponList> 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<Coupon> 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<TenantCouponRedemptionList> 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<TenantCouponReport> 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<TenantRefundList> 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<CommerceRefundRequest> 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<CommerceRefundRequest> 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<TenantRefundEventList> 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<TenantReconciliationBatchList> GetReconciliationBatchesAsync(
CommerceAdminActor actor,
CommerceAdminQuery query,
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var batches = dbContext.CommerceReconciliationBatches.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId);
if (!string.IsNullOrWhiteSpace(query.Provider))
{
var provider = NormalizeProvider(query.Provider);
batches = batches.Where(item => item.Provider == provider);
}
if (!string.IsNullOrWhiteSpace(query.Status))
{
batches = batches.Where(item => item.Status == ParseReconciliationBatchStatus(query.Status));
}
var items = await batches.OrderByDescending(item => item.CreatedAt)
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
.ToArrayAsync(cancellationToken);
return new TenantReconciliationBatchList(items);
}
public async Task<CommerceReconciliationBatch> CreateReconciliationBatchAsync(
CommerceAdminActor actor,
CreateReconciliationBatchCommand command,
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var batch = new CommerceReconciliationBatch
{
TenantId = actor.TenantId,
CreatedBy = actor.UserId,
Provider = NormalizeProvider(command.Provider),
BillDate = command.BillDate,
BillType = command.BillType,
Source = command.Source,
SourceName = command.SourceName?.Trim(),
SourceHash = command.SourceHash.Trim(),
Status = ReconciliationBatchStatus.Pending,
Metadata = JsonObjectOrDefault(command.Metadata)
};
dbContext.CommerceReconciliationBatches.Add(batch);
await AddAuditAsync(actor, "commerce.reconciliation_batch.created", "commerce_reconciliation_batches", batch.Id, new { batch.Provider, batch.BillDate }, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
return batch;
}
public async Task<TenantReconciliationIssueList> GetReconciliationIssuesAsync(
CommerceAdminActor actor,
CommerceAdminQuery query,
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var issues = dbContext.CommerceReconciliationIssues.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId);
if (!string.IsNullOrWhiteSpace(query.Provider))
{
var provider = NormalizeProvider(query.Provider);
issues = issues.Where(item => item.Provider == provider);
}
if (!string.IsNullOrWhiteSpace(query.Status))
{
issues = issues.Where(item => item.Status == ParseReconciliationIssueStatus(query.Status));
}
var items = await issues.OrderByDescending(item => item.CreatedAt)
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
.ToArrayAsync(cancellationToken);
return new TenantReconciliationIssueList(items);
}
public async Task<CommerceReconciliationIssue> UpdateReconciliationIssueAsync(
CommerceAdminActor actor,
UpdateReconciliationIssueCommand command,
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var issue = await dbContext.CommerceReconciliationIssues.SingleOrDefaultAsync(
item => item.TenantId == actor.TenantId && item.Id == command.IssueId,
cancellationToken) ?? throw new CommerceException("Reconciliation issue was not found.", "reconciliation_issue_not_found");
var fromStatus = issue.Status;
issue.Status = command.Status;
issue.ResolutionType = command.ResolutionType;
issue.ResolutionNote = command.Note?.Trim();
issue.AssignedTo = command.AssignedTo ?? issue.AssignedTo;
if (command.Status is ReconciliationIssueStatus.Resolved or ReconciliationIssueStatus.Ignored)
{
issue.ResolvedBy = actor.UserId;
issue.ResolvedAt = DateTimeOffset.UtcNow;
}
dbContext.CommerceReconciliationIssueEvents.Add(new CommerceReconciliationIssueEvent
{
TenantId = actor.TenantId,
IssueId = issue.Id,
FromStatus = fromStatus,
ToStatus = command.Status,
EventType = "status_changed",
ActorUserId = actor.UserId,
Note = command.Note,
Details = JsonSerializer.SerializeToElement(new { command.ResolutionType, command.AssignedTo })
});
await AddAuditAsync(actor, "commerce.reconciliation_issue.status_changed", "commerce_reconciliation_issues", issue.Id, new { issue.IssueNo, From = fromStatus, To = command.Status }, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
return issue;
}
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);
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<CurrentDataScope> 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<OrderStatus>(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<PaymentStatus>(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<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) =>
Enum.TryParse<PointExchangeItemStatus>(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<PointExchangeOrderStatus>(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<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) =>
Enum.TryParse<CommerceRefundStatus>(NormalizeEnum(status), true, out var parsed)
? parsed
: throw new CommerceException("Refund status is invalid.", "invalid_refund_status");
private static ReconciliationBatchStatus ParseReconciliationBatchStatus(string? status) =>
Enum.TryParse<ReconciliationBatchStatus>(NormalizeEnum(status), true, out var parsed)
? parsed
: throw new CommerceException("Reconciliation batch status is invalid.", "invalid_reconciliation_batch_status");
private static ReconciliationIssueStatus ParseReconciliationIssueStatus(string? status) =>
Enum.TryParse<ReconciliationIssueStatus>(NormalizeEnum(status), true, out var parsed)
? parsed
: throw new CommerceException("Reconciliation issue status is invalid.", "invalid_reconciliation_issue_status");
private 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(
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 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,
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<string, JsonElement>(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 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];
RandomNumberGenerator.Fill(bytes);
return $"TKU{Convert.ToHexString(bytes)}";
}
private static string FormatCny(int cents) =>
(cents / 100m).ToString("0.00", CultureInfo.InvariantCulture);
}