1756 lines
77 KiB
C#
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);
|
|
}
|