forked from xiongyuxing/tiku-backend.net
1138 lines
49 KiB
C#
1138 lines
49 KiB
C#
using System.Globalization;
|
|
using System.Security.Cryptography;
|
|
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Tiku.Application.Commerce;
|
|
using Tiku.Application.Security;
|
|
using Tiku.Application.Tenancy;
|
|
using Tiku.Domain.Catalog;
|
|
using Tiku.Domain.Commerce;
|
|
using Tiku.Domain.Tenancy;
|
|
using Tiku.Infrastructure.Persistence;
|
|
using Tiku.Infrastructure.Security;
|
|
|
|
namespace Tiku.Infrastructure.Commerce;
|
|
|
|
internal sealed class CommerceAdminService(
|
|
TikuDbContext dbContext,
|
|
ITenantSecretProtector tenantSecretProtector,
|
|
ITenantExternalProviderConfigService providerConfigService,
|
|
ICurrentAccessContext currentAccessContext) : ICommerceAdminService
|
|
{
|
|
public async Task<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;
|
|
}
|
|
|
|
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 async Task ApplyRefundToOrderAsync(CommerceRefundRequest refund, CancellationToken cancellationToken)
|
|
{
|
|
var order = await dbContext.Orders.SingleAsync(
|
|
item => item.TenantId == refund.TenantId && item.Id == refund.OrderId,
|
|
cancellationToken);
|
|
if (order.RefundedAmountCents < order.AmountCents)
|
|
{
|
|
order.RefundedAmountCents = Math.Min(order.AmountCents, order.RefundedAmountCents + refund.AmountCents);
|
|
order.Status = order.RefundedAmountCents >= order.AmountCents
|
|
? OrderStatus.Refunded
|
|
: OrderStatus.PartiallyRefunded;
|
|
}
|
|
|
|
if (refund.PaymentId.HasValue)
|
|
{
|
|
var payment = await dbContext.Payments.SingleOrDefaultAsync(
|
|
item => item.TenantId == refund.TenantId && item.Id == refund.PaymentId.Value,
|
|
cancellationToken);
|
|
if (payment is not null)
|
|
{
|
|
payment.RefundedAmountCents = Math.Min(payment.AmountCents, payment.RefundedAmountCents + refund.AmountCents);
|
|
payment.Status = payment.RefundedAmountCents >= payment.AmountCents
|
|
? PaymentStatus.Refunded
|
|
: PaymentStatus.PartiallyRefunded;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static bool IsAllowedRefundTransition(CommerceRefundStatus from, CommerceRefundStatus to)
|
|
{
|
|
return from switch
|
|
{
|
|
CommerceRefundStatus.Requested => to is CommerceRefundStatus.Approved or CommerceRefundStatus.Rejected or CommerceRefundStatus.Cancelled,
|
|
CommerceRefundStatus.Approved => to is CommerceRefundStatus.Processing or CommerceRefundStatus.Cancelled,
|
|
CommerceRefundStatus.Processing => to is CommerceRefundStatus.Succeeded or CommerceRefundStatus.Failed,
|
|
CommerceRefundStatus.Failed => to is CommerceRefundStatus.Processing or CommerceRefundStatus.Cancelled,
|
|
_ => false
|
|
};
|
|
}
|
|
|
|
private void AddRefundEvent(
|
|
CommerceRefundRequest refund,
|
|
CommerceRefundStatus? fromStatus,
|
|
CommerceRefundStatus toStatus,
|
|
string eventType,
|
|
Guid actorUserId,
|
|
object details)
|
|
{
|
|
dbContext.CommerceRefundEvents.Add(new CommerceRefundEvent
|
|
{
|
|
TenantId = refund.TenantId,
|
|
RefundRequestId = refund.Id,
|
|
FromStatus = fromStatus,
|
|
ToStatus = toStatus,
|
|
EventType = eventType,
|
|
ActorUserId = actorUserId,
|
|
Details = JsonSerializer.SerializeToElement(details)
|
|
});
|
|
}
|
|
|
|
private Task AddAuditAsync(
|
|
CommerceAdminActor actor,
|
|
string action,
|
|
string targetType,
|
|
Guid targetId,
|
|
object details,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
dbContext.AuditLogs.Add(new Tiku.Domain.Operations.AuditLog
|
|
{
|
|
TenantId = actor.TenantId,
|
|
ActorUserId = actor.UserId,
|
|
Action = action,
|
|
TargetType = targetType,
|
|
TargetId = targetId.ToString(),
|
|
Details = JsonSerializer.SerializeToElement(details)
|
|
});
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private static string NormalizeEnum(string? value) =>
|
|
string.Concat((value ?? string.Empty).Split(['_', '-', ' '], StringSplitOptions.RemoveEmptyEntries));
|
|
|
|
private static string NormalizeProvider(string? provider)
|
|
{
|
|
var normalized = (provider ?? string.Empty).Trim().ToLowerInvariant().Replace("-", "_", StringComparison.Ordinal);
|
|
return normalized switch
|
|
{
|
|
"wechat" or "wechatpay" or "wxpay" or "wx_pay" => PaymentProviders.WechatPay,
|
|
"ali_pay" => PaymentProviders.Alipay,
|
|
"" => throw new CommerceException("Provider is required.", "provider_required"),
|
|
_ => normalized
|
|
};
|
|
}
|
|
|
|
private static JsonElement WithPaymentMode(JsonElement element, string? mode)
|
|
{
|
|
var values = new Dictionary<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 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);
|
|
}
|