Files

146 lines
6.7 KiB
Dart

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 = <String, ProductDetails>{};
final _messages = StreamController<String>.broadcast();
StreamSubscription<List<PurchaseDetails>>? _purchases;
/// 사용자에게 보여 줄 결제 결과 한 줄. 결제는 OS 창에서 끝나 화면 밖에서 도착한다.
Stream<String> 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<void> dispose() async {
await _purchases?.cancel();
_purchases = null;
}
/// 스토어 상품 한 개. 스토어를 쓸 수 없거나 등록되지 않았으면 null.
Future<ProductDetails?> 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<void> 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<void> restore() => _iap.restorePurchases();
Future<void> _onPurchases(List<PurchaseDetails> 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<void> _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<InAppPurchaseAndroidPlatformAddition>()
.consumePurchase(purchase);
} else if (purchase.pendingCompletePurchase) {
await _iap.completePurchase(purchase);
}
if (purchase.status == PurchaseStatus.purchased) {
_messages.add(appL10n.storePurchased);
}
}
}