이번에 안드로이드 인앱 결제 테스트를 해보았고 간단하게 히스토리를 기록하려고 한다.
단어
1. 일회성 제품 : 일회성 제품은 사용자가 결제 방법으로 반복되지 않는 단일 요금을 지급함으로써 구매할 수 있는 컨텐츠. Google Play 결제 라이브러리에서는 정기 결제를 "INAPP"이라고 칭함
- 소비성 제품 : 소비성 제품은 사용자가 게임 내 컨텐츠를 받기 위해 소비하는 제품. ex) 게임 머니
- 비소비성 제품 : 비소비성 제품은 한 번 구매하면 영구적인 혜택을 제공하는 제품 ex) 프리미엄 업그레이드
2. 정기 결제 : 정기 결제는 반복적으로 컨텐츠에 대한 엑세스를 제공하는 제품. Google Play 결제 라이브러리에서는 정기 결제를 "SUBS"라고 칭함
- 정기 결제는 취소될 때까지 자동으로 갱신
- 무료 체험판을 제공
- 신규 할인 가격 제공
- 결제 실패 시 유예기간을 제공
- 사용자가 정기 결제를 취소하는 대신 일시 중지 기능 제공
3. 프로모션 : 프로모션 또는 프로모션 코드를 사용하면 한정된 수의 사용자에게 무료로 일회성 제품이나 정기 결제 무료 체험판을 제공할 수 있음. 유저는 Google Play 스토어 앱에서 프로모션 코드를 입력하여 체험판을 사용할 수 있음.
개발 방식
1. 로컬 검증 방식
- 안드로이드 Billing 라이브러리로만 구매 처리를 하는 방식 (플레이스토어 앱에서 구매 데이터를 캐싱 처리 하는 것으로 추측)
- 구글 서버에 검증 처리를 거치지 않고 로컬에서만 처리를 하기 때문에 보안이 취약함.
2. 영수증 검증 방식
- 인앱을 구매하면 purchaseToken이 발행되는데 이 값을 api 서버에 넘기고 google 영수증 api를 호출하여 검증하는 방식.
- 구글 서버에 직접 질의하는 방식이므로 유저가 거짓으로 구매한건지 확인할 수 있음
- google 영수증 api 제한 존재
- 구독 취소, 환불과 같은 이벤트들에 유기적으로 대응할 수 없음
3. PUB/SUB
- G/C에 api 서버 엔드 포인트를 등록시켜 결제 이벤트가 발생했을 때 정보 수신
- 구독 취소, 환불, 일반 상품 사용 처리 등 대부분의 이벤트들을 수신할 수 있음
- 유료임
- RTDN 설정법
- 먼저 구글 콘솔에서 서비스 계정을 만든다. 모든 어플리케이션 → 설정 → API 엑세스 → 서비스 계정 만들기 → 권한설정
- https://console.cloud.google.com/cloudpubsub 접속하여 주제 → 주제 만들기 → 주제 ID 등록
- 구독 → 구독 만들기 → 구독 ID 기입 → 등록해 놓은 주제 선택 → 전송 유형 → 가져오기(PUSH) → EndPoint URL 등록(https 등록되있어야함) → 구독 만들기
- 구글 콘솔 → 서비스 및 API → 실시간 개발자 알림 → 주제 이름 → 주제 만들 때 사용한 주제 이름 작성 ex(projects/esoteric-pen-288109/topics/play-notification) → 테스트 보내기
- 테스트 코드
<?php
/***
* 202102 Hinos
* RTDN(개발자 실시간 알림)으로 유저가 결제를 취소하거나 환불했을 때 이 페이지가 호출이 됨.
* 공식 문서 참고 : https://developer.android.google.cn/google/play/billing/realtime_developer_notifications?hl=ko
*
***/
include 'vendor/autoload.php';
define("JsonPath", "서비스 제이슨 파일");
define("AndroidPublisher", "https://www.googleapis.com/auth/androidpublisher");
define("DEBURG", true);
if (DEBURG)
{
verifyReceipt(null);
return;
}
$json = json_decode(file_get_contents("php://input"));
$myData = base64_decode($json->message->data);
switch ($myData)
{
case strpos($myData, "testNotification"):
$fp = fopen("log.txt", "w");
fwrite($fp, $myData);
exit();
break;
case strpos($myData, "OneTimeProductNotification"):
parsePurchaseInfo($myData);
break;
case strpos($myData, "SubscriptionNotification"):
parseSubScriptionInfo($myData);
break;
}
function parsePurchaseInfo($myData) // 일회용 상품일 때
{
/***
* {
* "version": string
* "notificationType": int
* "purchaseToken": string
* "subscriptionId": string
* }
*/
}
function parseSubScriptionInfo($myData) // 정기결제(구독) 상품일때
{
/***
*{
* "version":"1.0",
* "packageName":"com.some.thing",
* "eventTimeMillis":"1503349566168",
* "subscriptionNotification":
* {
* "version":"1.0",
* "notificationType":4,
* "purchaseToken":"PURCHASE_TOKEN",
* "subscriptionId":"my.sku"
* }
*}
*/
}
function verifyReceipt(array $arrData) // $mode 1. 일반상품, 2. 구독결제, 3. 취소정보 얻어옴, 4 수동으로 지움
{
/** 구글서버에 접속하여 인증작업 진행
* 정상적인 인증일 때 True 반환, 비정상적일 때 False 반환
*/
$product_id = "";
$purchase_token = "";
$package_name = "";
$mode = 0;
if (DEBURG)
{
$product_id = "상품아이디";
$purchase_token = "임시키";
$package_name = "패키지";
$mode = 2;
}
else
{
$product_id = $arrData['product_id'];
$purchase_token =$arrData['purchase_token'];
$package_name = $arrData['package_name'];
$mode = $arrData['mode'];
}
$verify = new BillingVerify($product_id, $purchase_token, $package_name);
$str = null;
$arr_result = array();
switch ($mode)
{
case 0 :
error_log("Billing Mode Value 0");
break;
case 1 : //일반결제 정보 얻어옴
$arr_result = $verify -> getProductInfo();
$str = json_encode($arr_result);
break;
case 2 : //구독정보 얻어옴
$arr_result = $verify -> getSubscribeInfo();
$str = json_encode($arr_result);
break;
case 3 : //취소정보 얻어옴(구독결제)
try{
$arr_result['res_code'] = 1;
$arr_result['res_msg'] = 'ok';
$result = $verify -> getCancelInfo();
}catch (Exception $e)
{
$arr_result['res_code'] = 2;
$arr_result['res_msg'] = 'error';
}
$str = json_encode($arr_result);
break;
}
}
class BillingVerify
{
public $mProduct_id;
public $mPurchase_token;
public $mPackage_name;
public $mClient;
public $mService;
public function __construct($product_id, $purchase_token, $package_name)
{
$this->mProduct_id = $product_id;
$this->mPurchase_token = $purchase_token;
$this->mPackage_name = $package_name;
$this->init();
}
public function init()
{
$this->mClient = new Google_Client();
$this->mClient->setAuthConfig(JsonPath);
$this->mClient->addScope(AndroidPublisher);
$this->mService = new Google_Service_AndroidPublisher($this->mClient);
}
public function getProductInfo()
{
$this->paramEmptyCheck();
$result = $this->mService->purchases_products->get($this->mPackage_name, $this->mProduct_id, $this->mPurchase_token);
$arr = array();
$arr['kind'] = $result->getKind();
$arr['purchaseTimeMillis'] = $result->getPurchaseTimeMillis();
$arr['purchaseState'] = $result->getPurchaseState();
$arr['consumptionState'] = $result->getConsumptionState();
$arr['developerPayload'] = $result->getDeveloperPayload();
$arr['orderId'] = $result->getOrderId();
$arr['purchaseType'] = $result->getPurchaseType();
$arr['acknowledgementState'] = $result->getAcknowledgementState();
return $arr;
}
public function getSubscribeInfo()
{
$this->paramEmptyCheck();
$result = $this->mService-> purchases_subscriptions-> get($this->mPackage_name, $this->mProduct_id, $this->mPurchase_token);
$arr = array();
$arr['kind'] = $result -> kind;
$arr['startTimeMillis'] = $result -> startTimeMillis;
$arr['expiryTimeMillis'] = $result -> expiryTimeMillis;
$arr['autoResumeTimeMillis'] = $result -> autoResumeTimeMillis;
$arr['autoRenewing'] = $result -> autoRenewing;
$arr['priceCurrencyCode'] = $result -> priceCurrencyCode;
$arr['priceAmountMicros'] = $result -> priceAmountMicros;
$arr['introductoryPriceInfo'] = $result -> getIntroductoryPriceInfo();
$arr['countryCode'] = $result -> countryCode;
$arr['developerPayload'] = $result -> developerPayload;
$arr['paymentState'] = $result -> getPaymentState();
$arr['cancelReason'] = $result -> cancelReason;
$arr['userCancellationTimeMillis'] = $result -> userCancellationTimeMillis;
$arr['cancelSurveyResult'] = $result -> getCancelSurveyResult();
$arr['orderId'] = $result -> orderId;
$arr['linkedPurchaseToken'] = $result -> linkedPurchaseToken;
$arr['purchaseType'] = $result -> purchaseType; // 0. 테스트, 1. 프로모션 코드를 사용하여 구매
$arr['priceChange'] = $result -> getPriceChange();
$arr['profileName'] = $result -> profileName;
$arr['emailAddress'] = $result -> emailAddress; // 구글 이메일
$arr['givenName'] = $result -> givenName;
$arr['familyName'] = $result -> familyName;
$arr['profileId'] = $result -> profileId;
$arr['acknowledgementState'] = $result -> acknowledgementState;
$arr['promotionType'] = $result -> promotionType;
$arr['promotionCode'] = $result -> promotionCode;
return $arr;
}
public function getCancelInfo()
{
$arr = array();
$this->paramEmptyCheck();
$result = $this->mService-> purchases_subscriptions-> cancel($this->mPackage_name, $this->mProduct_id, $this->mPurchase_token);
return $result;
}
function test()
{
$result = $this->mService-> purchases_subscriptions-> get($this->mPackage_name, $this->mProduct_id, $this->mPurchase_token);
print_r($result);
}
function paramEmptyCheck()
{
if (empty($this->mService))
{
$this -> errorResponse("1101", "no paramater", "service not bound");
}
else if (empty($this->mProduct_id))
{
$this -> errorResponse("1101", "no paramater", "empty mProduct_id");
}
else if (empty($this->mPurchase_token))
{
$this -> errorResponse("1101", "no paramater", "empty mPurchase_token");
}
else if (empty($this->mPackage_name))
{
$this -> errorResponse("1101", "no paramater", "empty mPackage_name");
}
exit();
}
function errorResponse($error_code, $error_message, $detail_message)
{
$arr = array();
$arr['error_code'] = $error_code;
$arr['error_message'] = $error_message;
$arr['detail_message'] = $detail_message;
echo json_encode($arr);
}
}
?>
※ 개발할 때 유의해야 할 점
- 구독 취소
- 환불
- 유예기간
- 프로모션
'OS > Android' 카테고리의 다른 글
Android decompile (0) | 2021.03.10 |
---|---|
Android ABI (0) | 2021.03.03 |
안드로이드 realtimedatabase, google login 사용하여 배포 (0) | 2021.01.05 |
안드로이드 MVVM 저장용 (0) | 2020.12.30 |
안드로이드 스튜디오 적응형 런처 아이콘 만들기 (0) | 2020.12.29 |