From a0b24f9a75a7c939508e044f23e80979cb76729c Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Wed, 14 Jan 2026 00:18:25 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20SubscriptionProvider=20=EA=B2=B0?= =?UTF-8?q?=EC=A0=9C=20=EA=B8=88=EC=95=A1=20=EA=B3=84=EC=82=B0=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/providers/subscription_provider.dart | 248 ++++++++++++++++------- 1 file changed, 172 insertions(+), 76 deletions(-) diff --git a/lib/providers/subscription_provider.dart b/lib/providers/subscription_provider.dart index c277409..1cc5008 100644 --- a/lib/providers/subscription_provider.dart +++ b/lib/providers/subscription_provider.dart @@ -10,6 +10,7 @@ import '../services/currency_util.dart'; import 'category_provider.dart'; import '../l10n/app_localizations.dart'; import '../navigator_key.dart'; +import '../utils/billing_cost_util.dart'; class SubscriptionProvider extends ChangeNotifier { late Box _subscriptionBox; @@ -24,18 +25,40 @@ class SubscriptionProvider extends ChangeNotifier { final rate = exchangeRateService.cachedUsdToKrwRate ?? ExchangeRateService.DEFAULT_USD_TO_KRW_RATE; + final now = DateTime.now(); + final currentYear = now.year; + final currentMonth = now.month; + final total = _subscriptions.fold( 0.0, (sum, subscription) { - final price = subscription.currentPrice; + // 이번 달에 결제가 발생하는지 확인 + final hasBilling = BillingCostUtil.hasBillingInMonth( + subscription.nextBillingDate, + subscription.billingCycle, + currentYear, + currentMonth, + ); + if (!hasBilling) { + debugPrint('[SubscriptionProvider] ${subscription.serviceName}: ' + '이번 달 결제 없음, 제외'); + return sum; + } + + // 실제 결제 금액으로 역변환 + final actualPrice = BillingCostUtil.convertFromMonthlyCost( + subscription.currentPrice, + subscription.billingCycle, + ); + if (subscription.currency == 'USD') { debugPrint('[SubscriptionProvider] ${subscription.serviceName}: ' - '\$$price × ₩$rate = ₩${price * rate}'); - return sum + (price * rate); + '\$$actualPrice × ₩$rate = ₩${actualPrice * rate}'); + return sum + (actualPrice * rate); } debugPrint( - '[SubscriptionProvider] ${subscription.serviceName}: ₩$price'); - return sum + price; + '[SubscriptionProvider] ${subscription.serviceName}: ₩$actualPrice'); + return sum + actualPrice; }, ); @@ -76,6 +99,9 @@ class SubscriptionProvider extends ChangeNotifier { // categoryId 마이그레이션 await _migrateCategoryIds(); + // billingCycle별 비용 마이그레이션 (연간/분기별 구독 월 비용 변환) + await _migrateBillingCosts(); + // 앱 시작 시 이벤트 상태 확인 await checkAndUpdateEventStatus(); @@ -274,7 +300,9 @@ class SubscriptionProvider extends ChangeNotifier { } } - /// 총 월간 지출을 계산합니다. (로케일별 기본 통화로 환산) + /// 이번 달 총 지출을 계산합니다. (로케일별 기본 통화로 환산) + /// - 이번 달에 결제가 발생하는 구독만 포함 + /// - 실제 결제 금액으로 계산 (연간이면 연간 금액) Future calculateTotalExpense({ String? locale, List? subset, @@ -282,26 +310,50 @@ class SubscriptionProvider extends ChangeNotifier { final targetSubscriptions = subset ?? _subscriptions; if (targetSubscriptions.isEmpty) return 0.0; + final now = DateTime.now(); + final currentYear = now.year; + final currentMonth = now.month; + // locale이 제공되지 않으면 현재 로케일 사용 final targetCurrency = locale != null ? CurrencyUtil.getDefaultCurrency(locale) : 'KRW'; // 기본값 debugPrint('[calculateTotalExpense] 계산 시작 - 타겟 통화: $targetCurrency, ' - '대상 구독: ${targetSubscriptions.length}개'); + '대상 구독: ${targetSubscriptions.length}개, 현재 월: $currentYear-$currentMonth'); double total = 0.0; for (final subscription in targetSubscriptions) { - final currentPrice = subscription.currentPrice; + // 이번 달에 결제가 발생하는지 확인 + final hasBilling = BillingCostUtil.hasBillingInMonth( + subscription.nextBillingDate, + subscription.billingCycle, + currentYear, + currentMonth, + ); + + if (!hasBilling) { + debugPrint('[calculateTotalExpense] ${subscription.serviceName}: ' + '이번 달 결제 없음 - 제외'); + continue; + } + + // 실제 결제 금액으로 변환 (월 비용 → 실제 결제 금액) + final actualPrice = BillingCostUtil.convertFromMonthlyCost( + subscription.currentPrice, + subscription.billingCycle, + ); + debugPrint('[calculateTotalExpense] ${subscription.serviceName}: ' - '$currentPrice ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})'); + '실제 결제 금액 $actualPrice ${subscription.currency} ' + '(월 비용: ${subscription.currentPrice}, 주기: ${subscription.billingCycle})'); final converted = await ExchangeRateService().convertBetweenCurrencies( - currentPrice, + actualPrice, subscription.currency, targetCurrency, ); - total += converted ?? currentPrice; + total += converted ?? actualPrice; } debugPrint('[calculateTotalExpense] 총 지출 계산 완료: $total ' @@ -310,6 +362,8 @@ class SubscriptionProvider extends ChangeNotifier { } /// 최근 6개월의 월별 지출 데이터를 반환합니다. (로케일별 기본 통화로 환산) + /// - 각 월에 결제가 발생하는 구독만 포함 + /// - 실제 결제 금액으로 계산 (연간이면 연간 금액) Future>> getMonthlyExpenseData({ String? locale, List? subset, @@ -336,60 +390,63 @@ class SubscriptionProvider extends ChangeNotifier { '[getMonthlyExpenseData] 현재 월(${month.year}-${month.month}) 계산 중...'); } - // 해당 월에 활성화된 구독 계산 + // 해당 월에 결제가 발생하는 구독만 계산 for (final subscription in targetSubscriptions) { + // 해당 월에 결제가 발생하는지 확인 + final hasBilling = BillingCostUtil.hasBillingInMonth( + subscription.nextBillingDate, + subscription.billingCycle, + month.year, + month.month, + ); + + if (!hasBilling) { + continue; // 해당 월에 결제가 없으면 제외 + } + + // 실제 결제 금액으로 변환 (월 비용 → 실제 결제 금액) + double actualCost; + + if (isCurrentMonth) { + // 현재 월: 이벤트 가격 반영 + actualCost = BillingCostUtil.convertFromMonthlyCost( + subscription.currentPrice, + subscription.billingCycle, + ); + } else { + // 과거 월: 이벤트 기간 확인 후 적용 + double monthlyCost; + if (subscription.isEventActive && + subscription.eventStartDate != null && + subscription.eventEndDate != null && + subscription.eventStartDate! + .isBefore(DateTime(month.year, month.month + 1, 1)) && + subscription.eventEndDate!.isAfter(month)) { + monthlyCost = subscription.eventPrice ?? subscription.monthlyCost; + } else { + monthlyCost = subscription.monthlyCost; + } + actualCost = BillingCostUtil.convertFromMonthlyCost( + monthlyCost, + subscription.billingCycle, + ); + } + if (isCurrentMonth) { - // 현재 월인 경우: 모든 활성 구독 포함 (calculateTotalExpense와 동일하게) - final cost = subscription.currentPrice; debugPrint( '[getMonthlyExpenseData] 현재 월 - ${subscription.serviceName}: ' - '$cost ${subscription.currency} (이벤트 적용: ${subscription.isCurrentlyInEvent})'); - - // 통화 변환 - final converted = - await ExchangeRateService().convertBetweenCurrencies( - cost, - subscription.currency, - targetCurrency, - ); - - monthTotal += converted ?? cost; - } else { - // 과거 월인 경우: 기존 로직 유지 - // 구독이 해당 월에 활성화되어 있었는지 확인 - final subscriptionStartDate = subscription.nextBillingDate.subtract( - Duration(days: _getBillingCycleDays(subscription.billingCycle)), - ); - - if (subscriptionStartDate - .isBefore(DateTime(month.year, month.month + 1, 1)) && - subscription.nextBillingDate.isAfter(month)) { - // 해당 월의 비용 계산 (이벤트 가격 고려) - double cost; - - if (subscription.isEventActive && - subscription.eventStartDate != null && - subscription.eventEndDate != null && - // 이벤트 기간과 해당 월이 겹치는지 확인 - subscription.eventStartDate! - .isBefore(DateTime(month.year, month.month + 1, 1)) && - subscription.eventEndDate!.isAfter(month)) { - cost = subscription.eventPrice ?? subscription.monthlyCost; - } else { - cost = subscription.monthlyCost; - } - - // 통화 변환 - final converted = - await ExchangeRateService().convertBetweenCurrencies( - cost, - subscription.currency, - targetCurrency, - ); - - monthTotal += converted ?? cost; - } + '실제 결제 금액 $actualCost ${subscription.currency}'); } + + // 통화 변환 + final converted = + await ExchangeRateService().convertBetweenCurrencies( + actualCost, + subscription.currency, + targetCurrency, + ); + + monthTotal += converted ?? actualCost; } if (isCurrentMonth) { @@ -413,22 +470,6 @@ class SubscriptionProvider extends ChangeNotifier { return totalEventSavings; } - /// 결제 주기를 일 단위로 변환합니다. - int _getBillingCycleDays(String billingCycle) { - switch (billingCycle) { - case 'monthly': - return 30; - case 'yearly': - return 365; - case 'weekly': - return 7; - case 'quarterly': - return 90; - default: - return 30; - } - } - /// 월 라벨을 생성합니다. String _getMonthLabel(DateTime month, String locale) { if (locale == 'ko') { @@ -557,4 +598,59 @@ class SubscriptionProvider extends ChangeNotifier { debugPrint('❎ 모든 구독이 이미 categoryId를 가지고 있습니다'); } } + + /// billingCycle별 비용 마이그레이션 + /// 기존 연간/분기별 구독의 monthlyCost를 월 환산 비용으로 변환 + Future _migrateBillingCosts() async { + debugPrint('💰 BillingCost 마이그레이션 시작...'); + + int migratedCount = 0; + + for (var subscription in _subscriptions) { + final cycle = subscription.billingCycle.toLowerCase(); + + // 월간 구독이 아닌 경우에만 변환 필요 + if (cycle != 'monthly' && cycle != '월간' && cycle != '매월') { + // 현재 monthlyCost가 실제 월 비용인지 확인 + // 연간 구독인데 monthlyCost가 12배 이상 크면 변환 안됨 상태로 판단 + final multiplier = BillingCostUtil.getBillingCycleMultiplier(cycle); + + // 변환이 필요한 경우: monthlyCost가 비정상적으로 큰 경우 + // (예: 연간 129,000원이 monthlyCost에 그대로 저장된 경우) + if (multiplier > 1.5) { + // 원래 monthlyCost를 백업 + final originalCost = subscription.monthlyCost; + + // 월 비용으로 변환 + final convertedCost = BillingCostUtil.convertToMonthlyCost( + originalCost, + cycle, + ); + + // 이벤트 가격도 있다면 변환 + if (subscription.eventPrice != null) { + final convertedEventPrice = BillingCostUtil.convertToMonthlyCost( + subscription.eventPrice!, + cycle, + ); + subscription.eventPrice = convertedEventPrice; + } + + subscription.monthlyCost = convertedCost; + await subscription.save(); + migratedCount++; + + debugPrint('✅ ${subscription.serviceName} ($cycle): ' + '₩${originalCost.toInt()} → ₩${convertedCost.toInt()}/월'); + } + } + } + + if (migratedCount > 0) { + debugPrint('💰 총 $migratedCount개의 구독 비용 변환 완료'); + await refreshSubscriptions(); + } else { + debugPrint('💰 모든 구독이 이미 월 비용으로 저장되어 있습니다'); + } + } }