From da530a99b7e43622ca86f7dc315deb9a6b010d8b Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Wed, 14 Jan 2026 00:18:12 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B2=B0=EC=A0=9C=20=EA=B8=88=EC=95=A1?= =?UTF-8?q?=20=EA=B3=84=EC=82=B0=20=EC=9C=A0=ED=8B=B8=EB=A6=AC=ED=8B=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/services/currency_util.dart | 62 +++++++- lib/utils/billing_cost_util.dart | 248 +++++++++++++++++++++++++++++++ 2 files changed, 303 insertions(+), 7 deletions(-) create mode 100644 lib/utils/billing_cost_util.dart diff --git a/lib/services/currency_util.dart b/lib/services/currency_util.dart index 5d08686..3d02378 100644 --- a/lib/services/currency_util.dart +++ b/lib/services/currency_util.dart @@ -1,5 +1,6 @@ import 'package:intl/intl.dart'; import '../models/subscription_model.dart'; +import '../utils/billing_cost_util.dart'; import 'exchange_rate_service.dart'; import 'cache_manager.dart'; @@ -129,7 +130,8 @@ class CurrencyUtil { return result; } - /// 구독 목록의 총 월 비용을 계산 (언어별 기본 통화로) + /// 구독 목록의 이번 달 총 비용을 계산 (언어별 기본 통화로) + /// 이번 달에 결제가 발생하는 구독만 포함하며, 실제 결제 금액을 사용 static Future calculateTotalMonthlyExpenseInDefaultCurrency( List subscriptions, String locale, @@ -137,16 +139,33 @@ class CurrencyUtil { final defaultCurrency = getDefaultCurrency(locale); double total = 0.0; + final now = DateTime.now(); + final currentYear = now.year; + final currentMonth = now.month; + for (var subscription in subscriptions) { - final price = subscription.currentPrice; + // 이번 달에 결제가 발생하는지 확인 + final hasBilling = BillingCostUtil.hasBillingInMonth( + subscription.nextBillingDate, + subscription.billingCycle, + currentYear, + currentMonth, + ); + if (!hasBilling) continue; + + // 실제 결제 금액으로 역변환 + final actualPrice = BillingCostUtil.convertFromMonthlyCost( + subscription.currentPrice, + subscription.billingCycle, + ); final converted = await _exchangeRateService.convertBetweenCurrencies( - price, + actualPrice, subscription.currency, defaultCurrency, ); - total += converted ?? price; + total += converted ?? actualPrice; } return total; @@ -158,17 +177,46 @@ class CurrencyUtil { return calculateTotalMonthlyExpenseInDefaultCurrency(subscriptions, 'ko'); } + /// 구독 목록의 예상 연간 총 비용을 계산 (언어별 기본 통화로) + /// 모든 구독의 연간 비용을 합산 (월 환산 비용 × 12) + static Future calculateTotalAnnualExpenseInDefaultCurrency( + List subscriptions, + String locale, + ) async { + final defaultCurrency = getDefaultCurrency(locale); + double total = 0.0; + + for (var subscription in subscriptions) { + // 월 환산 비용 × 12 = 연간 비용 + final annualPrice = subscription.currentPrice * 12; + + final converted = await _exchangeRateService.convertBetweenCurrencies( + annualPrice, + subscription.currency, + defaultCurrency, + ); + + total += converted ?? annualPrice; + } + + return total; + } + /// 구독의 월 비용을 표시 형식에 맞게 변환 (언어별 통화) static Future formatSubscriptionAmountWithLocale( SubscriptionModel subscription, String locale) async { - final price = subscription.currentPrice; - // 구독 단위 캐시 키 (통화/가격/locale + id) + // 월 환산 금액을 실제 결제 금액으로 역변환 + final price = BillingCostUtil.convertFromMonthlyCost( + subscription.currentPrice, + subscription.billingCycle, + ); + // 구독 단위 캐시 키 (통화/가격/locale + id + billingCycle) final decimals = (subscription.currency == 'KRW' || subscription.currency == 'JPY') ? 0 : 2; final key = - 'subfmt:$locale:${subscription.currency}:${price.toStringAsFixed(decimals)}:${subscription.id}'; + 'subfmt:$locale:${subscription.currency}:${price.toStringAsFixed(decimals)}:${subscription.id}:${subscription.billingCycle}'; final cached = _fmtCache.get(key); if (cached != null) return cached; diff --git a/lib/utils/billing_cost_util.dart b/lib/utils/billing_cost_util.dart new file mode 100644 index 0000000..6ef26a5 --- /dev/null +++ b/lib/utils/billing_cost_util.dart @@ -0,0 +1,248 @@ +/// 결제 주기에 따른 비용 변환 유틸리티 +class BillingCostUtil { + /// 결제 주기별 비용을 월 비용으로 변환 + /// + /// [amount]: 입력된 비용 + /// [billingCycle]: 결제 주기 ('monthly', 'yearly', 'quarterly', 'half-yearly' 등) + /// + /// Returns: 월 환산 비용 + static double convertToMonthlyCost(double amount, String billingCycle) { + final normalizedCycle = _normalizeBillingCycle(billingCycle); + + switch (normalizedCycle) { + case 'monthly': + return amount; + case 'yearly': + return amount / 12; + case 'quarterly': + return amount / 3; + case 'half-yearly': + return amount / 6; + case 'weekly': + return amount * 4.33; // 평균 주당 4.33주 + default: + return amount; // 알 수 없는 주기는 그대로 반환 + } + } + + /// 월 비용을 결제 주기별 비용으로 역변환 + /// + /// [monthlyCost]: 월 비용 + /// [billingCycle]: 결제 주기 + /// + /// Returns: 해당 주기의 실제 결제 금액 + static double convertFromMonthlyCost(double monthlyCost, String billingCycle) { + final normalizedCycle = _normalizeBillingCycle(billingCycle); + + switch (normalizedCycle) { + case 'monthly': + return monthlyCost; + case 'yearly': + return monthlyCost * 12; + case 'quarterly': + return monthlyCost * 3; + case 'half-yearly': + return monthlyCost * 6; + case 'weekly': + return monthlyCost / 4.33; + default: + return monthlyCost; + } + } + + /// 결제 주기를 정규화된 영어 키값으로 변환 + static String _normalizeBillingCycle(String cycle) { + switch (cycle.toLowerCase()) { + case 'monthly': + case '월간': + case '매월': + case '月間': + case '月付': + case '每月': + case '毎月': + return 'monthly'; + + case 'yearly': + case 'annual': + case 'annually': + case '연간': + case '매년': + case '年間': + case '年付': + case '每年': + return 'yearly'; + + case 'quarterly': + case 'quarter': + case '분기별': + case '분기': + case '季付': + case '季度付': + case '四半期': + case '每季度': + return 'quarterly'; + + case 'half-yearly': + case 'half yearly': + case 'semiannual': + case 'semi-annual': + case '반기별': + case '半年付': + case '半年払い': + case '半年ごと': + case '每半年': + return 'half-yearly'; + + case 'weekly': + case '주간': + case '週間': + case '周付': + case '每周': + return 'weekly'; + + default: + return 'monthly'; + } + } + + /// 결제 주기의 배수 반환 (월 기준) + /// + /// 예: yearly = 12, quarterly = 3 + static double getBillingCycleMultiplier(String billingCycle) { + final normalizedCycle = _normalizeBillingCycle(billingCycle); + + switch (normalizedCycle) { + case 'monthly': + return 1.0; + case 'yearly': + return 12.0; + case 'quarterly': + return 3.0; + case 'half-yearly': + return 6.0; + case 'weekly': + return 1 / 4.33; + default: + return 1.0; + } + } + + /// 다음 결제일에서 이전 결제일 계산 + /// + /// [nextBillingDate]: 다음 결제 예정일 + /// [billingCycle]: 결제 주기 + /// + /// Returns: 이전 결제일 (마지막으로 결제가 발생한 날짜) + static DateTime getLastBillingDate( + DateTime nextBillingDate, String billingCycle) { + final normalizedCycle = _normalizeBillingCycle(billingCycle); + + switch (normalizedCycle) { + case 'yearly': + return DateTime( + nextBillingDate.year - 1, + nextBillingDate.month, + nextBillingDate.day, + ); + case 'half-yearly': + return DateTime( + nextBillingDate.month <= 6 + ? nextBillingDate.year - 1 + : nextBillingDate.year, + nextBillingDate.month <= 6 + ? nextBillingDate.month + 6 + : nextBillingDate.month - 6, + nextBillingDate.day, + ); + case 'quarterly': + return DateTime( + nextBillingDate.month <= 3 + ? nextBillingDate.year - 1 + : nextBillingDate.year, + nextBillingDate.month <= 3 + ? nextBillingDate.month + 9 + : nextBillingDate.month - 3, + nextBillingDate.day, + ); + case 'monthly': + return DateTime( + nextBillingDate.month == 1 + ? nextBillingDate.year - 1 + : nextBillingDate.year, + nextBillingDate.month == 1 ? 12 : nextBillingDate.month - 1, + nextBillingDate.day, + ); + case 'weekly': + return nextBillingDate.subtract(const Duration(days: 7)); + default: + return DateTime( + nextBillingDate.month == 1 + ? nextBillingDate.year - 1 + : nextBillingDate.year, + nextBillingDate.month == 1 ? 12 : nextBillingDate.month - 1, + nextBillingDate.day, + ); + } + } + + /// 특정 월에 결제가 발생하는지 확인 + /// + /// [nextBillingDate]: 다음 결제 예정일 + /// [billingCycle]: 결제 주기 + /// [targetYear]: 확인할 연도 + /// [targetMonth]: 확인할 월 (1-12) + /// + /// Returns: 해당 월에 결제가 발생하면 true + static bool hasBillingInMonth( + DateTime nextBillingDate, + String billingCycle, + int targetYear, + int targetMonth, + ) { + final normalizedCycle = _normalizeBillingCycle(billingCycle); + + // 주간 결제는 매주 발생하므로 항상 true + if (normalizedCycle == 'weekly') { + return true; + } + + // 월간 결제는 매월 발생하므로 항상 true + if (normalizedCycle == 'monthly') { + return true; + } + + // 결제 주기에 따른 개월 수 + final cycleMonths = _getCycleMonths(normalizedCycle); + + // 결제 발생 월 계산 (nextBillingDate 기준으로 역산) + final billingMonth = nextBillingDate.month; + + // 대상 월이 결제 발생 월과 일치하는지 확인 + // 예: 연간 결제(1월), targetMonth = 1 → true + // 예: 연간 결제(1월), targetMonth = 2 → false + for (int i = 0; i < 12; i += cycleMonths) { + final checkMonth = ((billingMonth - 1 + i) % 12) + 1; + if (checkMonth == targetMonth) { + return true; + } + } + + return false; + } + + /// 결제 주기별 개월 수 반환 + static int _getCycleMonths(String normalizedCycle) { + switch (normalizedCycle) { + case 'yearly': + return 12; + case 'half-yearly': + return 6; + case 'quarterly': + return 3; + case 'monthly': + return 1; + default: + return 1; + } + } +}