본문 바로가기

OS/Android

안드로이드 인앱 결제 테스트

반응형

이번에 안드로이드 인앱 결제 테스트를 해보았고 간단하게 히스토리를 기록하려고 한다.

 

단어

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);
    }

}

?>

 

 

※ 개발할 때 유의해야 할 점

- 구독 취소

- 환불

- 유예기간

- 프로모션

반응형