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