Files
TakeoutSaaS.TenantApi/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfFinanceTransactionRepository.cs

804 lines
30 KiB
C#

using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Finance.Enums;
using TakeoutSaaS.Domain.Finance.Models;
using TakeoutSaaS.Domain.Finance.Repositories;
using TakeoutSaaS.Domain.Membership.Enums;
using TakeoutSaaS.Domain.Orders.Enums;
using TakeoutSaaS.Domain.Payments.Enums;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// 财务交易流水 EF Core 仓储实现。
/// </summary>
public sealed class EfFinanceTransactionRepository(TakeoutAppDbContext context) : IFinanceTransactionRepository
{
/// <inheritdoc />
public async Task<FinanceTransactionPageSnapshot> SearchPageAsync(
long tenantId,
long storeId,
DateTime? startAt,
DateTime? endAt,
FinanceTransactionType? transactionType,
DeliveryType? deliveryType,
PaymentMethod? paymentMethod,
string? keyword,
int page,
int pageSize,
CancellationToken cancellationToken = default)
{
// 1. 构建筛选查询并读取总量。
var normalizedPage = Math.Max(1, page);
var normalizedPageSize = Math.Clamp(pageSize, 1, 200);
var query = BuildQuery(tenantId, storeId, startAt, endAt, transactionType, deliveryType, paymentMethod, keyword);
var totalCount = await query.CountAsync(cancellationToken);
if (totalCount == 0)
{
return new FinanceTransactionPageSnapshot
{
Items = [],
TotalCount = 0,
PageIncomeAmount = 0,
PageRefundAmount = 0
};
}
// 2. 分页读取记录并完成统一映射。
var rows = await query
.OrderByDescending(item => item.OccurredAt)
.ThenByDescending(item => item.SourceId)
.Skip((normalizedPage - 1) * normalizedPageSize)
.Take(normalizedPageSize)
.ToListAsync(cancellationToken);
var items = rows.Select(MapToRecord).ToList();
// 3. 汇总本页收入与退款。
var pageIncomeAmount = items
.Where(item => item.AmountSigned > 0)
.Sum(item => item.AmountSigned);
var pageRefundAmount = items
.Where(item => item.AmountSigned < 0)
.Sum(item => Math.Abs(item.AmountSigned));
return new FinanceTransactionPageSnapshot
{
Items = items,
TotalCount = totalCount,
PageIncomeAmount = decimal.Round(pageIncomeAmount, 2, MidpointRounding.AwayFromZero),
PageRefundAmount = decimal.Round(pageRefundAmount, 2, MidpointRounding.AwayFromZero)
};
}
/// <inheritdoc />
public async Task<FinanceTransactionStatsSnapshot> GetStatsAsync(
long tenantId,
long storeId,
DateTime? startAt,
DateTime? endAt,
FinanceTransactionType? transactionType,
DeliveryType? deliveryType,
PaymentMethod? paymentMethod,
string? keyword,
CancellationToken cancellationToken = default)
{
// 1. 构建筛选查询并聚合总览指标。
var query = BuildQuery(tenantId, storeId, startAt, endAt, transactionType, deliveryType, paymentMethod, keyword);
var summary = await query
.GroupBy(_ => 1)
.Select(group => new
{
TotalIncome = group
.Where(item => item.AmountSigned > 0)
.Sum(item => item.AmountSigned),
TotalRefund = group
.Where(item => item.AmountSigned < 0)
.Sum(item => Math.Abs(item.AmountSigned)),
TotalCount = group.Count()
})
.FirstOrDefaultAsync(cancellationToken);
if (summary is null)
{
return new FinanceTransactionStatsSnapshot
{
TotalIncome = 0,
TotalRefund = 0,
TotalCount = 0
};
}
return new FinanceTransactionStatsSnapshot
{
TotalIncome = decimal.Round(summary.TotalIncome, 2, MidpointRounding.AwayFromZero),
TotalRefund = decimal.Round(summary.TotalRefund, 2, MidpointRounding.AwayFromZero),
TotalCount = summary.TotalCount
};
}
/// <inheritdoc />
public async Task<FinanceTransactionRecord?> GetDetailAsync(
long tenantId,
long storeId,
FinanceTransactionSourceType sourceType,
long sourceId,
CancellationToken cancellationToken = default)
{
// 1. 按来源组合键定位唯一流水。
var query = BuildQuery(
tenantId,
storeId,
startAt: null,
endAt: null,
transactionType: null,
deliveryType: null,
paymentMethod: null,
keyword: null);
var row = await query
.Where(item => item.SourceType == sourceType && item.SourceId == sourceId)
.OrderByDescending(item => item.OccurredAt)
.ThenByDescending(item => item.SourceId)
.FirstOrDefaultAsync(cancellationToken);
return row is null ? null : MapToRecord(row);
}
/// <inheritdoc />
public async Task<IReadOnlyList<FinanceTransactionRecord>> ListForExportAsync(
long tenantId,
long storeId,
DateTime? startAt,
DateTime? endAt,
FinanceTransactionType? transactionType,
DeliveryType? deliveryType,
PaymentMethod? paymentMethod,
string? keyword,
CancellationToken cancellationToken = default)
{
// 1. 按筛选读取导出数据,限定上限保障性能。
var query = BuildQuery(tenantId, storeId, startAt, endAt, transactionType, deliveryType, paymentMethod, keyword);
var rows = await query
.OrderByDescending(item => item.OccurredAt)
.ThenByDescending(item => item.SourceId)
.Take(20_000)
.ToListAsync(cancellationToken);
return rows.Select(MapToRecord).ToList();
}
/// <inheritdoc />
public async Task<FinanceSettlementStatsSnapshot> GetSettlementStatsAsync(
long tenantId,
long storeId,
DateTime currentUtc,
CancellationToken cancellationToken = default)
{
var utcNow = NormalizeUtc(currentUtc);
var todayStart = new DateTime(utcNow.Year, utcNow.Month, utcNow.Day, 0, 0, 0, DateTimeKind.Utc);
var tomorrowStart = todayStart.AddDays(1);
var yesterdayStart = todayStart.AddDays(-1);
var monthStart = new DateTime(utcNow.Year, utcNow.Month, 1, 0, 0, 0, DateTimeKind.Utc);
var monthEnd = monthStart.AddMonths(1);
var query = BuildSettlementPaymentQuery(
tenantId,
storeId,
startAt: null,
endAt: null,
paymentMethod: null);
var summary = await query
.GroupBy(_ => 1)
.Select(group => new
{
TodayArrivedAmount = group
.Where(item => item.PaidAt >= todayStart && item.PaidAt < tomorrowStart)
.Sum(item => item.Amount),
YesterdayArrivedAmount = group
.Where(item => item.PaidAt >= yesterdayStart && item.PaidAt < todayStart)
.Sum(item => item.Amount),
CurrentMonthArrivedAmount = group
.Where(item => item.PaidAt >= monthStart && item.PaidAt < monthEnd)
.Sum(item => item.Amount),
CurrentMonthTransactionCount = group
.Count(item => item.PaidAt >= monthStart && item.PaidAt < monthEnd)
})
.FirstOrDefaultAsync(cancellationToken);
if (summary is null)
{
return new FinanceSettlementStatsSnapshot
{
TodayArrivedAmount = 0,
YesterdayArrivedAmount = 0,
CurrentMonthArrivedAmount = 0,
CurrentMonthTransactionCount = 0
};
}
return new FinanceSettlementStatsSnapshot
{
TodayArrivedAmount = decimal.Round(summary.TodayArrivedAmount, 2, MidpointRounding.AwayFromZero),
YesterdayArrivedAmount = decimal.Round(summary.YesterdayArrivedAmount, 2, MidpointRounding.AwayFromZero),
CurrentMonthArrivedAmount = decimal.Round(summary.CurrentMonthArrivedAmount, 2, MidpointRounding.AwayFromZero),
CurrentMonthTransactionCount = summary.CurrentMonthTransactionCount
};
}
/// <inheritdoc />
public async Task<FinanceSettlementAccountSnapshot?> GetSettlementAccountAsync(
long tenantId,
CancellationToken cancellationToken = default)
{
var profile = await context.TenantVerificationProfiles
.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.DeletedAt == null)
.Select(item => new
{
item.BankName,
item.BankAccountName,
item.BankAccountNumber,
item.WeChatMerchantNo,
item.AlipayPid
})
.FirstOrDefaultAsync(cancellationToken);
if (profile is null)
{
return null;
}
return new FinanceSettlementAccountSnapshot
{
BankName = (profile.BankName ?? string.Empty).Trim(),
BankAccountName = (profile.BankAccountName ?? string.Empty).Trim(),
BankAccountNoMasked = MaskBankAccountNo(profile.BankAccountNumber),
WechatMerchantNoMasked = MaskWechatMerchantNo(profile.WeChatMerchantNo),
AlipayPidMasked = MaskAlipayPid(profile.AlipayPid),
SettlementPeriodText = "T+1 自动到账"
};
}
/// <inheritdoc />
public async Task<FinanceSettlementPageSnapshot> SearchSettlementPageAsync(
long tenantId,
long storeId,
DateTime? startAt,
DateTime? endAt,
PaymentMethod? paymentMethod,
int page,
int pageSize,
CancellationToken cancellationToken = default)
{
var normalizedPage = Math.Max(1, page);
var normalizedPageSize = Math.Clamp(pageSize, 1, 200);
var groupedQuery = BuildSettlementPaymentQuery(tenantId, storeId, startAt, endAt, paymentMethod)
.GroupBy(item => new { ArrivedDate = item.PaidAt.Date, item.PaymentMethod })
.Select(group => new FinanceSettlementListItemSnapshot
{
ArrivedDate = DateTime.SpecifyKind(group.Key.ArrivedDate, DateTimeKind.Utc),
PaymentMethod = group.Key.PaymentMethod,
TransactionCount = group.Count(),
ArrivedAmount = decimal.Round(group.Sum(item => item.Amount), 2, MidpointRounding.AwayFromZero)
});
var totalCount = await groupedQuery.CountAsync(cancellationToken);
if (totalCount == 0)
{
return new FinanceSettlementPageSnapshot
{
Items = [],
TotalCount = 0
};
}
var items = await groupedQuery
.OrderByDescending(item => item.ArrivedDate)
.ThenBy(item => item.PaymentMethod)
.Skip((normalizedPage - 1) * normalizedPageSize)
.Take(normalizedPageSize)
.ToListAsync(cancellationToken);
return new FinanceSettlementPageSnapshot
{
Items = items,
TotalCount = totalCount
};
}
/// <inheritdoc />
public async Task<IReadOnlyList<FinanceSettlementDetailItemSnapshot>> GetSettlementDetailsAsync(
long tenantId,
long storeId,
DateTime arrivedDate,
PaymentMethod paymentMethod,
int take,
CancellationToken cancellationToken = default)
{
var arrivedDay = NormalizeUtc(arrivedDate);
var dayStart = new DateTime(arrivedDay.Year, arrivedDay.Month, arrivedDay.Day, 0, 0, 0, DateTimeKind.Utc);
var dayEnd = dayStart.AddDays(1);
var normalizedTake = Math.Clamp(take, 1, 200);
return await BuildSettlementPaymentQuery(
tenantId,
storeId,
dayStart,
dayEnd,
paymentMethod)
.OrderByDescending(item => item.PaidAt)
.ThenByDescending(item => item.PaymentRecordId)
.Select(item => new FinanceSettlementDetailItemSnapshot
{
OrderNo = item.OrderNo,
Amount = decimal.Round(item.Amount, 2, MidpointRounding.AwayFromZero),
PaidAt = item.PaidAt
})
.Take(normalizedTake)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<FinanceSettlementListItemSnapshot>> ListSettlementForExportAsync(
long tenantId,
long storeId,
DateTime? startAt,
DateTime? endAt,
PaymentMethod? paymentMethod,
CancellationToken cancellationToken = default)
{
return await BuildSettlementPaymentQuery(tenantId, storeId, startAt, endAt, paymentMethod)
.GroupBy(item => new { ArrivedDate = item.PaidAt.Date, item.PaymentMethod })
.Select(group => new FinanceSettlementListItemSnapshot
{
ArrivedDate = DateTime.SpecifyKind(group.Key.ArrivedDate, DateTimeKind.Utc),
PaymentMethod = group.Key.PaymentMethod,
TransactionCount = group.Count(),
ArrivedAmount = decimal.Round(group.Sum(item => item.Amount), 2, MidpointRounding.AwayFromZero)
})
.OrderByDescending(item => item.ArrivedDate)
.ThenBy(item => item.PaymentMethod)
.Take(20_000)
.ToListAsync(cancellationToken);
}
private IQueryable<TransactionProjection> BuildQuery(
long tenantId,
long storeId,
DateTime? startAt,
DateTime? endAt,
FinanceTransactionType? transactionType,
DeliveryType? deliveryType,
PaymentMethod? paymentMethod,
string? keyword)
{
// 1. 收入流水(支付成功)。
var incomeQuery =
from payment in context.PaymentRecords.AsNoTracking()
join order in context.Orders.AsNoTracking()
on payment.OrderId equals order.Id
where payment.TenantId == tenantId
&& order.TenantId == tenantId
&& payment.Status == PaymentStatus.Paid
select new TransactionProjection
{
SourceType = FinanceTransactionSourceType.PaymentRecord,
SourceId = payment.Id,
StoreId = order.StoreId,
TransactionType = FinanceTransactionType.Income,
TransactionNo = payment.TradeNo,
OrderId = order.Id,
OrderNo = order.OrderNo,
DeliveryType = order.DeliveryType,
PaymentMethod = payment.Method,
AmountSigned = payment.Amount,
OccurredAt = payment.PaidAt ?? payment.CreatedAt,
Remark = payment.Remark,
CustomerName = order.CustomerName,
CustomerPhone = order.CustomerPhone
};
// 2. 渠道退款流水(优先来源)。
var channelRefundQuery =
from refund in context.PaymentRefundRecords.AsNoTracking()
join order in context.Orders.AsNoTracking()
on refund.OrderId equals order.Id
join payment in context.PaymentRecords.AsNoTracking()
on refund.PaymentRecordId equals payment.Id into paymentGroup
from matchedPayment in paymentGroup.DefaultIfEmpty()
where refund.TenantId == tenantId
&& order.TenantId == tenantId
&& refund.Status == PaymentRefundStatus.Succeeded
select new TransactionProjection
{
SourceType = FinanceTransactionSourceType.PaymentRefundRecord,
SourceId = refund.Id,
StoreId = order.StoreId,
TransactionType = FinanceTransactionType.Refund,
TransactionNo = refund.ChannelRefundId,
OrderId = order.Id,
OrderNo = order.OrderNo,
DeliveryType = order.DeliveryType,
PaymentMethod = matchedPayment == null ? null : matchedPayment.Method,
AmountSigned = 0 - refund.Amount,
OccurredAt = refund.CompletedAt ?? refund.RequestedAt,
Remark = null,
CustomerName = order.CustomerName,
CustomerPhone = order.CustomerPhone
};
// 3. 退款申请流水(补充来源:没有渠道退款记录时使用)。
var requestRefundQuery =
from refund in context.RefundRequests.AsNoTracking()
join order in context.Orders.AsNoTracking()
on refund.OrderId equals order.Id
where refund.TenantId == tenantId
&& order.TenantId == tenantId
&& refund.Status == RefundStatus.Refunded
&& !context.PaymentRefundRecords
.AsNoTracking()
.Any(channelRefund =>
channelRefund.TenantId == tenantId
&& channelRefund.OrderId == refund.OrderId
&& channelRefund.Status == PaymentRefundStatus.Succeeded)
select new TransactionProjection
{
SourceType = FinanceTransactionSourceType.RefundRequest,
SourceId = refund.Id,
StoreId = order.StoreId,
TransactionType = FinanceTransactionType.Refund,
TransactionNo = refund.RefundNo,
OrderId = order.Id,
OrderNo = order.OrderNo,
DeliveryType = order.DeliveryType,
PaymentMethod = context.PaymentRecords
.AsNoTracking()
.Where(payment => payment.TenantId == tenantId && payment.OrderId == order.Id)
.OrderByDescending(payment => payment.PaidAt ?? payment.CreatedAt)
.ThenByDescending(payment => payment.Id)
.Select(payment => (PaymentMethod?)payment.Method)
.FirstOrDefault(),
AmountSigned = 0 - refund.Amount,
OccurredAt = refund.ProcessedAt ?? refund.RequestedAt,
Remark = refund.ReviewNotes,
RefundNo = refund.RefundNo,
RefundReason = refund.Reason,
CustomerName = order.CustomerName,
CustomerPhone = order.CustomerPhone
};
// 4. 储值充值流水。
var rechargeQuery =
from recharge in context.MemberStoredCardRechargeRecords.AsNoTracking()
where recharge.TenantId == tenantId
select new TransactionProjection
{
SourceType = FinanceTransactionSourceType.StoredCardRechargeRecord,
SourceId = recharge.Id,
StoreId = recharge.StoreId,
TransactionType = FinanceTransactionType.StoredCardRecharge,
TransactionNo = recharge.RecordNo,
OrderId = null,
OrderNo = null,
DeliveryType = null,
PaymentMethod = recharge.PaymentMethod,
AmountSigned = recharge.RechargeAmount,
OccurredAt = recharge.RechargedAt,
Remark = recharge.Remark,
MemberName = recharge.MemberName,
MemberMobileMasked = recharge.MemberMobileMasked,
RechargeAmount = recharge.RechargeAmount,
GiftAmount = recharge.GiftAmount,
ArrivedAmount = recharge.ArrivedAmount
};
// 5. 积分抵扣流水(仅统计可关联订单)。
var pointRedeemQuery =
from pointLedger in context.MemberPointLedgers.AsNoTracking()
join order in context.Orders.AsNoTracking()
on pointLedger.SourceId equals (long?)order.Id
where pointLedger.TenantId == tenantId
&& order.TenantId == tenantId
&& pointLedger.SourceId.HasValue
&& pointLedger.Reason == PointChangeReason.Redeem
&& pointLedger.ChangeAmount < 0
select new TransactionProjection
{
SourceType = FinanceTransactionSourceType.MemberPointLedger,
SourceId = pointLedger.Id,
StoreId = order.StoreId,
TransactionType = FinanceTransactionType.PointRedeem,
TransactionNo = null,
OrderId = order.Id,
OrderNo = order.OrderNo,
DeliveryType = order.DeliveryType,
PaymentMethod = context.PaymentRecords
.AsNoTracking()
.Where(payment => payment.TenantId == tenantId && payment.OrderId == order.Id)
.OrderByDescending(payment => payment.PaidAt ?? payment.CreatedAt)
.ThenByDescending(payment => payment.Id)
.Select(payment => (PaymentMethod?)payment.Method)
.FirstOrDefault(),
AmountSigned = order.DiscountAmount,
OccurredAt = pointLedger.OccurredAt,
Remark = null,
PointChangeAmount = pointLedger.ChangeAmount,
PointBalanceAfterChange = pointLedger.BalanceAfterChange,
CustomerName = order.CustomerName,
CustomerPhone = order.CustomerPhone
};
// 6. 合并多来源并下推统一筛选条件。
var query = incomeQuery
.Concat(channelRefundQuery)
.Concat(requestRefundQuery)
.Concat(rechargeQuery)
.Concat(pointRedeemQuery)
.Where(item => item.StoreId == storeId);
if (startAt.HasValue)
{
query = query.Where(item => item.OccurredAt >= startAt.Value);
}
if (endAt.HasValue)
{
query = query.Where(item => item.OccurredAt < endAt.Value);
}
if (transactionType.HasValue)
{
query = query.Where(item => item.TransactionType == transactionType.Value);
}
if (deliveryType.HasValue)
{
query = query.Where(item => item.DeliveryType == deliveryType.Value);
}
if (paymentMethod.HasValue)
{
query = query.Where(item => item.PaymentMethod == paymentMethod.Value);
}
var normalizedKeyword = (keyword ?? string.Empty).Trim();
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
{
var like = $"%{normalizedKeyword}%";
query = query.Where(item =>
EF.Functions.ILike(item.TransactionNo ?? string.Empty, like)
|| EF.Functions.ILike(item.OrderNo ?? string.Empty, like)
|| EF.Functions.ILike(item.RefundNo ?? string.Empty, like)
|| EF.Functions.ILike(item.MemberName ?? string.Empty, like)
|| EF.Functions.ILike(item.MemberMobileMasked ?? string.Empty, like));
}
return query;
}
private IQueryable<SettlementPaymentProjection> BuildSettlementPaymentQuery(
long tenantId,
long storeId,
DateTime? startAt,
DateTime? endAt,
PaymentMethod? paymentMethod)
{
var query =
from payment in context.PaymentRecords.AsNoTracking()
join order in context.Orders.AsNoTracking()
on payment.OrderId equals order.Id
where payment.TenantId == tenantId
&& order.TenantId == tenantId
&& order.StoreId == storeId
&& payment.Status == PaymentStatus.Paid
&& payment.PaidAt.HasValue
&& (payment.Method == PaymentMethod.WeChatPay || payment.Method == PaymentMethod.Alipay)
select new SettlementPaymentProjection
{
PaymentRecordId = payment.Id,
OrderNo = order.OrderNo,
PaymentMethod = payment.Method,
Amount = payment.Amount,
PaidAt = payment.PaidAt!.Value
};
if (startAt.HasValue)
{
query = query.Where(item => item.PaidAt >= startAt.Value);
}
if (endAt.HasValue)
{
query = query.Where(item => item.PaidAt < endAt.Value);
}
if (paymentMethod.HasValue)
{
query = query.Where(item => item.PaymentMethod == paymentMethod.Value);
}
return query;
}
private static FinanceTransactionRecord MapToRecord(TransactionProjection source)
{
return new FinanceTransactionRecord
{
SourceType = source.SourceType,
SourceId = source.SourceId,
StoreId = source.StoreId,
TransactionNo = ResolveTransactionNo(source),
TransactionType = source.TransactionType,
OrderId = source.OrderId,
OrderNo = source.OrderNo,
DeliveryType = source.DeliveryType,
PaymentMethod = source.PaymentMethod,
AmountSigned = decimal.Round(source.AmountSigned, 2, MidpointRounding.AwayFromZero),
OccurredAt = source.OccurredAt,
Remark = ResolveRemark(source),
CustomerName = source.CustomerName,
CustomerPhone = source.CustomerPhone,
RefundNo = source.RefundNo,
RefundReason = source.RefundReason,
MemberName = source.MemberName,
MemberMobileMasked = source.MemberMobileMasked,
RechargeAmount = source.RechargeAmount,
GiftAmount = source.GiftAmount,
ArrivedAmount = source.ArrivedAmount,
PointChangeAmount = source.PointChangeAmount,
PointBalanceAfterChange = source.PointBalanceAfterChange
};
}
private static string ResolveTransactionNo(TransactionProjection source)
{
if (!string.IsNullOrWhiteSpace(source.TransactionNo))
{
return source.TransactionNo.Trim();
}
return source.SourceType switch
{
FinanceTransactionSourceType.PaymentRecord => $"PAY{source.SourceId}",
FinanceTransactionSourceType.PaymentRefundRecord => $"REFUND{source.SourceId}",
FinanceTransactionSourceType.RefundRequest => $"REFREQ{source.SourceId}",
FinanceTransactionSourceType.StoredCardRechargeRecord => $"RECHARGE{source.SourceId}",
FinanceTransactionSourceType.MemberPointLedger => $"POINT{source.SourceId}",
_ => $"TXN{source.SourceId}"
};
}
private static string ResolveRemark(TransactionProjection source)
{
if (!string.IsNullOrWhiteSpace(source.Remark))
{
return source.Remark.Trim();
}
if (source.TransactionType == FinanceTransactionType.PointRedeem && source.PointChangeAmount.HasValue)
{
return $"积分抵扣{Math.Abs(source.PointChangeAmount.Value)}分";
}
return source.TransactionType switch
{
FinanceTransactionType.Income => "订单收款",
FinanceTransactionType.Refund => "订单退款",
FinanceTransactionType.StoredCardRecharge => "会员储值充值",
FinanceTransactionType.PointRedeem => "积分抵扣",
_ => "--"
};
}
private sealed class TransactionProjection
{
public required FinanceTransactionSourceType SourceType { get; init; }
public required long SourceId { get; init; }
public required long StoreId { get; init; }
public string? TransactionNo { get; init; }
public required FinanceTransactionType TransactionType { get; init; }
public long? OrderId { get; init; }
public string? OrderNo { get; init; }
public DeliveryType? DeliveryType { get; init; }
public PaymentMethod? PaymentMethod { get; init; }
public required decimal AmountSigned { get; init; }
public required DateTime OccurredAt { get; init; }
public string? Remark { get; init; }
public string? CustomerName { get; init; }
public string? CustomerPhone { get; init; }
public string? RefundNo { get; init; }
public string? RefundReason { get; init; }
public string? MemberName { get; init; }
public string? MemberMobileMasked { get; init; }
public decimal? RechargeAmount { get; init; }
public decimal? GiftAmount { get; init; }
public decimal? ArrivedAmount { get; init; }
public int? PointChangeAmount { get; init; }
public int? PointBalanceAfterChange { get; init; }
}
private sealed class SettlementPaymentProjection
{
public required long PaymentRecordId { get; init; }
public required string OrderNo { get; init; }
public required PaymentMethod PaymentMethod { get; init; }
public required decimal Amount { get; init; }
public required DateTime PaidAt { get; init; }
}
private static DateTime NormalizeUtc(DateTime value)
{
return value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Local => value.ToUniversalTime(),
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
};
}
private static string MaskBankAccountNo(string? value)
{
var digits = new string((value ?? string.Empty).Where(char.IsDigit).ToArray());
if (digits.Length >= 4)
{
return $"****{digits[^4..]}";
}
return digits;
}
private static string MaskWechatMerchantNo(string? value)
{
var normalized = (value ?? string.Empty).Trim();
if (normalized.Length >= 4)
{
return $"{normalized[..2]}{new string('x', normalized.Length - 2)}";
}
return normalized;
}
private static string MaskAlipayPid(string? value)
{
var normalized = (value ?? string.Empty).Trim();
if (normalized.Length > 6)
{
return $"{normalized[..4]}{new string('x', normalized.Length - 4)}";
}
return normalized;
}
}