using System.Security.Claims; using MiaoJiZhang.Api.Contracts; using MiaoJiZhang.Api.Services; using MiaoJiZhang.Domain.Entities; using MiaoJiZhang.Domain.Enums; using MiaoJiZhang.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace MiaoJiZhang.Api.Controllers; [ApiController] [Authorize] [Route("api/transactions")] public class TransactionsController( AppDbContext db, LedgerResolver ledgers, BudgetPushService budgetPush) : ControllerBase { private long Uid => long.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")!); /// 手动记一笔 [HttpPost] public async Task> Create(CreateTransactionRequest req) { if (req.Amount <= 0) return BadRequest(new ApiError("AMOUNT_INVALID", "金额必须大于 0")); var type = ParseType(req.Type); if (!type.HasValue) return BadRequest(new ApiError("TYPE_INVALID", "收支类型必须是 expense 或 income")); var clientRequestId = string.IsNullOrWhiteSpace(req.ClientRequestId) ? null : req.ClientRequestId.Trim(); if (clientRequestId?.Length > 64) return BadRequest(new ApiError("CLIENT_REQUEST_ID_INVALID", "幂等标识最长 64 个字符")); if (clientRequestId != null) { var existing = await db.Transactions .Include(t => t.Category) .FirstOrDefaultAsync(t => t.UserId == Uid && t.ClientRequestId == clientRequestId); if (existing is not null) return Ok(ToDto(existing, existing.Category)); } var ledgerId = await ledgers.ResolveAsync(Uid, req.LedgerId); if (!ledgerId.HasValue) return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问")); var cat = await db.Categories.FirstOrDefaultAsync(c => c.Id == req.CategoryId && !c.IsDeleted && c.Type == type.Value && (c.UserId == null || c.UserId == Uid)); if (cat is null) return BadRequest(new ApiError("CATEGORY_TYPE_MISMATCH", "分类与收支类型不一致")); var tx = new Transaction { LedgerId = ledgerId.Value, UserId = Uid, CategoryId = cat.Id, OccurredAt = req.OccurredAt.HasValue ? NormalizeOccurredAt(req.OccurredAt.Value) : DateTime.UtcNow, Type = type.Value, Amount = req.Amount, Note = req.Note, PaymentMethod = req.PaymentMethod, Source = SourceFromWire(req.Source), SourceText = req.SourceText, ClientRequestId = clientRequestId, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, }; db.Transactions.Add(tx); await using var writeScope = await db.Database.BeginTransactionAsync(); try { await db.SaveChangesAsync(); if (tx.Type == TransactionType.Expense) { await budgetPush.EvaluateAsync(Uid, [new BudgetExpenseChange(tx.LedgerId, tx.CategoryId, tx.OccurredAt, tx.Amount)]); await db.SaveChangesAsync(); } await writeScope.CommitAsync(); return Ok(ToDto(tx, cat)); } catch (DbUpdateException) when (clientRequestId is not null) { await writeScope.RollbackAsync(); // Another channel may have committed the same recognition candidate // after the initial lookup. Resolve the unique-key race as idempotent success. db.Entry(tx).State = EntityState.Detached; var existing = await db.Transactions .AsNoTracking() .Include(t => t.Category) .FirstOrDefaultAsync(t => t.UserId == Uid && t.ClientRequestId == clientRequestId); if (existing is not null) return Ok(ToDto(existing, existing.Category)); throw; } } [HttpPost("recognition-batch")] public async Task>> CreateRecognitionBatch( CreateRecognitionBatchRequest req, CancellationToken ct) { if (!Guid.TryParse(req.BatchId, out _) || req.Items.Count is < 1 or > 20) return BadRequest(new ApiError("BATCH_INVALID", "批次标识或账单数量无效")); if (req.Items.Select(item => item.CandidateId).Distinct().Count() != req.Items.Count || req.Items.Select(item => item.ClientRequestId).Distinct().Count() != req.Items.Count) { return BadRequest(new ApiError("BATCH_DUPLICATED", "批次中存在重复账单标识")); } if (req.Items.Any(item => item.Amount <= 0 || item.ClientRequestId.Length is < 1 or > 64 || ParseType(item.Type) is null)) { return BadRequest(new ApiError("BATCH_ITEM_INVALID", "批次中存在无效账单")); } var ledgerId = await ledgers.ResolveAsync(Uid, req.LedgerId); if (!ledgerId.HasValue) return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问")); var categoryIds = req.Items.Select(item => item.CategoryId).Distinct().ToList(); var categories = await db.Categories .Where(category => categoryIds.Contains(category.Id) && !category.IsDeleted && (category.UserId == null || category.UserId == Uid)) .ToDictionaryAsync(category => category.Id, ct); foreach (var item in req.Items) { var type = ParseType(item.Type)!.Value; if (!categories.TryGetValue(item.CategoryId, out var category) || category.Type != type) return BadRequest(new ApiError("CATEGORY_TYPE_MISMATCH", "分类与收支类型不一致")); } var requestIds = req.Items.Select(item => item.ClientRequestId).ToList(); var existing = await db.Transactions .Include(transaction => transaction.Category) .Where(transaction => transaction.UserId == Uid && transaction.ClientRequestId != null && requestIds.Contains(transaction.ClientRequestId)) .ToDictionaryAsync(transaction => transaction.ClientRequestId!, ct); await using var transactionScope = await db.Database.BeginTransactionAsync(ct); var mapped = new List<(string CandidateId, Transaction Transaction)>(); foreach (var item in req.Items) { if (existing.TryGetValue(item.ClientRequestId, out var found)) { mapped.Add((item.CandidateId, found)); continue; } var category = categories[item.CategoryId]; var transaction = new Transaction { LedgerId = ledgerId.Value, UserId = Uid, CategoryId = category.Id, Category = category, Type = ParseType(item.Type)!.Value, Amount = item.Amount, Note = item.Note?.Trim(), PaymentMethod = item.PaymentMethod?.Trim(), OccurredAt = NormalizeOccurredAt(item.OccurredAt), Source = SourceFromWire(item.Source), SourceText = item.SourceText, ClientRequestId = item.ClientRequestId, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, }; db.Transactions.Add(transaction); mapped.Add((item.CandidateId, transaction)); } try { await db.SaveChangesAsync(ct); var expenseChanges = mapped .Where(item => !existing.ContainsKey(item.Transaction.ClientRequestId ?? "") && item.Transaction.Type == TransactionType.Expense) .Select(item => new BudgetExpenseChange( item.Transaction.LedgerId, item.Transaction.CategoryId, item.Transaction.OccurredAt, item.Transaction.Amount)) .ToList(); await budgetPush.EvaluateAsync(Uid, expenseChanges, ct); await db.SaveChangesAsync(ct); await transactionScope.CommitAsync(ct); return Ok(mapped.Select(item => new RecognitionBatchTransactionDto( item.CandidateId, ToDto(item.Transaction, item.Transaction.Category))).ToList()); } catch (DbUpdateException) { await transactionScope.RollbackAsync(ct); foreach (var entry in db.ChangeTracker.Entries() .Where(entry => entry.State == EntityState.Added)) { entry.State = EntityState.Detached; } var raced = await db.Transactions .AsNoTracking() .Include(transaction => transaction.Category) .Where(transaction => transaction.UserId == Uid && transaction.ClientRequestId != null && requestIds.Contains(transaction.ClientRequestId)) .ToDictionaryAsync(transaction => transaction.ClientRequestId!, ct); if (raced.Count != requestIds.Count) throw; return Ok(req.Items.Select(item => new RecognitionBatchTransactionDto( item.CandidateId, ToDto(raced[item.ClientRequestId], raced[item.ClientRequestId].Category))).ToList()); } } /// 账单详情(P11:AI 来源追溯、分类、备注、时间) [HttpGet("{id:long}")] public async Task> Detail(long id) { var tx = await db.Transactions.Include(t => t.Category) .FirstOrDefaultAsync(t => t.Id == id && t.UserId == Uid); if (tx is null) return NotFound(new ApiError("TX_NOT_FOUND", "账单不存在")); return Ok(ToDto(tx, tx.Category)); } [HttpPut("{id:long}")] public async Task> Update(long id, UpdateTransactionRequest req) { if (req.Amount <= 0) return BadRequest(new ApiError("AMOUNT_INVALID", "金额必须大于 0")); var type = ParseType(req.Type); if (!type.HasValue) return BadRequest(new ApiError("TYPE_INVALID", "收支类型必须是 expense 或 income")); var ledgerId = await ledgers.ResolveAsync(Uid, req.LedgerId); if (!ledgerId.HasValue) return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问")); var category = await db.Categories.FirstOrDefaultAsync(c => c.Id == req.CategoryId && !c.IsDeleted && c.Type == type.Value && (c.UserId == null || c.UserId == Uid)); if (category is null) return BadRequest(new ApiError("CATEGORY_TYPE_MISMATCH", "分类与收支类型不一致")); var tx = await db.Transactions.FirstOrDefaultAsync(t => t.Id == id && t.UserId == Uid); if (tx is null) return NotFound(new ApiError("TX_NOT_FOUND", "账单不存在")); if (HasVersionConflict(tx, req.BaseUpdatedAt)) return Conflict(new { code = "SYNC_CONFLICT", message = "账单已在其他设备修改,请选择保留本地或云端版本", server = ToDto(tx, await db.Categories.FindAsync(tx.CategoryId) ?? category), }); var expenseChanges = new List(); if (tx.Type == TransactionType.Expense) expenseChanges.Add(new BudgetExpenseChange( tx.LedgerId, tx.CategoryId, tx.OccurredAt, -tx.Amount)); tx.LedgerId = ledgerId.Value; tx.CategoryId = category.Id; tx.Category = category; tx.Type = type.Value; tx.Amount = req.Amount; tx.Note = req.Note?.Trim(); tx.PaymentMethod = req.PaymentMethod?.Trim(); tx.OccurredAt = NormalizeOccurredAt(req.OccurredAt); tx.UpdatedAt = DateTime.UtcNow; if (tx.Type == TransactionType.Expense) expenseChanges.Add(new BudgetExpenseChange( tx.LedgerId, tx.CategoryId, tx.OccurredAt, tx.Amount)); await using var writeScope = await db.Database.BeginTransactionAsync(); await db.SaveChangesAsync(); await budgetPush.EvaluateAsync(Uid, expenseChanges); await db.SaveChangesAsync(); await writeScope.CommitAsync(); return Ok(ToDto(tx, category)); } /// 撤销/删除(软删除,决策 18) [HttpDelete("{id:long}")] public async Task Delete(long id, [FromQuery] DateTime? baseUpdatedAt = null) { var tx = await db.Transactions.FirstOrDefaultAsync(t => t.Id == id && t.UserId == Uid); if (tx is null) return NotFound(new ApiError("TX_NOT_FOUND", "账单不存在")); if (HasVersionConflict(tx, baseUpdatedAt)) return Conflict(new { code = "SYNC_CONFLICT", message = "账单已在其他设备修改,请确认后再删除", server = ToDto(tx, await db.Categories.FindAsync(tx.CategoryId) ?? tx.Category), }); tx.IsDeleted = true; tx.DeletedAt = DateTime.UtcNow; tx.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return NoContent(); } [HttpGet("recycle-bin")] public async Task>> RecycleBin( [FromQuery] long? ledgerId = null, [FromQuery] long? beforeId = null, [FromQuery] int limit = 50) { var resolvedLedgerId = await ledgers.ResolveAsync(Uid, ledgerId); if (!resolvedLedgerId.HasValue) return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问")); var query = db.Transactions .IgnoreQueryFilters() .Include(t => t.Category) .Where(t => t.UserId == Uid && t.LedgerId == resolvedLedgerId.Value && t.IsDeleted); if (beforeId.HasValue) query = query.Where(t => t.Id < beforeId.Value); var items = await query.OrderByDescending(t => t.DeletedAt) .ThenByDescending(t => t.Id) .Take(Math.Clamp(limit, 1, 100)) .ToListAsync(); return Ok(items.Select(t => ToDto(t, t.Category)).ToList()); } [HttpPost("{id:long}/restore")] public async Task> Restore(long id, [FromQuery] DateTime? baseUpdatedAt = null) { var tx = await db.Transactions.IgnoreQueryFilters().Include(t => t.Category) .FirstOrDefaultAsync(t => t.Id == id && t.UserId == Uid && t.IsDeleted); if (tx is null) return NotFound(new ApiError("TX_NOT_FOUND", "回收站中没有这笔账单")); if (HasVersionConflict(tx, baseUpdatedAt)) return Conflict(new { code = "SYNC_CONFLICT", message = "账单已在其他设备修改,请确认后再恢复", server = ToDto(tx, tx.Category), }); tx.IsDeleted = false; tx.DeletedAt = null; tx.UpdatedAt = DateTime.UtcNow; await using var writeScope = await db.Database.BeginTransactionAsync(); await db.SaveChangesAsync(); if (tx.Type == TransactionType.Expense) { await budgetPush.EvaluateAsync(Uid, [new BudgetExpenseChange(tx.LedgerId, tx.CategoryId, tx.OccurredAt, tx.Amount)]); await db.SaveChangesAsync(); } await writeScope.CommitAsync(); return Ok(ToDto(tx, tx.Category)); } [HttpDelete("{id:long}/permanent")] public async Task PermanentDelete(long id) { var tx = await db.Transactions.IgnoreQueryFilters() .FirstOrDefaultAsync(t => t.Id == id && t.UserId == Uid && t.IsDeleted); if (tx is null) return NotFound(new ApiError("TX_NOT_FOUND", "回收站中没有这笔账单")); await PermanentDeleteCore(tx); await db.SaveChangesAsync(); return NoContent(); } [HttpDelete("recycle-bin")] public async Task ClearRecycleBin([FromQuery] long? ledgerId = null) { var resolvedLedgerId = await ledgers.ResolveAsync(Uid, ledgerId); if (!resolvedLedgerId.HasValue) return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问")); var items = await db.Transactions.IgnoreQueryFilters() .Where(t => t.UserId == Uid && t.LedgerId == resolvedLedgerId.Value && t.IsDeleted) .ToListAsync(); foreach (var item in items) await PermanentDeleteCore(item); await db.SaveChangesAsync(); return NoContent(); } /// 月账单(按日分组,首页明细用) [HttpGet("month")] public async Task> Month([FromQuery] int year, [FromQuery] int month, [FromQuery] long? ledgerId = null) { var resolvedLedgerId = await ledgers.ResolveAsync(Uid, ledgerId); if (!resolvedLedgerId.HasValue) return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问")); var (start, end) = ChinaClock.MonthRangeUtc(year, month); var q = db.Transactions .Include(t => t.Category) .Where(t => t.UserId == Uid && t.LedgerId == resolvedLedgerId.Value && t.OccurredAt >= start && t.OccurredAt < end); var list = await q.OrderByDescending(t => t.OccurredAt).ToListAsync(); var income = list.Where(t => t.Type == TransactionType.Income).Sum(t => t.Amount); var expense = list.Where(t => t.Type == TransactionType.Expense).Sum(t => t.Amount); var days = list .GroupBy(t => DateOnly.FromDateTime(ChinaClock.ToLocal(t.OccurredAt))) .OrderByDescending(g => g.Key) .Select(g => new DailyGroupDto( g.Key, g.Where(t => t.Type == TransactionType.Expense).Sum(t => t.Amount), g.Where(t => t.Type == TransactionType.Income).Sum(t => t.Amount), g.Select(t => ToDto(t, t.Category)).ToList())) .ToList(); return Ok(new MonthSummaryDto(year, month, income, expense, income - expense, list.Count, days)); } /// 月统计(统计页:分类占比 + 每日趋势 + AI 分析) [HttpGet("stats")] public async Task> Stats( [FromQuery] int year, [FromQuery] int month, [FromQuery] long? ledgerId = null) { var resolvedLedgerId = await ledgers.ResolveAsync(Uid, ledgerId); if (!resolvedLedgerId.HasValue) return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问")); var (start, end) = ChinaClock.MonthRangeUtc(year, month); var daysInMonth = DateTime.DaysInMonth(year, month); var list = await db.Transactions .Include(t => t.Category) .Where(t => t.UserId == Uid && t.LedgerId == resolvedLedgerId.Value && t.OccurredAt >= start && t.OccurredAt < end) .ToListAsync(); var expenses = list.Where(t => t.Type == TransactionType.Expense).ToList(); var totalExpense = expenses.Sum(t => t.Amount); var totalIncome = list.Where(t => t.Type == TransactionType.Income).Sum(t => t.Amount); var byCat = expenses .GroupBy(t => t.Category) .Select(g => new CategoryStatDto( g.Key.Id, g.Key.Name, g.Key.IconKey, g.Sum(t => t.Amount), totalExpense == 0 ? 0 : (double)(g.Sum(t => t.Amount) / totalExpense * 100), g.Key.ColorKey)) .OrderByDescending(c => c.Amount) .ToList(); var daily = Enumerable.Range(1, daysInMonth) .Select(d => expenses.Where(t => ChinaClock.ToLocal(t.OccurredAt).Day == d).Sum(t => t.Amount)) .ToList(); // AI 分析卡文案 var topCat = byCat.FirstOrDefault(); var prevMonth = month == 1 ? 12 : month - 1; var prevYear = month == 1 ? year - 1 : year; var (prevStart, _) = ChinaClock.MonthRangeUtc(prevYear, prevMonth); var prevExpense = await db.Transactions .Where(t => t.UserId == Uid && t.LedgerId == resolvedLedgerId.Value && t.Type == TransactionType.Expense && t.OccurredAt >= prevStart && t.OccurredAt < start) .SumAsync(t => t.Amount); var trend = prevExpense == 0 ? "这是你的第一个月记账哦" : totalExpense > prevExpense * 1.05m ? $"比上月多花了 ¥{(totalExpense - prevExpense):F0}" : totalExpense < prevExpense * 0.95m ? $"比上月省了 ¥{(prevExpense - totalExpense):F0}" : "和上月差不多"; var aiText = topCat is null ? "这个月还没开始花钱呢,快去记一笔吧" : $"这个月「{topCat.Name}」花最多,共 ¥{topCat.Amount:F0}(占 {topCat.Percent:F0}%),{trend}。"; return Ok(new MonthStatsDto(year, month, totalExpense, totalIncome, byCat, daily) { AiAnalysis = aiText }); } [HttpGet("stats/period")] public async Task> PeriodStats( [FromQuery] string period = "month", [FromQuery] DateTime? anchor = null, [FromQuery] long? ledgerId = null) { var normalizedPeriod = period.Trim().ToLowerInvariant(); if (normalizedPeriod is not ("week" or "month" or "year")) return BadRequest(new ApiError( "PERIOD_INVALID", "统计周期必须是 week、month 或 year")); var resolvedLedgerId = await ledgers.ResolveAsync(Uid, ledgerId); if (!resolvedLedgerId.HasValue) return BadRequest(new ApiError( "LEDGER_NOT_FOUND", "账本不存在或无权访问")); var selected = DateTime.SpecifyKind( (anchor ?? ChinaClock.Now).Date, DateTimeKind.Unspecified); DateTime localStart; DateTime localEnd; DateTime previousLocalStart; string periodLabel; switch (normalizedPeriod) { case "week": { var offset = ((int)selected.DayOfWeek + 6) % 7; localStart = selected.AddDays(-offset); localEnd = localStart.AddDays(7); previousLocalStart = localStart.AddDays(-7); periodLabel = $"{localStart:MM月dd日} - {localEnd.AddDays(-1):MM月dd日}"; break; } case "year": localStart = new DateTime(selected.Year, 1, 1); localEnd = localStart.AddYears(1); previousLocalStart = localStart.AddYears(-1); periodLabel = $"{localStart.Year}年"; break; default: localStart = new DateTime(selected.Year, selected.Month, 1); localEnd = localStart.AddMonths(1); previousLocalStart = localStart.AddMonths(-1); periodLabel = $"{localStart.Year}年{localStart.Month}月"; break; } var start = ChinaClock.ToUtc(localStart); var end = ChinaClock.ToUtc(localEnd); var previousStart = ChinaClock.ToUtc(previousLocalStart); var list = await db.Transactions .Include(transaction => transaction.Category) .Where(transaction => transaction.UserId == Uid && transaction.LedgerId == resolvedLedgerId.Value && transaction.OccurredAt >= start && transaction.OccurredAt < end) .ToListAsync(); var expenses = list .Where(transaction => transaction.Type == TransactionType.Expense) .ToList(); var totalExpense = expenses.Sum(transaction => transaction.Amount); var totalIncome = list .Where(transaction => transaction.Type == TransactionType.Income) .Sum(transaction => transaction.Amount); var byCategory = expenses .GroupBy(transaction => transaction.Category) .Select(group => new CategoryStatDto( group.Key.Id, group.Key.Name, group.Key.IconKey, group.Sum(transaction => transaction.Amount), totalExpense == 0 ? 0 : (double)(group.Sum(transaction => transaction.Amount) / totalExpense * 100), group.Key.ColorKey)) .OrderByDescending(item => item.Amount) .ThenBy(item => item.Name) .ToList(); List trend; if (normalizedPeriod == "year") { trend = Enumerable.Range(0, 12) .Select(index => { var pointStart = localStart.AddMonths(index); var pointEnd = pointStart.AddMonths(1); var pointItems = list.Where(transaction => { var local = ChinaClock.ToLocal(transaction.OccurredAt); return local >= pointStart && local < pointEnd; }); return new PeriodTrendPointDto( $"{pointStart.Month}月", pointStart, pointItems.Where(item => item.Type == TransactionType.Expense).Sum(item => item.Amount), pointItems.Where(item => item.Type == TransactionType.Income).Sum(item => item.Amount)); }) .ToList(); } else { var dayCount = (localEnd - localStart).Days; trend = Enumerable.Range(0, dayCount) .Select(index => { var date = localStart.AddDays(index); var dayItems = list.Where(transaction => ChinaClock.ToLocal(transaction.OccurredAt).Date == date.Date); var label = normalizedPeriod == "week" ? new[] { "周一", "周二", "周三", "周四", "周五", "周六", "周日" }[index] : $"{date.Day}日"; return new PeriodTrendPointDto( label, date, dayItems.Where(item => item.Type == TransactionType.Expense).Sum(item => item.Amount), dayItems.Where(item => item.Type == TransactionType.Income).Sum(item => item.Amount)); }) .ToList(); } var previousExpense = await db.Transactions .Where(transaction => transaction.UserId == Uid && transaction.LedgerId == resolvedLedgerId.Value && transaction.Type == TransactionType.Expense && transaction.OccurredAt >= previousStart && transaction.OccurredAt < start) .SumAsync(transaction => transaction.Amount); var topCategory = byCategory.FirstOrDefault(); string analysis; if (topCategory is null) { analysis = "这个周期还没有支出记录"; } else if (previousExpense == 0) { analysis = $"{topCategory.Name}支出最多,共 ¥{topCategory.Amount:F0}"; } else { var difference = totalExpense - previousExpense; analysis = Math.Abs(difference) < previousExpense * 0.05m ? $"{topCategory.Name}支出最多,与上个周期基本持平" : difference > 0 ? $"{topCategory.Name}支出最多,比上个周期多 ¥{difference:F0}" : $"{topCategory.Name}支出最多,比上个周期少 ¥{-difference:F0}"; } return Ok(new PeriodStatsDto( normalizedPeriod, periodLabel, localStart, localEnd, totalExpense, totalIncome, totalIncome - totalExpense, list.Count, byCategory, trend, analysis)); } private async Task PermanentDeleteCore(Transaction transaction) { var cards = await db.ChatMessages .Where(message => message.TransactionId == transaction.Id) .ToListAsync(); foreach (var card in cards) { card.TransactionId = null; card.Content = """{"deleted":true}"""; } db.Transactions.Remove(transaction); } private static TransactionType? ParseType(string? value) => value switch { "income" => TransactionType.Income, "expense" => TransactionType.Expense, _ => null, }; private static bool HasVersionConflict(Transaction transaction, DateTime? baseUpdatedAt) { if (!baseUpdatedAt.HasValue) return false; var baseline = baseUpdatedAt.Value.Kind == DateTimeKind.Utc ? baseUpdatedAt.Value : baseUpdatedAt.Value.ToUniversalTime(); return transaction.UpdatedAt > baseline.AddMilliseconds(1); } private static TransactionSource SourceFromWire(string? source) => source switch { "voice" => TransactionSource.Voice, "ocr" => TransactionSource.ReceiptOcr, "screenshot" => TransactionSource.Screenshot, "accessibility" => TransactionSource.Accessibility, "notification" => TransactionSource.Notification, "recognition_ai" => TransactionSource.RecognitionAi, "local_ocr" => TransactionSource.LocalOcr, _ => TransactionSource.Manual, }; private static DateTime NormalizeOccurredAt(DateTime value) => value.Kind == DateTimeKind.Utc ? value : ChinaClock.ToUtc(value); internal static TransactionDto ToDto(Transaction t, Category c) => new( t.Id, t.LedgerId, c.Id, c.Name, c.IconKey, t.Type == TransactionType.Income ? "income" : "expense", t.Amount, t.Source == TransactionSource.AiChat && !string.IsNullOrWhiteSpace(t.SourceText) && string.Equals(t.Note?.Trim(), t.SourceText.Trim(), StringComparison.Ordinal) ? TransactionNoteFormatter.BuildNote(t.SourceText, c.Name) : t.Note, t.PaymentMethod, t.OccurredAt, t.Source switch { TransactionSource.AiChat => "ai_chat", TransactionSource.Voice => "voice", TransactionSource.ReceiptOcr => "ocr", TransactionSource.Screenshot => "screenshot", TransactionSource.Accessibility => "accessibility", TransactionSource.Notification => "notification", TransactionSource.RecognitionAi => "recognition_ai", TransactionSource.LocalOcr => "local_ocr", _ => "manual", }, t.SourceText, t.IsDeleted, c.ColorKey, t.UpdatedAt); }