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 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(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(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(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(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(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(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(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(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(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(NormalizeEnum(status), true, out var parsed) ? parsed : throw new CommerceException("Adjustment voucher status is invalid.", "invalid_adjustment_voucher_status"); } protected async Task AssertOptionalReferenceAsync( DbSet set, Guid tenantId, Guid? id, string code, CancellationToken cancellationToken) where TEntity : class { if (!id.HasValue) return; var exists = await set.AnyAsync( item => EF.Property(item, "TenantId") == tenantId && EF.Property(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(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 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( 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( 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 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); }