import 'dart:async'; import 'dart:io' show Platform; import 'package:in_app_purchase/in_app_purchase.dart'; import 'package:in_app_purchase_android/in_app_purchase_android.dart'; import '../data/backend.dart'; import '../i18n/app_locale.dart'; /// 인앱 결제 — 별첨 1 ⑥ 「영수증 서버검증 통과(위조 차단)」 · 정책 원장 B-02 · B-04 · B-84. /// /// **앱은 지급하지 않는다.** 스토어가 준 영수증(Android 구매 토큰 · iOS 영수증)을 /// `fnIapVerify` 에 넘기고, 서버가 상품 ID 로 혜택을 정한다. 서버가 받아들인 뒤에만 /// 스토어에 구매 완료를 알린다 — 검증 전에 완료하면 검증이 실패해도 결제가 확정되고, /// 완료하지 않은 Android 구매는 3일 뒤 스토어가 자동 환불한다. /// /// 상품 ID 는 서버 상품 표(`firebase/functions/src/payment/receipt.ts`)와 Hero Gift /// Catalog(`heroGiftProducts/{스토어 상품 ID}`)가 정한다. 스토어에 상품이 아직 없으면 /// 조회가 비어 오고, 화면은 「스토어 상품 준비 중」으로 정직하게 멈춘다. class Store { Store(this._backend, {InAppPurchase? iap}) : _iap = iap ?? InAppPurchase.instance; /// 실 모드에서만 채워진다(`Repositories`). 목 모드와 테스트에서는 null 이라 /// 화면이 「준비 중」을 띄운다. static Store? instance; /// Focus 월 구독 — 서버 `SUBSCRIPTION_PRODUCTS` 와 같은 ID. static const focusMonthly = 'focus.monthly'; static const _subscriptions = {'focus.monthly', 'focus.yearly'}; static String get notReady => appL10n.storeNotReady; final Backend _backend; final InAppPurchase _iap; final _products = {}; final _messages = StreamController.broadcast(); StreamSubscription>? _purchases; /// 사용자에게 보여 줄 결제 결과 한 줄. 결제는 OS 창에서 끝나 화면 밖에서 도착한다. Stream get messages => _messages.stream; /// 구매 알림을 듣기 시작한다. 로그인 뒤에 한 번 부른다. /// /// iOS 는 완료하지 않은 거래를 구독 즉시 다시 보낸다. Android 는 보내지 않으므로 /// 복원으로 끌어온다 — 결제 대기(pending)였다가 승인된 구매와 검증에 실패했던 /// 구매가 여기서 다시 서버로 간다. 서버는 거래 ID 로 멱등이라 여러 번 가도 한 번 지급이다. void start() { if (_purchases != null) return; _purchases = _iap.purchaseStream.listen(_onPurchases, onError: (_) {}); if (Platform.isAndroid) unawaited(_iap.restorePurchases().catchError((_) {})); } Future dispose() async { await _purchases?.cancel(); _purchases = null; } /// 스토어 상품 한 개. 스토어를 쓸 수 없거나 등록되지 않았으면 null. Future product(String id) async { final cached = _products[id]; if (cached != null) return cached; if (!await _iap.isAvailable()) return null; final result = await _iap.queryProductDetails({id}); if (result.productDetails.isEmpty) return null; return _products[id] = result.productDetails.first; } /// 결제 창을 띄운다. 결과는 [messages] 로 온다. Future buy(String productId) async { final details = await product(productId); if (details == null) throw BackendException('E-PAY-02', notReady); final param = PurchaseParam(productDetails: details); if (_subscriptions.contains(productId)) { await _iap.buyNonConsumable(purchaseParam: param); } else { // 자동 소비를 끈다 — 서버 검증 전에 소비하면 실패한 구매를 다시 보낼 수 없다. await _iap.buyConsumable(purchaseParam: param, autoConsume: false); } } /// 구매 복원. Focus 구독을 다른 기기·재설치 뒤에 되살린다. Future restore() => _iap.restorePurchases(); Future _onPurchases(List purchases) async { for (final purchase in purchases) { switch (purchase.status) { case PurchaseStatus.pending: _messages.add(appL10n.storePending); case PurchaseStatus.canceled: case PurchaseStatus.error: if (purchase.status == PurchaseStatus.error) { _messages.add(appL10n.storeFailed); } // 실패·취소 거래도 닫아야 iOS 큐에 남지 않는다. 결제가 확정되지 않은 건이다. if (purchase.pendingCompletePurchase) { await _iap.completePurchase(purchase); } case PurchaseStatus.purchased: case PurchaseStatus.restored: await _verify(purchase); } } } Future _verify(PurchaseDetails purchase) async { final details = _products[purchase.productID]; try { await _backend.call('fnIapVerify', { 'platform': Platform.isIOS ? 'ios' : 'android', 'receipt': purchase.verificationData.serverVerificationData, 'productId': purchase.productID, // iOS 앱 영수증은 여러 거래를 담는다 — 서버가 이 거래를 고른다. if (purchase.purchaseID case final id?) 'transactionId': id, // 표시용. 서버는 지급 판단에 쓰지 않는다. if (details != null) ...{ 'title': details.title, 'priceAmount': details.rawPrice, 'currency': details.currencyCode, }, }); } on BackendException catch (error) { if (purchase.status == PurchaseStatus.purchased) _messages.add(error.message); if (error.code == 'E-PAY-01' && !Platform.isAndroid) { // 재시도해도 같은 거부(판매 중지 · 다른 계정 · 소속 행성 없음). iOS 는 거래를 닫지 // 않으면 실행마다 다시 온다. 환불은 Apple 경로(사용자 요청)로 간다. if (purchase.pendingCompletePurchase) await _iap.completePurchase(purchase); } // 그 밖은 닫지 않는다 — 다음 실행에서 다시 보낸다. Android 는 E-PAY-01 도 확인하지 // 않고 둔다: 3일 안에 확인되지 않은 구매는 스토어가 **자동 환불**한다. 다시 보내는 // 복원 재전송은 조용하므로 알림은 처음 한 번뿐이다. return; } if (Platform.isAndroid && !_subscriptions.contains(purchase.productID)) { // 소비해야 같은 상품을 다시 살 수 있다. 소비가 곧 확인(acknowledge)이다. await _iap .getPlatformAddition() .consumePurchase(purchase); } else if (purchase.pendingCompletePurchase) { await _iap.completePurchase(purchase); } if (purchase.status == PurchaseStatus.purchased) { _messages.add(appL10n.storePurchased); } } }