feat: enhance sms scanner repeat detection
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart' show kIsWeb, compute;
|
import 'package:flutter/foundation.dart' show kIsWeb, compute;
|
||||||
import 'package:flutter_sms_inbox/flutter_sms_inbox.dart';
|
import 'package:flutter_sms_inbox/flutter_sms_inbox.dart';
|
||||||
import '../models/subscription_model.dart';
|
import '../models/subscription_model.dart';
|
||||||
@@ -38,81 +40,20 @@ class SmsScanner {
|
|||||||
|
|
||||||
// SMS 데이터를 분석하여 반복 결제되는 구독 식별
|
// SMS 데이터를 분석하여 반복 결제되는 구독 식별
|
||||||
final List<SubscriptionModel> subscriptions = [];
|
final List<SubscriptionModel> subscriptions = [];
|
||||||
final Map<String, List<Map<String, dynamic>>> serviceGroups = {};
|
final serviceGroups = _groupMessagesByIdentifier(smsList);
|
||||||
|
|
||||||
// 서비스명별로 SMS 메시지 그룹화
|
Log.d('SmsScanner: 그룹화된 키 수: ${serviceGroups.length}');
|
||||||
for (final sms in smsList) {
|
|
||||||
final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스';
|
|
||||||
if (!serviceGroups.containsKey(serviceName)) {
|
|
||||||
serviceGroups[serviceName] = [];
|
|
||||||
}
|
|
||||||
serviceGroups[serviceName]!.add(sms);
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.d('SmsScanner: 그룹화된 서비스 수: ${serviceGroups.length}');
|
|
||||||
|
|
||||||
// 그룹화된 데이터로 구독 분석
|
|
||||||
for (final entry in serviceGroups.entries) {
|
for (final entry in serviceGroups.entries) {
|
||||||
Log.d('SmsScanner: 서비스 "${entry.key}" - 메시지 개수: ${entry.value.length}');
|
Log.d('SmsScanner: 그룹 "${entry.key}" - 메시지 ${entry.value.length}건');
|
||||||
|
final repeatResult = _detectRepeatingSubscriptions(entry.value);
|
||||||
// 2회 이상 반복된 서비스만 구독으로 간주
|
if (repeatResult == null) {
|
||||||
if (entry.value.length >= 2) {
|
Log.d('SmsScanner: 반복 조건 불충족 - ${entry.key}');
|
||||||
// 결제일 패턴 유추를 위해 최근 2개의 결제일을 사용
|
continue;
|
||||||
final messages = [...entry.value];
|
|
||||||
messages.sort((a, b) {
|
|
||||||
final da = DateTime.tryParse(a['previousPaymentDate'] ?? '') ??
|
|
||||||
DateTime(1970);
|
|
||||||
final db = DateTime.tryParse(b['previousPaymentDate'] ?? '') ??
|
|
||||||
DateTime(1970);
|
|
||||||
return db.compareTo(da); // desc
|
|
||||||
});
|
|
||||||
|
|
||||||
final mostRecent = messages.first;
|
|
||||||
DateTime? recentDate =
|
|
||||||
DateTime.tryParse(mostRecent['previousPaymentDate'] ?? '');
|
|
||||||
DateTime? prevDate = messages.length > 1
|
|
||||||
? DateTime.tryParse(messages[1]['previousPaymentDate'] ?? '')
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// 기본 결제 일자(일단위) 추정: 가장 최근 결제의 일자
|
|
||||||
int baseDay = recentDate?.day ?? DateTime.now().day;
|
|
||||||
|
|
||||||
// 이전 결제가 주말 이월로 보이는 패턴인지 검사하여 baseDay 보정
|
|
||||||
if (recentDate != null && 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) {
|
|
||||||
// 예: 12일(토)→14일(월)
|
|
||||||
baseDay = baseDay; // 유지
|
|
||||||
} else {
|
|
||||||
// 차이가 크면 이전 달의 일자를 채택
|
|
||||||
baseDay = prevDate.day;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 다음 결제일 계산: 기준 일자를 바탕으로 다음 달 또는 이번 달로 설정 후 영업일 보정
|
final subscription =
|
||||||
final DateTime now = DateTime.now();
|
_parseSms(repeatResult.baseMessage, repeatResult.repeatCount);
|
||||||
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 = baseDay.clamp(1, dim);
|
|
||||||
DateTime nextBilling = DateTime(year, month, day);
|
|
||||||
nextBilling = BusinessDayUtil.nextBusinessDay(nextBilling);
|
|
||||||
|
|
||||||
// 가장 최근 SMS 맵에 override 값으로 주입
|
|
||||||
final serviceSms = Map<String, dynamic>.from(mostRecent);
|
|
||||||
serviceSms['overrideNextBillingDate'] = nextBilling.toIso8601String();
|
|
||||||
|
|
||||||
final subscription = _parseSms(serviceSms, entry.value.length);
|
|
||||||
if (subscription != null) {
|
if (subscription != null) {
|
||||||
Log.i(
|
Log.i(
|
||||||
'SmsScanner: 구독 추가: ${subscription.serviceName}, 반복 횟수: ${subscription.repeatCount}');
|
'SmsScanner: 구독 추가: ${subscription.serviceName}, 반복 횟수: ${subscription.repeatCount}');
|
||||||
@@ -120,9 +61,6 @@ class SmsScanner {
|
|||||||
} else {
|
} else {
|
||||||
Log.w('SmsScanner: 구독 파싱 실패: ${entry.key}');
|
Log.w('SmsScanner: 구독 파싱 실패: ${entry.key}');
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
Log.d('SmsScanner: 반복 횟수 부족, 구독으로 간주하지 않음: ${entry.key}');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d('SmsScanner: 최종 구독 개수: ${subscriptions.length}');
|
Log.d('SmsScanner: 최종 구독 개수: ${subscriptions.length}');
|
||||||
@@ -347,33 +285,6 @@ class SmsScanner {
|
|||||||
// 대량 SMS 파싱: plugin 객체를 직렬화한 맵 리스트를 받아 구독 후보 맵 리스트로 변환
|
// 대량 SMS 파싱: plugin 객체를 직렬화한 맵 리스트를 받아 구독 후보 맵 리스트로 변환
|
||||||
List<Map<String, dynamic>> _parseRawSmsBatch(
|
List<Map<String, dynamic>> _parseRawSmsBatch(
|
||||||
List<Map<String, dynamic>> messages) {
|
List<Map<String, dynamic>> messages) {
|
||||||
// 키워드/정규식은 Isolate 내에서 재생성 (간단 복제)
|
|
||||||
const subscriptionKeywords = [
|
|
||||||
'구독',
|
|
||||||
'결제',
|
|
||||||
'정기결제',
|
|
||||||
'자동결제',
|
|
||||||
'월정액',
|
|
||||||
'subscription',
|
|
||||||
'payment',
|
|
||||||
'billing',
|
|
||||||
'charge',
|
|
||||||
'넷플릭스',
|
|
||||||
'Netflix',
|
|
||||||
'유튜브',
|
|
||||||
'YouTube',
|
|
||||||
'Spotify',
|
|
||||||
'멜론',
|
|
||||||
'웨이브',
|
|
||||||
'Disney+',
|
|
||||||
'디즈니플러스',
|
|
||||||
'Apple',
|
|
||||||
'Microsoft',
|
|
||||||
'GitHub',
|
|
||||||
'Adobe',
|
|
||||||
'Amazon'
|
|
||||||
];
|
|
||||||
|
|
||||||
final amountPatterns = <RegExp>[
|
final amountPatterns = <RegExp>[
|
||||||
RegExp(r'(\d{1,3}(?:,\d{3})*)(?:원|₩)'), // 원화
|
RegExp(r'(\d{1,3}(?:,\d{3})*)(?:원|₩)'), // 원화
|
||||||
RegExp(r'\$(\d+(?:\.\d{2})?)'), // 달러
|
RegExp(r'\$(\d+(?:\.\d{2})?)'), // 달러
|
||||||
@@ -389,26 +300,20 @@ List<Map<String, dynamic>> _parseRawSmsBatch(
|
|||||||
final dateMillis =
|
final dateMillis =
|
||||||
(m['dateMillis'] as int?) ?? DateTime.now().millisecondsSinceEpoch;
|
(m['dateMillis'] as int?) ?? DateTime.now().millisecondsSinceEpoch;
|
||||||
final date = DateTime.fromMillisecondsSinceEpoch(dateMillis);
|
final date = DateTime.fromMillisecondsSinceEpoch(dateMillis);
|
||||||
|
|
||||||
final lowerBody = body.toLowerCase();
|
|
||||||
final lowerSender = sender.toLowerCase();
|
|
||||||
final isSubscription = subscriptionKeywords.any((k) =>
|
|
||||||
lowerBody.contains(k.toLowerCase()) ||
|
|
||||||
lowerSender.contains(k.toLowerCase()));
|
|
||||||
|
|
||||||
if (!isSubscription) continue;
|
|
||||||
|
|
||||||
final serviceName = _isoExtractServiceName(body, sender);
|
final serviceName = _isoExtractServiceName(body, sender);
|
||||||
final amount = _isoExtractAmount(body, amountPatterns) ?? 0.0;
|
final amount = _isoExtractAmount(body, amountPatterns);
|
||||||
final billingCycle = _isoExtractBillingCycle(body);
|
final billingCycle = _isoExtractBillingCycle(body);
|
||||||
final nextBillingDate =
|
final nextBillingDate =
|
||||||
_isoCalculateNextBillingFromDate(date, billingCycle);
|
_isoCalculateNextBillingFromDate(date, billingCycle);
|
||||||
|
final normalizedBody = _isoNormalizeBody(body);
|
||||||
|
|
||||||
results.add({
|
results.add({
|
||||||
'serviceName': serviceName,
|
'serviceName': serviceName,
|
||||||
|
'address': sender,
|
||||||
'monthlyCost': amount,
|
'monthlyCost': amount,
|
||||||
'billingCycle': billingCycle,
|
'billingCycle': billingCycle,
|
||||||
'message': body,
|
'message': body,
|
||||||
|
'normalizedBody': normalizedBody,
|
||||||
'nextBillingDate': nextBillingDate.toIso8601String(),
|
'nextBillingDate': nextBillingDate.toIso8601String(),
|
||||||
'previousPaymentDate': date.toIso8601String(),
|
'previousPaymentDate': date.toIso8601String(),
|
||||||
});
|
});
|
||||||
@@ -473,6 +378,23 @@ String _isoExtractBillingCycle(String body) {
|
|||||||
return 'monthly';
|
return 'monthly';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _isoNormalizeBody(String body) {
|
||||||
|
final patterns = <RegExp>[
|
||||||
|
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 _isoCalculateNextBillingFromDate(
|
||||||
DateTime lastDate, String billingCycle) {
|
DateTime lastDate, String billingCycle) {
|
||||||
switch (billingCycle) {
|
switch (billingCycle) {
|
||||||
@@ -486,3 +408,256 @@ DateTime _isoCalculateNextBillingFromDate(
|
|||||||
return lastDate.add(const Duration(days: 30));
|
return lastDate.add(const Duration(days: 30));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map<String, List<Map<String, dynamic>>> _groupMessagesByIdentifier(
|
||||||
|
List<dynamic> smsList) {
|
||||||
|
final Map<String, List<Map<String, dynamic>>> groups = {};
|
||||||
|
|
||||||
|
for (final smsEntry in smsList) {
|
||||||
|
if (smsEntry is! Map) continue;
|
||||||
|
final sms = Map<String, dynamic>.from(smsEntry as Map<String, dynamic>);
|
||||||
|
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<String, dynamic> 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<Map<String, dynamic>> messages) {
|
||||||
|
if (messages.length < 2) return null;
|
||||||
|
|
||||||
|
final sorted = messages.map((sms) => Map<String, dynamic>.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 = <int>{};
|
||||||
|
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 baseIndex = matchedIndices
|
||||||
|
.reduce((value, element) => value < element ? value : element);
|
||||||
|
final baseMessage = Map<String, dynamic>.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<String, dynamic> recent, Map<String, dynamic> 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<String, dynamic> a, Map<String, dynamic> 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<String, dynamic> a, Map<String, dynamic> 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<String, dynamic> a, Map<String, dynamic> b) {
|
||||||
|
final normalizedA = _getNormalizedBody(a);
|
||||||
|
final normalizedB = _getNormalizedBody(b);
|
||||||
|
if (normalizedA.isEmpty || normalizedB.isEmpty) return false;
|
||||||
|
return normalizedA == normalizedB;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getNormalizedBody(Map<String, dynamic> 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<Map<String, dynamic>> 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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user