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 : }
|