LCOV - code coverage report
Current view: top level - lib/services - subscription_service.dart Coverage Total Hit
Test: lcov.info Lines: 74.3 % 214 159
Test Date: 2025-08-12 03:21:51 Functions: - 0 0

            Line data    Source code
       1              : import 'dart:convert';
       2              : import 'package:flutter/material.dart';
       3              : import 'package:http/http.dart' as http;
       4              : 
       5              : import '../config/api_config.dart';
       6              : import '../models/subscription_model.dart';
       7              : import 'in_app_purchase_service.dart';
       8              : 
       9              : /// 구독 관련 서비스
      10              : class SubscriptionService extends ChangeNotifier {
      11              :   final String _baseUrl = ApiConfig.baseUrl;
      12              :   final String _token;
      13              :   final String _clubId;
      14              :   final String _userId;
      15              :   
      16              :   Subscription? _currentSubscription;
      17              :   List<SubscriptionPlan> _availablePlans = [];
      18              :   bool _isLoading = false;
      19              :   String? _error;
      20              :   
      21              :   // 인앱 결제 서비스
      22              :   final InAppPurchaseService _purchaseService;
      23              :   final http.Client _httpClient;
      24              : 
      25              :   // 기본 생성자
      26            0 :   SubscriptionService(this._token, [this._clubId = '', this._userId = '']) : 
      27            0 :     _purchaseService = InAppPurchaseService(),
      28            0 :     _httpClient = http.Client() {
      29            0 :     _loadAvailablePlans();
      30            0 :     _initializeInAppPurchase();
      31              :   }
      32              :   
      33              :   // 테스트용 생성자
      34            2 :   factory SubscriptionService.forTest({
      35              :     required http.Client client,
      36              :     required InAppPurchaseService purchaseService,
      37              :     required String userId,
      38              :     required String token,
      39              :     required String clubId,
      40              :   }) {
      41            2 :     return SubscriptionService._internal(
      42              :       token,
      43              :       clubId,
      44              :       userId,
      45              :       client,
      46              :       purchaseService,
      47              :     );
      48              :   }
      49              :   
      50              :   // 내부 생성자
      51            2 :   SubscriptionService._internal(
      52              :     this._token,
      53              :     this._clubId,
      54              :     this._userId,
      55              :     this._httpClient,
      56              :     this._purchaseService,
      57              :   ) {
      58            2 :     _loadAvailablePlans();
      59            2 :     _initializeInAppPurchase();
      60              :   }
      61              : 
      62              :   // 게터
      63            4 :   Subscription? get currentSubscription => _currentSubscription;
      64            4 :   List<SubscriptionPlan> get availablePlans => _availablePlans;
      65            8 :   bool get isLoading => _isLoading || _purchaseService.isLoading;
      66            8 :   String? get error => _error ?? _purchaseService.error;
      67            2 :   bool get hasSubscription => _currentSubscription != null;
      68            2 :   bool get isActive => _currentSubscription?.isActive ?? false;
      69              :   
      70              :   // 인앱 결제 관련 게터
      71            2 :   InAppPurchaseService get purchaseService => _purchaseService;
      72            0 :   List<dynamic> get products => _purchaseService.products;
      73              : 
      74              :   /// 현재 구독 정보 로드
      75            2 :   Future<void> loadCurrentSubscription() async {
      76            2 :     _isLoading = true;
      77            2 :     _error = null;
      78            2 :     notifyListeners();
      79              : 
      80              :     try {
      81            4 :       final response = await _httpClient.post(
      82            6 :         Uri.parse('$_baseUrl${ApiConfig.currentSubscription}'),
      83            2 :         headers: {
      84              :           'Content-Type': 'application/json',
      85            4 :           'Authorization': 'Bearer $_token',
      86              :         },
      87            4 :         body: jsonEncode({
      88            2 :           'clubId': _clubId,
      89              :         }),
      90              :       );
      91              : 
      92            4 :       if (response.statusCode == 200) {
      93            4 :         final data = jsonDecode(response.body);
      94            4 :         _currentSubscription = Subscription.fromJson(data);
      95            0 :       } else if (response.statusCode == 404) {
      96              :         // 구독 정보가 없는 경우 (Free 플랜으로 간주)
      97            0 :         _currentSubscription = Subscription(
      98              :           id: 'free',
      99            0 :           userId: _userId,
     100              :           planType: SubscriptionPlanType.free,
     101              :           status: SubscriptionStatus.active,
     102            0 :           startDate: DateTime.now(),
     103            0 :           endDate: DateTime.now().add(const Duration(days: 36500)), // 100년
     104              :           price: 0,
     105              :           currency: 'KRW',
     106              :           autoRenew: false,
     107            0 :           createdAt: DateTime.now(),
     108            0 :           updatedAt: DateTime.now(),
     109              :         );
     110              :       } else {
     111            0 :         _error = '구독 정보를 불러오는데 실패했습니다. (${response.statusCode})';
     112              :       }
     113              :     } catch (e) {
     114            2 :       _error = '구독 정보를 불러오는데 실패했습니다: $e';
     115              :     } finally {
     116            2 :       _isLoading = false;
     117            2 :       notifyListeners();
     118              :     }
     119              :   }
     120              : 
     121              :   /// 사용 가능한 구독 플랜 목록 로드
     122            2 :   Future<void> _loadAvailablePlans() async {
     123            2 :     _isLoading = true;
     124            2 :     _error = null;
     125            2 :     notifyListeners();
     126              : 
     127              :     try {
     128            4 :       final response = await _httpClient.get(
     129            6 :         Uri.parse('$_baseUrl${ApiConfig.subscriptionPlans}'),
     130            2 :         headers: {
     131              :           'Content-Type': 'application/json',
     132            4 :           'Authorization': 'Bearer $_token',
     133              :         },
     134              :       );
     135              : 
     136            0 :       if (response.statusCode == 200) {
     137            0 :         final List<dynamic> data = jsonDecode(response.body);
     138            0 :         _availablePlans = data.map((plan) => SubscriptionPlan.fromJson(plan)).toList();
     139              :       } else {
     140            0 :         _error = '구독 플랜 정보를 불러오는데 실패했습니다. (${response.statusCode})';
     141              :         // 기본 플랜 정보 사용
     142            0 :         _availablePlans = SubscriptionPlan.getDefaultPlans();
     143              :       }
     144              :     } catch (e) {
     145            4 :       _error = '구독 플랜 정보를 불러오는데 실패했습니다: $e';
     146              :       // 기본 플랜 정보 사용
     147            4 :       _availablePlans = SubscriptionPlan.getDefaultPlans();
     148              :     } finally {
     149            2 :       _isLoading = false;
     150            2 :       notifyListeners();
     151              :     }
     152              :   }
     153              : 
     154              :   /// 인앱 결제 서비스 초기화
     155            2 :   Future<void> _initializeInAppPurchase() async {
     156              :     // 인앱 결제 서비스는 자체적으로 초기화됨
     157              :     // 필요한 경우 추가 설정
     158              :   }
     159              : 
     160              :   /// 구독 복원
     161            2 :   Future<bool> restoreSubscriptions() async {
     162            2 :     _isLoading = true;
     163            2 :     _error = null;
     164            2 :     notifyListeners();
     165              : 
     166              :     try {
     167              :       // 인앱 구매 복원 시도
     168            4 :       final restored = await _purchaseService.restorePurchases();
     169              :       
     170              :       if (restored) {
     171              :         // 서버에서 현재 구독 정보 가져오기
     172            2 :         await loadCurrentSubscription();
     173              :         return true;
     174              :       } else {
     175            0 :         _error = '구독을 복원할 수 없습니다.';
     176              :         return false;
     177              :       }
     178              :     } catch (e) {
     179            0 :       _error = '구독 복원 중 오류가 발생했습니다: $e';
     180              :       return false;
     181              :     } finally {
     182            2 :       _isLoading = false;
     183            2 :       notifyListeners();
     184              :     }
     185              :   }
     186              : 
     187              :   /// 구독 상태 검증
     188            1 :   Future<bool> verifySubscription() async {
     189            1 :     _isLoading = true;
     190            1 :     _error = null;
     191            1 :     notifyListeners();
     192              :     
     193              :     try {
     194            2 :       final response = await _httpClient.get(
     195            3 :         Uri.parse('$_baseUrl${ApiConfig.currentSubscription}'),
     196            1 :         headers: {
     197              :           'Content-Type': 'application/json',
     198            2 :           'Authorization': 'Bearer $_token',
     199              :         },
     200              :       );
     201              :       
     202            2 :       if (response.statusCode == 200) {
     203            2 :         final data = jsonDecode(response.body);
     204            2 :         _currentSubscription = Subscription.fromJson(data);
     205            1 :         notifyListeners();
     206              :         return true;
     207              :       } else {
     208            0 :         _error = '구독 검증에 실패했습니다. (${response.statusCode})';
     209              :         return false;
     210              :       }
     211              :     } catch (e) {
     212            0 :       _error = '구독 검증에 실패했습니다: $e';
     213              :       return false;
     214              :     } finally {
     215            1 :       _isLoading = false;
     216            1 :       notifyListeners();
     217              :     }
     218              :   }
     219              : 
     220              :   /// 새 구독 생성 (인앱 결제 통합)
     221            1 :   Future<bool> createSubscription(SubscriptionPlanType planType, bool isYearly) async {
     222            1 :     _isLoading = true;
     223            1 :     _error = null;
     224            1 :     notifyListeners();
     225              : 
     226              :     try {
     227              :       // 인앱 결제 시작
     228            2 :       final success = await _purchaseService.purchaseSubscription(planType);
     229              :       
     230              :       if (!success) {
     231            0 :         _error = '구독 구매 요청에 실패했습니다.';
     232            0 :         _isLoading = false;
     233            0 :         notifyListeners();
     234              :         return false;
     235              :       }
     236              :       
     237              :       // 구매 검증 결과 대기
     238              :       bool verified = false;
     239              :       int attempts = 0;
     240              :       const maxAttempts = 10;
     241              :       
     242              :       // 구매 검증이 완료될 때까지 최대 10회 확인
     243            1 :       while (!verified && attempts < maxAttempts) {
     244            1 :         await Future.delayed(const Duration(seconds: 1));
     245            2 :         verified = _purchaseService.isPurchaseVerified;
     246            1 :         attempts++;
     247              :         
     248              :         // 오류가 발생한 경우
     249            2 :         if (_purchaseService.error != null) {
     250            0 :           _error = _purchaseService.error;
     251            0 :           _isLoading = false;
     252            0 :           notifyListeners();
     253              :           return false;
     254              :         }
     255              :       }
     256              :       
     257              :       if (!verified) {
     258            0 :         _error = '구독 검증 시간이 초과되었습니다.';
     259            0 :         _isLoading = false;
     260            0 :         notifyListeners();
     261              :         return false;
     262              :       }
     263              :       
     264              :       // 검증된 구매 정보 가져오기
     265            2 :       final verifiedPurchase = _purchaseService.lastVerifiedPurchase;
     266              :       if (verifiedPurchase == null) {
     267            0 :         _error = '검증된 구매 정보를 찾을 수 없습니다.';
     268            0 :         _isLoading = false;
     269            0 :         notifyListeners();
     270              :         return false;
     271              :       }
     272              :       
     273              :       // 서버에 구독 생성 요청
     274            2 :       final response = await _httpClient.post(
     275            3 :         Uri.parse('$_baseUrl${ApiConfig.subscribeClub}'),
     276            1 :         headers: {
     277              :           'Content-Type': 'application/json',
     278            2 :           'Authorization': 'Bearer $_token',
     279              :         },
     280            2 :         body: jsonEncode({
     281            3 :           'planType': planType.toString().split('.').last,
     282              :           'isYearly': isYearly,
     283            1 :           'clubId': _clubId,
     284            1 :           'transactionId': verifiedPurchase.purchaseID,
     285            1 :           'productId': verifiedPurchase.productID,
     286            2 :           'verificationData': verifiedPurchase.verificationData.serverVerificationData,
     287              :         }),
     288              :       );
     289              : 
     290            2 :       if (response.statusCode == 200) {
     291            2 :         final data = jsonDecode(response.body);
     292            2 :         _currentSubscription = Subscription.fromJson(data);
     293              :         
     294              :         // 구매 검증 상태 초기화
     295            2 :         _purchaseService.isPurchaseVerified = false;
     296            2 :         _purchaseService.lastVerifiedPurchase = null;
     297              :         
     298            1 :         notifyListeners();
     299              :         return true;
     300              :       } else {
     301            0 :         _error = '구독 생성에 실패했습니다. (${response.statusCode})';
     302              :         return false;
     303              :       }
     304              :     } catch (e) {
     305            0 :       _error = '구독 생성 중 오류가 발생했습니다: $e';
     306              :       return false;
     307              :     } finally {
     308            1 :       _isLoading = false;
     309            1 :       notifyListeners();
     310              :     }
     311              :   }
     312              : 
     313              :   /// 구독 취소
     314            1 :   Future<bool> cancelSubscription() async {
     315            1 :     if (_currentSubscription == null) {
     316            1 :       _error = '취소할 구독이 없습니다.';
     317            1 :       notifyListeners();
     318              :       return false;
     319              :     }
     320              : 
     321            1 :     _isLoading = true;
     322            1 :     _error = null;
     323            1 :     notifyListeners();
     324              : 
     325              :     try {
     326            2 :       final response = await _httpClient.post(
     327            5 :         Uri.parse('$_baseUrl${ApiConfig.subscriptions}/${_currentSubscription!.id}/cancel'),
     328            1 :         headers: {
     329              :           'Content-Type': 'application/json',
     330            2 :           'Authorization': 'Bearer $_token',
     331              :         },
     332              :       );
     333              : 
     334            2 :       if (response.statusCode == 200) {
     335            2 :         final data = jsonDecode(response.body);
     336            2 :         _currentSubscription = Subscription.fromJson(data);
     337            1 :         notifyListeners();
     338              :         return true;
     339              :       } else {
     340            0 :         _error = '구독 취소에 실패했습니다. (${response.statusCode})';
     341              :         return false;
     342              :       }
     343              :     } catch (e) {
     344            0 :       _error = '구독 취소에 실패했습니다: $e';
     345              :       return false;
     346              :     } finally {
     347            1 :       _isLoading = false;
     348            1 :       notifyListeners();
     349              :     }
     350              :   }
     351              : 
     352              :   /// 구독 플랜 변경
     353            1 :   Future<bool> changePlan(SubscriptionPlanType newPlanType, bool isYearly) async {
     354            1 :     if (_currentSubscription == null) {
     355            1 :       _error = '변경할 구독이 없습니다.';
     356            1 :       notifyListeners();
     357              :       return false;
     358              :     }
     359              : 
     360            1 :     _isLoading = true;
     361            1 :     _error = null;
     362            1 :     notifyListeners();
     363              : 
     364              :     try {
     365            2 :       final response = await _httpClient.post(
     366            3 :         Uri.parse('$_baseUrl${ApiConfig.changePlan}'),
     367            1 :         headers: {
     368              :           'Content-Type': 'application/json',
     369            2 :           'Authorization': 'Bearer $_token',
     370              :         },
     371            2 :         body: jsonEncode({
     372            2 :           'subscriptionId': _currentSubscription!.id,
     373            3 :           'planType': newPlanType.toString().split('.').last,
     374              :           'isYearly': isYearly,
     375            1 :           'clubId': _clubId,
     376              :         }),
     377              :       );
     378              : 
     379            2 :       if (response.statusCode == 200) {
     380            2 :         final data = jsonDecode(response.body);
     381            2 :         _currentSubscription = Subscription.fromJson(data);
     382            1 :         notifyListeners();
     383              :         return true;
     384              :       } else {
     385            0 :         _error = '구독 플랜 변경에 실패했습니다. (${response.statusCode})';
     386              :         return false;
     387              :       }
     388              :     } catch (e) {
     389            0 :       _error = '구독 플랜 변경에 실패했습니다: $e';
     390              :       return false;
     391              :     } finally {
     392            1 :       _isLoading = false;
     393            1 :       notifyListeners();
     394              :     }
     395              :   }
     396              : 
     397              :   /// 자동 갱신 설정 변경
     398            1 :   Future<bool> toggleAutoRenew() async {
     399            1 :     if (_currentSubscription == null) {
     400            1 :       _error = '변경할 구독이 없습니다.';
     401            1 :       notifyListeners();
     402              :       return false;
     403              :     }
     404              : 
     405            1 :     _isLoading = true;
     406            1 :     _error = null;
     407            1 :     notifyListeners();
     408              : 
     409              :     try {
     410            2 :       final response = await _httpClient.post(
     411            3 :         Uri.parse('$_baseUrl${ApiConfig.subscriptions}/autorenew'),
     412            1 :         headers: {
     413              :           'Content-Type': 'application/json',
     414            2 :           'Authorization': 'Bearer $_token',
     415              :         },
     416            2 :         body: jsonEncode({
     417            2 :           'subscriptionId': _currentSubscription!.id,
     418            2 :           'autoRenew': !_currentSubscription!.autoRenew,
     419            1 :           'clubId': _clubId,
     420              :         }),
     421              :       );
     422              : 
     423            2 :       if (response.statusCode == 200) {
     424            2 :         final data = jsonDecode(response.body);
     425            2 :         _currentSubscription = Subscription.fromJson(data);
     426            1 :         notifyListeners();
     427              :         return true;
     428              :       } else {
     429            0 :         _error = '자동 갱신 설정 변경에 실패했습니다. (${response.statusCode})';
     430              :         return false;
     431              :       }
     432              :     } catch (e) {
     433            0 :       _error = '자동 갱신 설정 변경에 실패했습니다: $e';
     434              :       return false;
     435              :     } finally {
     436            1 :       _isLoading = false;
     437            1 :       notifyListeners();
     438              :     }
     439              :   }
     440              : 
     441              :   /// 특정 기능이 현재 구독에서 사용 가능한지 확인
     442            1 :   bool canUseFeature(String featureName) {
     443            1 :     if (_currentSubscription == null || !_currentSubscription!.isActive) {
     444              :       return false;
     445              :     }
     446              : 
     447              :     // 기능별 접근 권한 확인 로직
     448              :     switch (featureName) {
     449            0 :       case 'advancedStats':
     450            0 :         return _getSubscriptionPlan(_currentSubscription!.planType).hasAdvancedStats;
     451            0 :       case 'customization':
     452            0 :         return _getSubscriptionPlan(_currentSubscription!.planType).hasCustomization;
     453            0 :       case 'prioritySupport':
     454            0 :         return _getSubscriptionPlan(_currentSubscription!.planType).hasPriority;
     455              :       default:
     456              :         return false;
     457              :     }
     458              :   }
     459              : 
     460              :   /// 현재 구독으로 추가 가능한 최대 회원 수 확인
     461            1 :   int getMaxMembers() {
     462            1 :     if (_currentSubscription == null || !_currentSubscription!.isActive) {
     463            3 :       return SubscriptionPlan.getDefaultPlans().first.maxMembers; // 무료 플랜
     464              :     }
     465            0 :     return _getSubscriptionPlan(_currentSubscription!.planType).maxMembers;
     466              :   }
     467              : 
     468              :   /// 현재 구독으로 추가 가능한 최대 이벤트 수 확인
     469            1 :   int getMaxEvents() {
     470            1 :     if (_currentSubscription == null || !_currentSubscription!.isActive) {
     471            3 :       return SubscriptionPlan.getDefaultPlans().first.maxEvents; // 무료 플랜
     472              :     }
     473            0 :     return _getSubscriptionPlan(_currentSubscription!.planType).maxEvents;
     474              :   }
     475              : 
     476              :   /// 구독 플랜 타입에 해당하는 플랜 정보 가져오기
     477            0 :   SubscriptionPlan _getSubscriptionPlan(SubscriptionPlanType planType) {
     478            0 :     return _availablePlans.firstWhere(
     479            0 :       (plan) => plan.type == planType,
     480            0 :       orElse: () => SubscriptionPlan.getDefaultPlans().first,
     481              :     );
     482              :   }
     483              : }
        

Generated by: LCOV version 2.3.1-1