509 lines
20 KiB
C#
509 lines
20 KiB
C#
using System.Globalization;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Tiku.Application.Commerce;
|
|
using Tiku.Application.Security;
|
|
using Tiku.Application.Tenancy;
|
|
using Tiku.Domain.Commerce;
|
|
using Tiku.Domain.Operations;
|
|
using Tiku.Domain.Tenancy;
|
|
using NotificationSeverity = Tiku.Domain.Commerce.NotificationSeverity;
|
|
|
|
namespace Tiku.Infrastructure.Commerce;
|
|
|
|
internal abstract partial class CommerceAdministrationServiceBase
|
|
{
|
|
protected 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");
|
|
}
|
|
|
|
protected async Task<CurrentDataScope> RequireDataScopeAsync(
|
|
CommerceAdminActor actor,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await AssertAdminAsync(actor, cancellationToken);
|
|
return (await currentAccessContext.GetAsync(cancellationToken)).DataScope;
|
|
}
|
|
|
|
protected static TenantPaymentProviderItem ToPaymentAccountItem(TenantExternalProviderItem item)
|
|
{
|
|
return new TenantPaymentProviderItem(
|
|
item.Id,
|
|
item.Provider,
|
|
GetJsonString(item.ConfigPublic, "mode") ?? "TenantCollect",
|
|
item.DisplayName,
|
|
item.Status,
|
|
item.SecretRef,
|
|
item.Priority,
|
|
item.ConfigPublic,
|
|
item.CreatedAt,
|
|
item.UpdatedAt);
|
|
}
|
|
|
|
protected static TenantSecretItem ToSecretItem(TenantSecret item)
|
|
{
|
|
return new TenantSecretItem(item.Id, item.Purpose, item.Provider, item.SecretKey, item.SecretRef,
|
|
item.Status.ToString(),
|
|
item.RotatedAt, item.ExpiresAt, item.UpdatedAt);
|
|
}
|
|
|
|
protected static CodeBatchItem ToCodeBatchItem(CodeBatch item)
|
|
{
|
|
return new CodeBatchItem(item.Id, item.Name, item.TotalCount, item.Days ?? 0, item.RegionId, item.SaleType,
|
|
item.Channel,
|
|
item.DefaultUnitPriceCents, item.CostPriceCents, item.IssuedAt, item.Remark, item.CreatedAt);
|
|
}
|
|
|
|
protected static ActivationCodeItem ToActivationCodeItem(ActivationCode item)
|
|
{
|
|
return new ActivationCodeItem(item.Id, item.BatchId, item.Code, item.Days, item.IsUsed, item.UsedBy,
|
|
item.UsedAt, item.SaleType,
|
|
item.SoldTo, item.Remark, item.CreatedAt);
|
|
}
|
|
|
|
protected static CommerceOrderItem ToOrderItem(Order order)
|
|
{
|
|
return new CommerceOrderItem(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);
|
|
}
|
|
|
|
protected static CommercePaymentItem ToPaymentItem(Payment payment, string orderNo)
|
|
{
|
|
return new CommercePaymentItem(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);
|
|
}
|
|
|
|
protected static OrderStatus ParseOrderStatus(string? status)
|
|
{
|
|
return Enum.TryParse<OrderStatus>(NormalizeEnum(status), true, out var parsed)
|
|
? parsed
|
|
: throw new CommerceException("Order status is invalid.", "invalid_order_status");
|
|
}
|
|
|
|
protected static PaymentStatus ParsePaymentStatus(string? status)
|
|
{
|
|
return Enum.TryParse<PaymentStatus>(NormalizeEnum(status), true, out var parsed)
|
|
? parsed
|
|
: throw new CommerceException("Payment status is invalid.", "invalid_payment_status");
|
|
}
|
|
|
|
protected static PointActivityTaskStatus ParsePointTaskStatus(string? status)
|
|
{
|
|
return Enum.TryParse<PointActivityTaskStatus>(NormalizeEnum(status), true, out var parsed)
|
|
? parsed
|
|
: throw new CommerceException("Point task status is invalid.", "invalid_point_task_status");
|
|
}
|
|
|
|
protected static PointExchangeItemStatus ParsePointExchangeItemStatus(string? status)
|
|
{
|
|
return Enum.TryParse<PointExchangeItemStatus>(NormalizeEnum(status), true, out var parsed)
|
|
? parsed
|
|
: throw new CommerceException("Point exchange item status is invalid.",
|
|
"invalid_point_exchange_item_status");
|
|
}
|
|
|
|
protected static PointExchangeOrderStatus ParsePointExchangeOrderStatus(string? status)
|
|
{
|
|
return Enum.TryParse<PointExchangeOrderStatus>(NormalizeEnum(status), true, out var parsed)
|
|
? parsed
|
|
: throw new CommerceException("Point exchange order status is invalid.",
|
|
"invalid_point_exchange_order_status");
|
|
}
|
|
|
|
protected static CouponRedemptionStatus ParseCouponRedemptionStatus(string? status)
|
|
{
|
|
return Enum.TryParse<CouponRedemptionStatus>(NormalizeEnum(status), true, out var parsed)
|
|
? parsed
|
|
: throw new CommerceException("Coupon redemption status is invalid.", "invalid_coupon_redemption_status");
|
|
}
|
|
|
|
protected static CommerceRefundStatus ParseRefundStatus(string? status)
|
|
{
|
|
return Enum.TryParse<CommerceRefundStatus>(NormalizeEnum(status), true, out var parsed)
|
|
? parsed
|
|
: throw new CommerceException("Refund status is invalid.", "invalid_refund_status");
|
|
}
|
|
|
|
protected static ReconciliationBatchStatus ParseReconciliationBatchStatus(string? status)
|
|
{
|
|
return Enum.TryParse<ReconciliationBatchStatus>(NormalizeEnum(status), true, out var parsed)
|
|
? parsed
|
|
: throw new CommerceException("Reconciliation batch status is invalid.",
|
|
"invalid_reconciliation_batch_status");
|
|
}
|
|
|
|
protected static ReconciliationIssueStatus ParseReconciliationIssueStatus(string? status)
|
|
{
|
|
return Enum.TryParse<ReconciliationIssueStatus>(NormalizeEnum(status), true, out var parsed)
|
|
? parsed
|
|
: throw new CommerceException("Reconciliation issue status is invalid.",
|
|
"invalid_reconciliation_issue_status");
|
|
}
|
|
|
|
protected static CommerceAdjustmentVoucherStatus ParseAdjustmentVoucherStatus(string? status)
|
|
{
|
|
return Enum.TryParse<CommerceAdjustmentVoucherStatus>(NormalizeEnum(status), true, out var parsed)
|
|
? parsed
|
|
: throw new CommerceException("Adjustment voucher status is invalid.", "invalid_adjustment_voucher_status");
|
|
}
|
|
|
|
protected 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);
|
|
}
|
|
|
|
protected 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;
|
|
}
|
|
}
|
|
}
|
|
|
|
protected 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
|
|
};
|
|
}
|
|
|
|
protected 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
|
|
};
|
|
}
|
|
|
|
protected 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)
|
|
});
|
|
}
|
|
|
|
protected Task AddAuditAsync(
|
|
CommerceAdminActor actor,
|
|
string action,
|
|
string targetType,
|
|
Guid targetId,
|
|
object details,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
dbContext.AuditLogs.Add(new AuditLog
|
|
{
|
|
TenantId = actor.TenantId,
|
|
ActorUserId = actor.UserId,
|
|
Action = action,
|
|
TargetType = targetType,
|
|
TargetId = targetId.ToString(),
|
|
Details = JsonSerializer.SerializeToElement(details)
|
|
});
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
protected static string NormalizeEnum(string? value)
|
|
{
|
|
return string.Concat((value ?? string.Empty).Split(['_', '-', ' '], StringSplitOptions.RemoveEmptyEntries));
|
|
}
|
|
|
|
protected 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
|
|
};
|
|
}
|
|
|
|
protected 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);
|
|
}
|
|
|
|
protected 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;
|
|
}
|
|
|
|
protected static JsonElement JsonObjectOrDefault(JsonElement element)
|
|
{
|
|
return element.ValueKind == JsonValueKind.Object
|
|
? element.Clone()
|
|
: JsonSerializer.SerializeToElement(new { });
|
|
}
|
|
|
|
protected 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}");
|
|
}
|
|
}
|
|
|
|
protected 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(Encoding.UTF8.GetBytes($"{normalizedProvider}:{rows.GetRawText()}")))
|
|
.ToLowerInvariant();
|
|
return new ReconciliationImportPreview(
|
|
parsedRows.Length,
|
|
paymentCount,
|
|
refundCount,
|
|
invalidCount,
|
|
amountCents,
|
|
refundAmountCents,
|
|
sourceHash);
|
|
}
|
|
|
|
protected 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());
|
|
}
|
|
}
|
|
|
|
protected static ReconciliationImportRow InvalidImportRow(string issueCode, JsonElement row)
|
|
{
|
|
return new ReconciliationImportRow(
|
|
ReconciliationTransactionType.Payment,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
0,
|
|
0,
|
|
null,
|
|
null,
|
|
ReconciliationMatchStatus.AmountMismatch,
|
|
issueCode,
|
|
row.Clone());
|
|
}
|
|
|
|
protected static CommerceReconciliationItem CreateReconciliationItem(
|
|
Guid tenantId,
|
|
Guid batchId,
|
|
int rowNo,
|
|
string provider,
|
|
ReconciliationImportRow row)
|
|
{
|
|
return new CommerceReconciliationItem
|
|
{
|
|
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
|
|
};
|
|
}
|
|
|
|
protected 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;
|
|
}
|
|
|
|
protected static string GenerateActivationCode()
|
|
{
|
|
Span<byte> bytes = stackalloc byte[8];
|
|
RandomNumberGenerator.Fill(bytes);
|
|
return $"TKU{Convert.ToHexString(bytes)}";
|
|
}
|
|
|
|
protected static string FormatCny(int cents)
|
|
{
|
|
return (cents / 100m).ToString("0.00", CultureInfo.InvariantCulture);
|
|
}
|
|
|
|
protected 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);
|
|
}
|