Compare commits
3 Commits
codex/fix-
...
cba7d082bd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cba7d082bd | ||
|
|
8cec03f181 | ||
|
|
7ace3afaf3 |
106
doc/payment_card_plan.md
Normal file
106
doc/payment_card_plan.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# 결제수단 구분 확장 계획
|
||||
|
||||
## 배경
|
||||
- 현재 홈 화면은 카테고리별 구독 목록만 제공하며, 결제 카드 기준으로 필터링하거나 시각적으로 구분할 수 없음.
|
||||
- 사용자 요청: 카드 회사명과 마지막 4자리로 구독을 분류해 데이터/UX 양쪽 모두에서 카드별 인사이트를 제공.
|
||||
|
||||
## 목표
|
||||
- 구독 데이터를 카드 단위로 매핑할 수 있는 스키마 확장.
|
||||
- 카드 정보를 한 번만 등록하도록 관리 화면을 제공해 재사용성 확보.
|
||||
- 홈 화면에서 카테고리/카드 뷰를 토글하며, 헤더·리스트·분석 카드가 카드 정보를 시각적으로 노출.
|
||||
- 모든 변경은 기존 카테고리 UX를 유지하면서 점진적 도입이 가능해야 함.
|
||||
|
||||
## 데이터 모델 및 저장소
|
||||
- `SubscriptionModel`에 `paymentCardId`(필요 시 `displayName`/닉네임) 필드 추가, Hive 어댑터 재생성. 기존 데이터는 `null` 기본값으로 역호환.
|
||||
- 새 `PaymentCardModel` 작성: `id`, `issuerName`(회사명 자유 입력), `last4`, `colorHex`, `iconName` 등을 저장. Hive typeId는 미사용 값을 배정.
|
||||
- `PaymentCardProvider`에서 Hive box(`payment_cards`)를 관리하고 CRUD, 정렬, 기본값 선택 기능 제공.
|
||||
- `main.dart` 초기화 시 카드 어댑터 등록 → Provider 주입.
|
||||
- 구독 저장 로직(`SubscriptionProvider.add/update`)과 SMS/수동 추가 컨트롤러에서 `paymentCardId`를 인자로 전달.
|
||||
|
||||
## 카드 정보 입력 UX
|
||||
- 전용 관리 화면: 설정 > “결제수단 관리” 또는 독립 `PaymentCardManagementScreen`.
|
||||
- 필수 입력: 회사명(자유 텍스트), 마지막 4자리(숫자 4자리), 선택형 색상/아이콘.
|
||||
- 리스트 정렬, 편집, 삭제, 기본 카드 지정, 구독 수 연동 배지 표시.
|
||||
- 컨텍스트 내 빠른 등록: 구독 추가/수정 폼, SMS 스캔 리뷰 화면 등에서 “+ 새 카드” 버튼을 눌렀을 때 시트/모달로 간단 등록 가능.
|
||||
- 구독 추가/수정 폼에 `PaymentCardSelector`를 추가:
|
||||
- 드롭다운/검색형 목록에 등록된 카드를 노출하고, 최근 사용 카드가 상단에 정렬되도록 UX 최적화.
|
||||
- 카드 ID가 비어 있으면 “미지정” 상태로 저장해 기존 UX 유지.
|
||||
- UX 권장안: **설정 화면**에서 카드 풀을 미리 관리하되, **컨텍스트 모달**로도 등록할 수 있게 하여 흐름을 끊지 않음. 단순한 “옵션” 스위치에 카드 정보를 묻는 것보다 입력 목적이 명확하고 재사용성이 높음.
|
||||
|
||||
## 홈 화면 및 리스트 UI
|
||||
- `HomeContent`를 상태형으로 전환하고 `enum SubscriptionGrouping { category, paymentCard }`를 유지. 선택 상태는 `SharedPreferences` 등으로 로컬 저장.
|
||||
- “내 구독” 헤더 오른쪽에 SegmentedButton/ChoiceChip으로 카테고리↔카드 토글을 제공.
|
||||
- `SubscriptionListWidget`을 범용 그룹 리스트로 확장:
|
||||
- 그룹 메타데이터(타이틀, 통화 합계, 색상, 서브텍스트)를 받아 헤더 구성.
|
||||
- 카드 모드에서는 회사명 + `****1234`, 카드 색상 배지, 카드별 통화 합계를 노출.
|
||||
- 개별 구독 카드(`SubscriptionCard`) 상단에 결제수단 Chip을 추가해 어떤 카드에 속했는지 즉시 파악 가능.
|
||||
|
||||
## 구독 상세 화면 반영
|
||||
- `DetailScreen` 상단 요약 카드에 결제수단 Chip/배지와 카드 색상을 노출.
|
||||
- “결제 정보” 섹션에 “결제수단” 행을 추가해 회사명 + `****1234`, 카드별 메모 등을 보여줌.
|
||||
- 상세 화면의 편집 아이콘 → 편집 시트로 진입 시 현재 `paymentCardId`를 기본 선택하여 사용자가 쉽게 변경할 수 있게 함.
|
||||
- 카드 Chip을 탭하면 카드 관리 화면으로 이동하거나 빠른 편집 시트를 띄워 카드 명칭/색상 수정이 가능하도록 연동.
|
||||
|
||||
## SMS 스캔 흐름 적용
|
||||
- `SmsScanController`가 생성한 임시 구독 모델에도 `paymentCardId` 필드를 포함.
|
||||
- 스캔 결과 리뷰 리스트에서 각 구독 옆에 카드 선택 드롭다운을 노출:
|
||||
- 기본값은 (1) 동일 발급사를 과거에 사용한 기록이 있으면 해당 카드, (2) 지정된 기본 카드, (3) “미지정” 순으로 결정.
|
||||
- 다중 선택을 빠르게 하기 위해 스와이프/컨텍스트 메뉴 대신 인라인 세그먼트나 바텀 시트를 사용.
|
||||
- “모두 저장” 시 선택된 카드 ID를 `SubscriptionProvider.addSubscription` 호출에 전달.
|
||||
- SMS 패턴으로 카드사를 추정할 수 있는 경우(문구에 “KB국민카드 ****1234” 등)라면 자동으로 새 카드 템플릿을 제안하고, 사용자 확인 후 생성하도록 선택지를 제공.
|
||||
|
||||
## 화면/플로우별 변경 영향 (릴리스 전 점검)
|
||||
### 홈/목록/위젯
|
||||
- `HomeContent`, `SubscriptionListWidget`, `CategoryHeaderWidget`, `SubscriptionCard`, `NativeAdWidget` 인접 간격 등 모든 위젯이 새로운 그룹 메타데이터를 받아도 레이아웃이 깨지지 않는지 확인.
|
||||
- 카드 모드에서 스켈레톤/EmptyState/애니메이션이 그대로 작동하는지, 그리고 `RefreshIndicator`·무한 스크롤이 정상인지 검증.
|
||||
- 다국어(`en/ko/ja/zh`)에서 카드명/`****1234` 조합이 줄바꿈되지 않도록 최소/최대 길이 처리.
|
||||
|
||||
### 구독 추가/편집/상세
|
||||
- `AddSubscriptionController`, `DetailScreenController`의 상태/검증 로직에 `paymentCardId`가 포함되었는지 확인.
|
||||
- 저장/취소/변경 이벤트에서 카드 ID가 누락될 경우 기본값 처리.
|
||||
- 이벤트/할인 섹션, URL 섹션 등 기존 위젯과 상호작용 시 포커스 이동·폼 검증이 동일하게 작동하는지 QA.
|
||||
- 상세 화면 헤더/폼/아코디언 등 모든 서브 위젯(`detail_*`)이 카드 배지를 수용하도록 패딩 보정.
|
||||
|
||||
### SMS 스캔 및 자동 감지
|
||||
- `SmsScanController`, `SmsScanner`, `SubscriptionConverter` 등 데이터 파이프라인에 카드 메타 추가.
|
||||
- 스캔 결과 UI(선택 리스트, 확정 다이얼로그, Snackbar)에서 카드가 선택되지 않았을 때 경고/기본값 표시를 명확히 함.
|
||||
- 자동 감지 카드 생성 로직은 사용자 최종 확인 후만 저장되도록 하고, 잘못된 카드 추론 시 수정 경로를 안내.
|
||||
|
||||
### 분석/대시보드
|
||||
- `AnalysisScreen`, `SubscriptionPieChartCard`, `TotalExpenseSummaryCard`, `MonthlyExpenseChartCard`, `EventAnalysisCard`가 카드 모드 전환에 따른 필터/데이터세트 변경을 감지하는지 확인.
|
||||
- 향후 카드별 하이라이트를 추가할 경우를 대비해 `SubscriptionGroupingHelper` 출력 구조가 확장 가능한지 검토.
|
||||
|
||||
### 설정/관리/내비게이션
|
||||
- `SettingsScreen` 내 새 “결제수단 관리” 항목 및 `PaymentCardManagementScreen`이 탐색 스택/앱 잠금 흐름에 맞게 라우팅되는지 확인.
|
||||
- `NavigationProvider` 및 `FloatingNavigationBar` 상태와 충돌하지 않는지 QA.
|
||||
|
||||
### 데이터/싱크/백업
|
||||
- Hive 박스 버전이 증가한 뒤에도 기존 사용자 데이터(베타/QA) 로딩에 문제가 없는지 실제 마이그레이션 테스트.
|
||||
- `SubscriptionProvider.refreshSubscriptions`, `notificationProvider`, `ExchangeRateService` 등 구독 컬렉션을 사용하는 모든 클래스에서 `paymentCardId`를 읽고 무시해도 예외가 발생하지 않는지 확인.
|
||||
- 테스트 데이터(`lib/temp/test_sms_data.dart`, demo seed)에도 카드 필드가 포함되었는지 점검.
|
||||
|
||||
### 로컬라이제이션/접근성
|
||||
- `AppLocalizations`, `intl` 메시지에 결제수단 관련 텍스트(“결제수단”, “카드 관리”, 오류 메시지 등)를 추가하고 4개 언어 번역을 준비.
|
||||
- 스크린리더(VoiceOver/TalkBack)에서 카드 정보가 올바른 순서로 읽히는지, Chip 탭 시 라벨이 명확한지 확인.
|
||||
- 컬러 배지 대비가 Material 3 접근성 가이드라인(대비 3:1 이상)을 만족하도록 색상 선택 UI/프리셋을 검토.
|
||||
|
||||
### QA 체크리스트
|
||||
1. 새 카드 생성 → 구독 추가/편집/상세/SMS 스캔 → 삭제까지 전 과정에서 데이터 일관성 확인.
|
||||
2. 카드 토글이 유지되는지(앱 재시작 포함) 확인.
|
||||
3. `scripts/check.sh` + `flutter pub run build_runner build --delete-conflicting-outputs` 실행 후 경고 없는지 확인.
|
||||
4. 다국어·다크모드·태블릿 해상도에서 UI 붕괴 여부 점검.
|
||||
5. 알림/위젯/백그라운드 서비스(예: 결제 알림)에서 카드 필드 추가로 인한 크래시가 없는지 Crashlytics/디버그 로그 확인.
|
||||
|
||||
## 분석 및 향후 확장
|
||||
- 공통 `SubscriptionGroupingHelper`를 만들어 카드/카테고리 그룹 데이터를 모두 생성하게 설계하면 `MonthlyExpenseChartCard`, 파이 차트, 이벤트 카드 등도 카드 필터를 쉽게 지원.
|
||||
- 초기에는 홈 리스트에만 카드 모드를 적용하고, 이후 분석 탭에 “카드별 지출” 섹션을 추가해 순차 배포.
|
||||
|
||||
## 검증/운영
|
||||
- 모든 변경 후 `scripts/check.sh`로 포맷(`dart format`), 정적 분석(`flutter analyze`), 테스트(`flutter test`)를 실행.
|
||||
- Hive 스키마가 증가하므로 `flutter pub run build_runner build --delete-conflicting-outputs`를 통해 어댑터 재생성.
|
||||
- UI 변경 시 기본/카드 모드 스크린샷을 확보해 QA 공유.
|
||||
|
||||
## 리스크 및 완화
|
||||
- **Hive 마이그레이션**: 새 필드는 optional로 두고 기본값을 유지해 앱 크래시를 방지. 배포 전 베타 빌드로 데이터 검증.
|
||||
- **사용자 혼란**: 토글 기본값을 기존 “카테고리”로 유지하고, 첫 진입 시 간단한 스낵바/tooltip으로 카드 뷰를 안내.
|
||||
- **데이터 입력 번거로움**: 관리 화면에서 최소 필드만 요구하고, 구독 폼에서 바로 생성할 수 있게 동선 축소.
|
||||
@@ -1,3 +1,5 @@
|
||||
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';
|
||||
@@ -38,81 +40,20 @@ class SmsScanner {
|
||||
|
||||
// SMS 데이터를 분석하여 반복 결제되는 구독 식별
|
||||
final List<SubscriptionModel> subscriptions = [];
|
||||
final Map<String, List<Map<String, dynamic>>> serviceGroups = {};
|
||||
final serviceGroups = _groupMessagesByIdentifier(smsList);
|
||||
|
||||
// 서비스명별로 SMS 메시지 그룹화
|
||||
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}');
|
||||
|
||||
Log.d('SmsScanner: 그룹화된 서비스 수: ${serviceGroups.length}');
|
||||
|
||||
// 그룹화된 데이터로 구독 분석
|
||||
for (final entry in serviceGroups.entries) {
|
||||
Log.d('SmsScanner: 서비스 "${entry.key}" - 메시지 개수: ${entry.value.length}');
|
||||
|
||||
// 2회 이상 반복된 서비스만 구독으로 간주
|
||||
if (entry.value.length >= 2) {
|
||||
// 결제일 패턴 유추를 위해 최근 2개의 결제일을 사용
|
||||
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;
|
||||
}
|
||||
}
|
||||
Log.d('SmsScanner: 그룹 "${entry.key}" - 메시지 ${entry.value.length}건');
|
||||
final repeatResult = _detectRepeatingSubscriptions(entry.value);
|
||||
if (repeatResult == null) {
|
||||
Log.d('SmsScanner: 반복 조건 불충족 - ${entry.key}');
|
||||
continue;
|
||||
}
|
||||
|
||||
// 다음 결제일 계산: 기준 일자를 바탕으로 다음 달 또는 이번 달로 설정 후 영업일 보정
|
||||
final DateTime 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 = 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);
|
||||
final subscription =
|
||||
_parseSms(repeatResult.baseMessage, repeatResult.repeatCount);
|
||||
if (subscription != null) {
|
||||
Log.i(
|
||||
'SmsScanner: 구독 추가: ${subscription.serviceName}, 반복 횟수: ${subscription.repeatCount}');
|
||||
@@ -120,9 +61,6 @@ class SmsScanner {
|
||||
} else {
|
||||
Log.w('SmsScanner: 구독 파싱 실패: ${entry.key}');
|
||||
}
|
||||
} else {
|
||||
Log.d('SmsScanner: 반복 횟수 부족, 구독으로 간주하지 않음: ${entry.key}');
|
||||
}
|
||||
}
|
||||
|
||||
Log.d('SmsScanner: 최종 구독 개수: ${subscriptions.length}');
|
||||
@@ -347,33 +285,6 @@ class SmsScanner {
|
||||
// 대량 SMS 파싱: plugin 객체를 직렬화한 맵 리스트를 받아 구독 후보 맵 리스트로 변환
|
||||
List<Map<String, dynamic>> _parseRawSmsBatch(
|
||||
List<Map<String, dynamic>> messages) {
|
||||
// 키워드/정규식은 Isolate 내에서 재생성 (간단 복제)
|
||||
const subscriptionKeywords = [
|
||||
'구독',
|
||||
'결제',
|
||||
'정기결제',
|
||||
'자동결제',
|
||||
'월정액',
|
||||
'subscription',
|
||||
'payment',
|
||||
'billing',
|
||||
'charge',
|
||||
'넷플릭스',
|
||||
'Netflix',
|
||||
'유튜브',
|
||||
'YouTube',
|
||||
'Spotify',
|
||||
'멜론',
|
||||
'웨이브',
|
||||
'Disney+',
|
||||
'디즈니플러스',
|
||||
'Apple',
|
||||
'Microsoft',
|
||||
'GitHub',
|
||||
'Adobe',
|
||||
'Amazon'
|
||||
];
|
||||
|
||||
final amountPatterns = <RegExp>[
|
||||
RegExp(r'(\d{1,3}(?:,\d{3})*)(?:원|₩)'), // 원화
|
||||
RegExp(r'\$(\d+(?:\.\d{2})?)'), // 달러
|
||||
@@ -389,26 +300,20 @@ List<Map<String, dynamic>> _parseRawSmsBatch(
|
||||
final dateMillis =
|
||||
(m['dateMillis'] as int?) ?? DateTime.now().millisecondsSinceEpoch;
|
||||
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 amount = _isoExtractAmount(body, amountPatterns) ?? 0.0;
|
||||
final amount = _isoExtractAmount(body, amountPatterns);
|
||||
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(),
|
||||
});
|
||||
@@ -473,6 +378,23 @@ String _isoExtractBillingCycle(String body) {
|
||||
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 lastDate, String billingCycle) {
|
||||
switch (billingCycle) {
|
||||
@@ -486,3 +408,256 @@ DateTime _isoCalculateNextBillingFromDate(
|
||||
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