import 'dart:math' as math; import 'package:flutter/foundation.dart' show kIsWeb, compute; import 'package:flutter_sms_inbox/flutter_sms_inbox.dart'; import '../models/subscription_model.dart'; import '../utils/logger.dart'; import '../temp/test_sms_data.dart'; import '../services/subscription_url_matcher.dart'; import '../utils/platform_helper.dart'; import '../utils/business_day_util.dart'; import '../services/sms_scan/sms_scan_result.dart'; import '../models/payment_card_suggestion.dart'; class SmsScanner { final SmsQuery _query = SmsQuery(); Future> scanForSubscriptions() async { try { List smsList; Log.d('SmsScanner: 스캔 시작'); // 플랫폼별 분기 처리 if (kIsWeb) { // 웹 환경: 테스트 데이터 사용 Log.i('SmsScanner: 웹 환경에서 테스트 데이터 사용'); smsList = TestSmsData.getTestData(); Log.d('SmsScanner: 테스트 데이터 개수: ${smsList.length}'); } else if (PlatformHelper.isIOS) { // iOS: SMS 접근 불가, 빈 리스트 반환 Log.w('SmsScanner: iOS에서는 SMS 스캔 불가'); return []; } else if (PlatformHelper.isAndroid) { // Android: flutter_sms_inbox 사용 Log.i('SmsScanner: Android에서 실제 SMS 스캔'); smsList = await _scanAndroidSms(); Log.d('SmsScanner: 스캔된 SMS 개수: ${smsList.length}'); } else { // 기타 플랫폼 Log.w('SmsScanner: 지원하지 않는 플랫폼'); return []; } final filteredSms = smsList .whereType>() .where(_isEligibleSubscriptionSms) .toList(); Log.d( 'SmsScanner: 유효 결제 SMS ${filteredSms.length}건 / 전체 ${smsList.length}건'); if (filteredSms.isEmpty) { Log.w('SmsScanner: 결제 패턴 SMS 미검출'); return []; } // SMS 데이터를 분석하여 반복 결제되는 구독 식별 final List subscriptions = []; final serviceGroups = _groupMessagesByIdentifier(filteredSms); Log.d('SmsScanner: 그룹화된 키 수: ${serviceGroups.length}'); for (final entry in serviceGroups.entries) { Log.d('SmsScanner: 그룹 "${entry.key}" - 메시지 ${entry.value.length}건'); final repeatResult = _detectRepeatingSubscriptions(entry.value); if (repeatResult == null) { Log.d('SmsScanner: 반복 조건 불충족 - ${entry.key}'); continue; } final result = _parseSms(repeatResult.baseMessage, repeatResult.repeatCount); if (result != null) { Log.i( 'SmsScanner: 구독 추가: ${result.model.serviceName}, 반복 횟수: ${result.model.repeatCount}'); subscriptions.add(result); } else { Log.w('SmsScanner: 구독 파싱 실패: ${entry.key}'); } } Log.d('SmsScanner: 최종 구독 개수: ${subscriptions.length}'); return subscriptions; } catch (e) { Log.e('SmsScanner: 예외 발생', e); throw Exception('SMS 스캔 중 오류 발생: $e'); } } // Android에서 flutter_sms_inbox를 사용한 SMS 스캔 Future> _scanAndroidSms() async { try { final messages = await _query.getAllSms; // Isolate로 넘길 수 있도록 직렬화 (plugin 객체 직접 전달 불가) final serialized = >[]; for (final message in messages) { serialized.add({ 'body': message.body ?? '', 'address': message.address ?? '', 'dateMillis': (message.date ?? DateTime.now()).millisecondsSinceEpoch, }); } // 대량 파싱은 별도 Isolate로 오프로딩 final List> smsList = await compute(_parseRawSmsBatch, serialized); return smsList; } catch (e) { Log.e('SmsScanner: Android SMS 스캔 실패', e); return []; } } // (사용되지 않음) 기존 단일 메시지 파서 제거됨 - Isolate 배치 파싱으로 대체 SmsScanResult? _parseSms(Map sms, int repeatCount) { try { final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스'; final monthlyCost = (sms['monthlyCost'] as num?)?.toDouble() ?? 0.0; final billingCycle = SubscriptionModel.normalizeBillingCycle( sms['billingCycle'] as String? ?? 'monthly'); final nextBillingDateStr = sms['nextBillingDate'] as String?; // 실제 반복 횟수 사용 (테스트 데이터에서는 이미 제공됨) final actualRepeatCount = repeatCount > 0 ? repeatCount : 1; final message = sms['message'] as String? ?? ''; // 통화 단위 감지 - 메시지 내용과 서비스명 모두 검사 String currency = _detectCurrency(message); // 서비스명에 따라 통화 단위 재확인 final dollarServices = [ 'GitHub', 'GitHub Pro', 'Netflix US', 'Spotify', 'Spotify Premium' ]; if (dollarServices.any((service) => serviceName.contains(service))) { Log.d('서비스명 $serviceName으로 USD 통화 단위 확정'); currency = 'USD'; } DateTime? nextBillingDate; // 외부에서 계산된 다음 결제일이 있으면 우선 사용 final overrideNext = sms['overrideNextBillingDate'] as String?; if (overrideNext != null) { nextBillingDate = DateTime.tryParse(overrideNext); } else if (nextBillingDateStr != null) { nextBillingDate = DateTime.tryParse(nextBillingDateStr); } DateTime? lastPaymentDate; final previousPaymentDateStr = sms['previousPaymentDate'] as String?; if (previousPaymentDateStr != null && previousPaymentDateStr.isNotEmpty) { lastPaymentDate = DateTime.tryParse(previousPaymentDateStr); } // 결제일 계산 로직 추가 - 미래 날짜가 아니면 조정 DateTime adjustedNextBillingDate = _calculateNextBillingDate( nextBillingDate ?? DateTime.now().add(const Duration(days: 30)), billingCycle, ); // 주말/공휴일 보정 adjustedNextBillingDate = BusinessDayUtil.nextBusinessDay(adjustedNextBillingDate); final model = SubscriptionModel( id: DateTime.now().millisecondsSinceEpoch.toString(), serviceName: serviceName, monthlyCost: monthlyCost, billingCycle: billingCycle, nextBillingDate: adjustedNextBillingDate, isAutoDetected: true, repeatCount: actualRepeatCount, lastPaymentDate: lastPaymentDate, websiteUrl: _extractWebsiteUrl(serviceName), currency: currency, // 통화 단위 설정 ); final suggestion = _extractPaymentCardSuggestion(message); return SmsScanResult( model: model, cardSuggestion: suggestion, rawMessage: message, ); } catch (e) { return null; } } PaymentCardSuggestion? _extractPaymentCardSuggestion(String message) { if (message.isEmpty) return null; final issuer = _detectCardIssuer(message); final last4 = _detectCardLast4(message); if (issuer == null && last4 == null) { return null; } return PaymentCardSuggestion( issuerName: issuer ?? '결제수단', last4: last4, source: 'sms', ); } String? _detectCardIssuer(String message) { final normalized = message.toLowerCase(); const issuerKeywords = { 'KB국민카드': ['kb국민', '국민카드', 'kb card', 'kookmin'], '신한카드': ['신한', 'shinhan'], '우리카드': ['우리카드', 'woori'], '하나카드': ['하나카드', 'hana card', 'hana'], '농협카드': ['농협', 'nh', '농협카드'], 'BC카드': ['bc카드', 'bc card'], '삼성카드': ['삼성카드', 'samsung card'], '롯데카드': ['롯데카드', 'lotte card'], '현대카드': ['현대카드', 'hyundai card'], '씨티카드': ['씨티카드', 'citi card', 'citibank'], '카카오뱅크': ['카카오뱅크', 'kakaobank'], '토스뱅크': ['토스뱅크', 'toss bank'], 'Visa': ['visa'], 'Mastercard': ['mastercard', 'master card'], 'American Express': ['amex', 'american express'], }; for (final entry in issuerKeywords.entries) { final match = entry.value.any((keyword) => normalized.contains(keyword)); if (match) { return entry.key; } } return null; } String? _detectCardLast4(String message) { final patterns = [ RegExp(r'\*{3,}\s*(\d{4})'), RegExp(r'끝번호\s*(\d{4})'), RegExp(r'마지막\s*(\d{4})'), RegExp(r'\((\d{4})\)'), RegExp(r'ending(?: in)?\s*(\d{4})', caseSensitive: false), ]; for (final pattern in patterns) { final match = pattern.firstMatch(message); if (match != null && match.groupCount >= 1) { final candidate = match.group(1); if (candidate != null && candidate.length == 4) { return candidate; } } } return null; } // 다음 결제일 계산 (현재 날짜 기준으로 조정) DateTime _calculateNextBillingDate( DateTime billingDate, String billingCycle) { final now = DateTime.now(); // 결제일이 이미 미래인 경우 그대로 반환 if (billingDate.isAfter(now)) { return billingDate; } // 결제 주기별 다음 결제일 계산 if (billingCycle == 'monthly') { int month = now.month; int year = now.year; // 현재 달의 결제일이 이미 지났으면 다음 달로 이동 if (now.day >= billingDate.day) { month = month + 1; if (month > 12) { month = 1; year = year + 1; } } final dim = BusinessDayUtil.daysInMonth(year, month); final day = billingDate.day.clamp(1, dim); return DateTime(year, month, day); } else if (billingCycle == 'yearly') { // 올해의 결제일이 지났는지 확인 final thisYearBilling = DateTime(now.year, billingDate.month, billingDate.day); if (thisYearBilling.isBefore(now)) { return DateTime(now.year + 1, billingDate.month, billingDate.day); } else { return thisYearBilling; } } else if (billingCycle == 'weekly') { // 가장 가까운 다음 주 같은 요일 계산 final dayDifference = billingDate.weekday - now.weekday; final daysToAdd = dayDifference > 0 ? dayDifference : 7 + dayDifference; return now.add(Duration(days: daysToAdd)); } // 기본 처리: 30일 후 return now.add(const Duration(days: 30)); } String? _extractWebsiteUrl(String serviceName) { // SubscriptionUrlMatcher 서비스를 사용하여 URL 매칭 시도 final suggestedUrl = SubscriptionUrlMatcher.findMatchingUrl(serviceName); // 매칭된 URL이 있으면 반환, 없으면 기존 매핑 사용 if (suggestedUrl != null && suggestedUrl.isNotEmpty) { return suggestedUrl; } // 기존 하드코딩된 매핑 (필요한 경우 폴백으로 사용) final Map serviceUrls = { '넷플릭스': 'https://www.netflix.com', '디즈니플러스': 'https://www.disneyplus.com', '유튜브프리미엄': 'https://www.youtube.com', 'YouTube Premium': 'https://www.youtube.com', '애플 iCloud': 'https://www.icloud.com', 'Microsoft 365': 'https://www.microsoft.com/microsoft-365', '멜론': 'https://www.melon.com', '웨이브': 'https://www.wavve.com', 'Apple Music': 'https://www.apple.com/apple-music', 'Netflix': 'https://www.netflix.com', 'Disney+': 'https://www.disneyplus.com', 'Spotify': 'https://www.spotify.com', }; return serviceUrls[serviceName]; } // 메시지에서 통화 단위를 감지하는 함수 String _detectCurrency(String message) { final dollarKeywords = [ '\$', 'USD', 'dollar', '달러', 'dollars', 'US\$', // 해외 서비스 이름 'Netflix US', 'Spotify Premium', 'Apple US', 'Amazon US', 'GitHub' ]; // 특정 서비스명으로 통화 단위 결정 final Map serviceCurrencyMap = { 'Netflix US': 'USD', 'Spotify Premium': 'USD', 'Spotify': 'USD', 'GitHub': 'USD', 'GitHub Pro': 'USD', }; // 서비스명 기반 통화 단위 확인 for (final service in serviceCurrencyMap.keys) { if (message.contains(service)) { Log.d('_detectCurrency: $service는 USD 서비스로 판별됨'); return 'USD'; } } // 메시지에 달러 관련 키워드가 있는지 확인 for (final keyword in dollarKeywords) { if (message.toLowerCase().contains(keyword.toLowerCase())) { Log.d('_detectCurrency: USD 키워드 발견: $keyword'); return 'USD'; } } // 기본값은 원화 return 'KRW'; } } const List _paymentLikeKeywords = [ '승인', '결제', '청구', 'charged', 'charge', 'payment', 'billed', 'purchase', ]; const List _blockedKeywords = [ 'otp', '인증', '보안', 'verification', 'code', '코드', 'password', 'pw', '일회성', '1회용', '보안문자', ]; bool _containsPaymentKeyword(String message) { if (message.isEmpty) return false; final normalized = message.toLowerCase(); return _paymentLikeKeywords.any( (keyword) => normalized.contains(keyword.toLowerCase()), ); } bool _containsBlockedKeyword(String message) { if (message.isEmpty) return false; final normalized = message.toLowerCase(); return _blockedKeywords.any( (keyword) => normalized.contains(keyword.toLowerCase()), ); } bool _isEligibleSubscriptionSms(Map sms) { final amount = (sms['monthlyCost'] as num?)?.toDouble(); if (amount == null || amount <= 0) { return false; } final message = sms['message'] as String? ?? ''; final isPaymentLike = (sms['isPaymentLike'] as bool?) ?? _containsPaymentKeyword(message); final isBlocked = (sms['isBlocked'] as bool?) ?? _containsBlockedKeyword(message); if (!isPaymentLike || isBlocked) { return false; } return true; } // ===== Isolate 오프로딩용 Top-level 파서 ===== // 대량 SMS 파싱: plugin 객체를 직렬화한 맵 리스트를 받아 구독 후보 맵 리스트로 변환 List> _parseRawSmsBatch( List> messages) { final amountPatterns = [ RegExp(r'(\d{1,3}(?:,\d{3})*(?:\.\d{1,2})?)\s*(?:원|₩)'), RegExp(r'(?:원|₩)\s*(\d{1,3}(?:,\d{3})*(?:\.\d{1,2})?)'), RegExp(r'(?:(?:US)?\$)\s*(\d+(?:\.\d{1,2})?)', caseSensitive: false), RegExp(r'(\d+(?:,\d{3})*(?:\.\d{1,2})?)\s*(?:USD|KRW)', caseSensitive: false), RegExp(r'(?:USD|KRW)\s*(\d+(?:,\d{3})*(?:\.\d{1,2})?)', caseSensitive: false), RegExp(r'(?:결제|승인)[^0-9]{0,12}(\d{1,3}(?:,\d{3})*(?:\.\d{1,2})?)'), ]; final results = >[]; for (final m in messages) { final body = (m['body'] as String?) ?? ''; final sender = (m['address'] as String?) ?? ''; final dateMillis = (m['dateMillis'] as int?) ?? DateTime.now().millisecondsSinceEpoch; final date = DateTime.fromMillisecondsSinceEpoch(dateMillis); final serviceName = _isoExtractServiceName(body, sender); final amount = _isoExtractAmount(body, amountPatterns); final isPaymentLike = _containsPaymentKeyword(body); final isBlocked = _containsBlockedKeyword(body); final billingCycle = _isoExtractBillingCycle(body); final nextBillingDate = _isoCalculateNextBillingFromDate(date, billingCycle); final normalizedBody = _isoNormalizeBody(body); results.add({ 'serviceName': serviceName, 'address': sender, 'monthlyCost': amount, 'billingCycle': billingCycle, 'message': body, 'normalizedBody': normalizedBody, 'nextBillingDate': nextBillingDate.toIso8601String(), 'previousPaymentDate': date.toIso8601String(), 'isPaymentLike': isPaymentLike, 'isBlocked': isBlocked, }); } return results; } String _isoExtractServiceName(String body, String sender) { final servicePatterns = { 'netflix': '넷플릭스', 'youtube': '유튜브 프리미엄', 'spotify': 'Spotify', 'disney': '디즈니플러스', 'apple': 'Apple', 'microsoft': 'Microsoft', 'github': 'GitHub', 'adobe': 'Adobe', '멜론': '멜론', '웨이브': '웨이브', }; final combined = '$body $sender'.toLowerCase(); for (final e in servicePatterns.entries) { if (combined.contains(e.key)) return e.value; } return _isoExtractServiceNameFromSender(sender); } String _isoExtractServiceNameFromSender(String sender) { if (RegExp(r'^\d+$').hasMatch(sender)) { return '알 수 없는 서비스'; } return sender.replaceAll(RegExp(r'[^\w\s가-힣]'), '').trim(); } double? _isoExtractAmount(String body, List patterns) { for (final pattern in patterns) { final match = pattern.firstMatch(body); if (match != null) { var amountStr = match.group(1) ?? ''; amountStr = amountStr.replaceAll(',', ''); final parsed = double.tryParse(amountStr); if (parsed != null) return parsed; } } return null; } String _isoExtractBillingCycle(String body) { if (body.contains('월') || body.toLowerCase().contains('monthly') || body.contains('매월')) { return 'monthly'; } else if (body.contains('년') || body.toLowerCase().contains('yearly') || body.toLowerCase().contains('annual')) { return 'yearly'; } else if (body.contains('주') || body.toLowerCase().contains('weekly')) { return 'weekly'; } return 'monthly'; } String _isoNormalizeBody(String body) { final patterns = [ RegExp(r'\d{4}[./-]\d{1,2}[./-]\d{1,2}'), RegExp(r'\d{1,2}[./-]\d{1,2}[./-]\d{2,4}'), RegExp(r'\d{4}\s*년\s*\d{1,2}\s*월\s*\d{1,2}\s*일'), RegExp(r'\d{1,2}\s*월\s*\d{1,2}\s*일'), RegExp(r'\d{1,2}:\d{2}'), ]; var normalized = body; for (final pattern in patterns) { normalized = normalized.replaceAll(pattern, ' '); } return normalized.replaceAll(RegExp(r'\s+'), ' ').trim().toLowerCase(); } DateTime _isoCalculateNextBillingFromDate( DateTime lastDate, String billingCycle) { switch (billingCycle) { case 'monthly': return DateTime(lastDate.year, lastDate.month + 1, lastDate.day); case 'yearly': return DateTime(lastDate.year + 1, lastDate.month, lastDate.day); case 'weekly': return lastDate.add(const Duration(days: 7)); default: return lastDate.add(const Duration(days: 30)); } } Map>> _groupMessagesByIdentifier( List smsList) { final Map>> groups = {}; for (final smsEntry in smsList) { if (smsEntry is! Map) continue; final sms = Map.from(smsEntry as Map); final serviceName = (sms['serviceName'] as String?)?.trim(); final address = (sms['address'] as String?)?.trim(); final sender = (sms['sender'] as String?)?.trim(); String key = (serviceName != null && serviceName.isNotEmpty && serviceName != '알 수 없는 서비스') ? serviceName : (address?.isNotEmpty == true ? address! : (sender?.isNotEmpty == true ? sender! : 'unknown')); groups.putIfAbsent(key, () => []).add(sms); } return groups; } class _RepeatDetectionResult { _RepeatDetectionResult({ required this.baseMessage, required this.repeatCount, }); final Map baseMessage; final int repeatCount; } enum _MatchType { none, monthly, yearly, identical } class _MatchedPair { _MatchedPair(this.first, this.second, this.type); final int first; final int second; final _MatchType type; } _RepeatDetectionResult? _detectRepeatingSubscriptions( List> messages) { if (messages.length < 2) return null; final sorted = messages.map((sms) => Map.from(sms)).toList() ..sort((a, b) { final da = _parsePaymentDate(a['previousPaymentDate']); final db = _parsePaymentDate(b['previousPaymentDate']); return (db ?? DateTime.fromMillisecondsSinceEpoch(0)) .compareTo(da ?? DateTime.fromMillisecondsSinceEpoch(0)); }); final matchedIndices = {}; final matchedPairs = <_MatchedPair>[]; for (int i = 0; i < sorted.length - 1; i++) { for (int j = i + 1; j < sorted.length && j <= i + 5; j++) { final matchType = _evaluateMatch(sorted[i], sorted[j]); if (matchType == _MatchType.none) continue; matchedIndices.add(i); matchedIndices.add(j); matchedPairs.add(_MatchedPair(i, j, matchType)); break; } } if (matchedIndices.length < 2) return null; final hasValidInterval = matchedPairs.any((pair) => pair.type == _MatchType.monthly || pair.type == _MatchType.yearly); if (!hasValidInterval) return null; final baseIndex = matchedIndices .reduce((value, element) => value < element ? value : element); final baseMessage = Map.from(sorted[baseIndex]); final overrideDate = _deriveNextBillingDate(sorted, matchedPairs); if (overrideDate != null) { baseMessage['overrideNextBillingDate'] = overrideDate.toIso8601String(); } return _RepeatDetectionResult( baseMessage: baseMessage, repeatCount: matchedIndices.length, ); } _MatchType _evaluateMatch( Map recent, Map previous) { final amountMatch = _matchByAmountAndInterval(recent, previous); if (amountMatch != _MatchType.none) { return amountMatch; } if (_areBodiesEquivalent(recent, previous)) { final inferredInterval = _classifyIntervalByDates(recent, previous); return inferredInterval == _MatchType.none ? _MatchType.identical : inferredInterval; } return _MatchType.none; } _MatchType _matchByAmountAndInterval( Map a, Map b) { final amountA = (a['monthlyCost'] as num?)?.toDouble(); final amountB = (b['monthlyCost'] as num?)?.toDouble(); if (amountA == null || amountB == null) return _MatchType.none; if (!_isAmountSimilar(amountA, amountB)) return _MatchType.none; return _classifyIntervalByDates(a, b); } _MatchType _classifyIntervalByDates( Map a, Map b) { final dateA = _parsePaymentDate(a['previousPaymentDate']); final dateB = _parsePaymentDate(b['previousPaymentDate']); if (dateA == null || dateB == null) return _MatchType.none; final diffDays = (dateA.difference(dateB).inDays).abs(); if (diffDays >= 27 && diffDays <= 34) { return _MatchType.monthly; } if (diffDays >= 350 && diffDays <= 380) { return _MatchType.yearly; } return _MatchType.none; } bool _areBodiesEquivalent(Map a, Map b) { final normalizedA = _getNormalizedBody(a); final normalizedB = _getNormalizedBody(b); if (normalizedA.isEmpty || normalizedB.isEmpty) return false; return normalizedA == normalizedB; } String _getNormalizedBody(Map sms) { final cached = sms['normalizedBody'] as String?; if (cached != null && cached.isNotEmpty) return cached; final message = sms['message'] as String? ?? ''; final normalized = _isoNormalizeBody(message); sms['normalizedBody'] = normalized; return normalized; } DateTime? _deriveNextBillingDate( List> sorted, List<_MatchedPair> pairs) { if (pairs.isEmpty) return null; final targetPair = pairs.firstWhere( (pair) => pair.type == _MatchType.monthly || pair.type == _MatchType.yearly, orElse: () => pairs.first, ); final recent = sorted[targetPair.first]; final previous = sorted[targetPair.second]; final recentDate = _parsePaymentDate(recent['previousPaymentDate']); final prevDate = _parsePaymentDate(previous['previousPaymentDate']); return _calculateNextBillingFromPair(recentDate, prevDate, targetPair.type); } DateTime? _calculateNextBillingFromPair( DateTime? recentDate, DateTime? prevDate, _MatchType type) { if (recentDate == null) return null; if (type == _MatchType.monthly) { DateTime candidate = _addMonths(recentDate, 1); while (!candidate.isAfter(DateTime.now())) { candidate = _addMonths(candidate, 1); } return BusinessDayUtil.nextBusinessDay(candidate); } if (type == _MatchType.yearly) { DateTime candidate = DateTime( recentDate.year + 1, recentDate.month, _clampDay( recentDate.day, BusinessDayUtil.daysInMonth(recentDate.year + 1, recentDate.month), ), ); while (!candidate.isAfter(DateTime.now())) { candidate = DateTime(candidate.year + 1, candidate.month, candidate.day); } return BusinessDayUtil.nextBusinessDay(candidate); } return _inferMonthlyNextBilling(recentDate, prevDate); } DateTime? _inferMonthlyNextBilling(DateTime recentDate, DateTime? prevDate) { int baseDay = recentDate.day; if (prevDate != null) { final candidate = DateTime(prevDate.year, prevDate.month, baseDay); if (BusinessDayUtil.isWeekend(candidate)) { final diff = prevDate.difference(candidate).inDays; if (diff < 1 || diff > 3) { baseDay = prevDate.day; } } } final now = DateTime.now(); int year = now.year; int month = now.month; if (now.day >= baseDay) { month += 1; if (month > 12) { month = 1; year += 1; } } final dim = BusinessDayUtil.daysInMonth(year, month); final day = _clampDay(baseDay, dim); var nextBilling = DateTime(year, month, day); return BusinessDayUtil.nextBusinessDay(nextBilling); } DateTime _addMonths(DateTime date, int months) { final totalMonths = (date.month - 1) + months; final year = date.year + totalMonths ~/ 12; final month = totalMonths % 12 + 1; final dim = BusinessDayUtil.daysInMonth(year, month); final day = _clampDay(date.day, dim); return DateTime(year, month, day); } int _clampDay(int day, int maxDay) { if (day < 1) return 1; if (day > maxDay) return maxDay; return day; } DateTime? _parsePaymentDate(dynamic value) { if (value is DateTime) return value; if (value is String && value.isNotEmpty) { return DateTime.tryParse(value); } return null; } bool _isAmountSimilar(double a, double b) { final diff = (a - b).abs(); final base = math.max(a.abs(), b.abs()); final tolerance = base * 0.01; // 1% 허용 final minTolerance = base < 10 ? 0.1 : 1.0; return diff <= math.max(tolerance, minTolerance); }