Today's

길을 나서지 않으면 그 길에서 만날 수 있는 사람을 만날 수 없다

모바일 앱(안드로이드)

안드로이드 앱 만들기 : 구글 인앱 결제 쉽게 따라하기 (정기결제, 소스공유)

Billcorea 2022. 10. 22. 01:15
반응형

이전 포스팅

이전에 작성했던 포스팅을 참고하여 인앱 결제를 구현했던 기억을 되살펴 보겠습니다.

https://billcorea.tistory.com/27

안드로이드 앱 만들기 구글 인앱결제 쉽게 따라 하기...

인앱 결제를 하기 위해서 오늘도 구글링을 하시는 분들께... 기본적은 헤맴을 줄여보기 위해서 정리를 해 둡니다. 인앱 결제를 하려면 일단, 할 일은 앱을 하나 만들어서 구글 플레이에 등록을

billcorea.tistory.com


이전 포스팅에서는 1회성 결제에 대한 구현을 살펴볼 수 있습니다. 이번에는 정기 결제를 구현해 보도록 하겠습니다. 이번 구현을 위해서 gradle 설정을 해 봅니다.

dependencies {

    ...
    
    implementation 'com.android.billingclient:billing:5.0.0'
    implementation 'com.google.code.gson:gson:2.9.0'
    
    ...

}

BillingClient

코드 구현은 다음과 같이 구현을 하였습니다. 코드 하나 구현해 두면 다음 프로젝트에서는 그대로 옮겨다 사용할 수 있습니다.

import android.app.Activity;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import android.widget.Toast;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

import com.android.billingclient.api.AcknowledgePurchaseParams;
import com.android.billingclient.api.AcknowledgePurchaseResponseListener;
import com.android.billingclient.api.BillingClient;
import com.android.billingclient.api.BillingClientStateListener;
import com.android.billingclient.api.BillingFlowParams;
import com.android.billingclient.api.BillingResult;
import com.android.billingclient.api.ConsumeParams;
import com.android.billingclient.api.ConsumeResponseListener;
import com.android.billingclient.api.Purchase;
import com.android.billingclient.api.PurchaseHistoryRecord;
import com.android.billingclient.api.PurchaseHistoryResponseListener;
import com.android.billingclient.api.PurchasesResponseListener;
import com.android.billingclient.api.PurchasesUpdatedListener;
import com.android.billingclient.api.SkuDetails;
import com.android.billingclient.api.SkuDetailsParams;
import com.android.billingclient.api.SkuDetailsResponseListener;
import com.billcoreatech.daycnt415.R;
import com.billcoreatech.daycnt415.util.KakaoToast;
import com.billcoreatech.daycnt415.util.StringUtil;

import org.json.JSONException;
import org.json.JSONObject;

import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;

public class BillingManager implements PurchasesUpdatedListener, ConsumeResponseListener {
    String TAG = "BillingManager" ;
    BillingClient mBillingClient ;
    Activity mActivity ;
    public List<SkuDetails> mSkuDetails ;

    public enum connectStatusTypes { waiting, connected, fail, disconnected }
    public connectStatusTypes connectStatus = connectStatusTypes.waiting ;
    private ConsumeResponseListener mConsumResListnere ;

    String punchName = "210414_monthly_bill_999";
    String payType = BillingClient.SkuType.SUBS ;

    SharedPreferences option ;
    SharedPreferences.Editor editor ;

    public BillingManager (Activity _activity) {
        mActivity = _activity ;
        option = mActivity.getSharedPreferences("option", mActivity.MODE_PRIVATE);
        editor = option.edit();
        mBillingClient = BillingClient.newBuilder(mActivity)
                .setListener(this)
                .enablePendingPurchases()
                .build() ;
        mBillingClient.startConnection(new BillingClientStateListener() {
            @Override
            public void onBillingSetupFinished(@NonNull BillingResult billingResult) {
                Log.e(TAG, "respCode=" + billingResult.getResponseCode() ) ;
                if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) {
                    connectStatus = connectStatusTypes.connected ;
                    Log.e(TAG, "connected...") ;
                    purchaseAsync();
                    getSkuDetailList();

                } else {
                    connectStatus = connectStatusTypes.fail ;
                    Log.i(TAG, "connected... fail ") ;
                }
            }

            @Override
            public void onBillingServiceDisconnected() {
                connectStatus = connectStatusTypes.disconnected ;
                Log.i(TAG, "disconnected ") ;
            }
        });

    }

    /**
     * 정기 결재 소모 여부를 수신 : 21.04.20 1회성 구매의 경우는 결재하면 끝임.
     * @param billingResult
     * @param purchaseToken
     */
    @Override
    public void onConsumeResponse(@NonNull BillingResult billingResult, @NonNull String purchaseToken) {
        if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) {
            Log.i(TAG, "사용끝 + " + purchaseToken) ;
            return ;
        } else {
            Log.i(TAG, "소모에 실패 " + billingResult.getResponseCode() + " 대상 상품 " + purchaseToken) ;
            return ;
        }
    }

    public int purchase(SkuDetails skuDetails) {
        BillingFlowParams flowParams = BillingFlowParams.newBuilder()
                .setSkuDetails(skuDetails)
                .build();
        return mBillingClient.launchBillingFlow(mActivity, flowParams).getResponseCode();
    }

    public void purchaseAsync() {
        Log.e(TAG, "--------------------------------------------------------------");

        mBillingClient.queryPurchasesAsync(payType, new PurchasesResponseListener() {
            @Override
            public void onQueryPurchasesResponse(@NonNull BillingResult billingResult, @NonNull List<Purchase> list) {
                Log.e(TAG, "onQueryPurchasesResponse=" + billingResult.getResponseCode()) ;
                SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
                if (list.size() < 1) {
                    editor = option.edit();
                    editor.putBoolean("isBill", false);
                    editor.commit();
                } else {
                    for (Purchase purchase : list) {
                        Log.e(TAG, "getPurchaseToken=" + purchase.getPurchaseToken());
                        for (String str : purchase.getSkus()) {
                            Log.e(TAG, "getSkus=" + str);
                        }
                        Date now = new Date();
                        now.setTime(purchase.getPurchaseTime());
                        Log.e(TAG, "getPurchaseTime=" + sdf.format(now));
                        Log.e(TAG, "getQuantity=" + purchase.getQuantity());
                        Log.e(TAG, "getSignature=" + purchase.getSignature());
                        Log.e(TAG, "isAutoRenewing=" + purchase.isAutoRenewing());

                        editor = option.edit();
                        editor.putBoolean("isBill", purchase.isAutoRenewing());
                        editor.commit();
                    }
                }
            }
        });

        mBillingClient.queryPurchaseHistoryAsync(payType, new PurchaseHistoryResponseListener() {
            @Override
            public void onPurchaseHistoryResponse(@NonNull BillingResult billingResult, @Nullable List<PurchaseHistoryRecord> list) {
                if (billingResult.getResponseCode() == 0) {
                    SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
                    for(PurchaseHistoryRecord purchase : list) {
                        Log.e(TAG, "getPurchaseToken=" + purchase.getPurchaseToken());
                        for (String str : purchase.getSkus()) {
                            Log.e(TAG, "getSkus=" + str);
                        }
                        Date now = new Date();
                        now.setTime(purchase.getPurchaseTime());
                        Log.e(TAG, "getPurchaseTime=" + sdf.format(now));
                        Log.e(TAG, "getQuantity=" + purchase.getQuantity());
                        Log.e(TAG, "getSignature=" + purchase.getSignature());

                        if (payType.equals(BillingClient.SkuType.INAPP)) {
                            ConsumeParams params = ConsumeParams.newBuilder()
                                    .setPurchaseToken(purchase.getPurchaseToken())
                                    .build();
                            mBillingClient.consumeAsync(params, BillingManager.this);
                        }

                    }
                }
            }
        });
    }

    public void getSkuDetailList() {
        List<String> skuIdList = new ArrayList<>() ;
        skuIdList.add(punchName);

        SkuDetailsParams.Builder params = SkuDetailsParams.newBuilder();
        params.setSkusList(skuIdList).setType(payType);
        mBillingClient.querySkuDetailsAsync(params.build(), new SkuDetailsResponseListener() {
            @Override
            public void onSkuDetailsResponse(@NonNull BillingResult billingResult, @Nullable List<SkuDetails> skuDetailsList) {
                if (billingResult.getResponseCode() != BillingClient.BillingResponseCode.OK) {
                    Log.i(TAG, "detail respCode=" + billingResult.getResponseCode()) ;
                    return ;
                }
                if (skuDetailsList == null) {
                    KakaoToast.makeToast(mActivity, mActivity.getString(R.string.msgNotInfo), Toast.LENGTH_LONG).show();
                    return ;
                }
                Log.i(TAG, "listCount=" + skuDetailsList.size());
                for(SkuDetails skuDetails : skuDetailsList) {
                    Log.i(TAG, "\n" + skuDetails.getSku()
                            + "\n" + skuDetails.getTitle()
                            + "\n" + skuDetails.getPrice()
                            + "\n" + skuDetails.getDescription()
                            + "\n" + skuDetails.getFreeTrialPeriod()
                            + "\n" + skuDetails.getIconUrl()
                            + "\n" + skuDetails.getIntroductoryPrice()
                            + "\n" + skuDetails.getIntroductoryPriceAmountMicros()
                            + "\n" + skuDetails.getOriginalPrice()
                            + "\n" + skuDetails.getPriceCurrencyCode()) ;
                }
                mSkuDetails = skuDetailsList ;

            }
        });
    }

    /**
     * @param billingResult
     * @param purchases
     */
    @Override
    public void onPurchasesUpdated(@NonNull BillingResult billingResult, @Nullable List<Purchase> purchases) {

        if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK && purchases != null) {
            Log.i(TAG, "구매 성공>>>" + billingResult.getDebugMessage());
            JSONObject object = null ;
            String pID = "" ;
            String pDate = "" ;

            for(Purchase purchase : purchases) {
                // 정기구독의 경우는 구매처리후 구매 확인을 해 주어야 취소가 되지 않음.
                handlePurchase(purchase);
                Log.i(TAG, "성공값=" + purchase.getPurchaseToken()) ;
                try {
                    Log.e(TAG, "getOriginalJson=" + purchase.getOriginalJson());
                    object = new JSONObject(purchase.getOriginalJson());
                    String sku = "";
                    for (String str : purchase.getSkus()) {
                        sku = str ;
                        Log.e(TAG, "SKU=" + sku);
                    }
                    pID = object.getString("purchaseToken");
                    pDate = StringUtil.getDate(object.getLong("purchaseTime"));
                    if (sku.equals(punchName)) {                       
                        editor.putLong("billTimeStamp", object.getLong("purchaseTime"));
                        editor.putBoolean("isBill", object.getBoolean("autoRenewing"));
                        editor.putString("token", purchase.getPurchaseToken());
                    }
                    editor.commit();

                } catch (JSONException e) {
                    e.printStackTrace();
                }
            }
        } else if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.USER_CANCELED) {
            Log.i(TAG, "결제 취소");
            editor = option.edit();
            editor.putBoolean("isBill", false);
            editor.commit();
        } else {
            Log.i(TAG, "오류 코드=" + billingResult.getResponseCode()) ;
            editor = option.edit();
            editor.putBoolean("isBill", false);
            editor.commit();
        }
    }

    void handlePurchase(Purchase purchase) {
        if (purchase.getPurchaseState() == Purchase.PurchaseState.PURCHASED) {
            if (!purchase.isAcknowledged()) {
                AcknowledgePurchaseParams acknowledgePurchaseParams =
                        AcknowledgePurchaseParams.newBuilder()
                                .setPurchaseToken(purchase.getPurchaseToken())
                                .build();
                mBillingClient.acknowledgePurchase(acknowledgePurchaseParams, new AcknowledgePurchaseResponseListener() {
                    @Override
                    public void onAcknowledgePurchaseResponse(@NonNull BillingResult billingResult) {
                        Log.e(TAG, "getResponseCode=" + billingResult.getResponseCode());
                    }
                });
            }
        }
        //PENDING
        else if (purchase.getPurchaseState() == Purchase.PurchaseState.PENDING) {
            //구매 유예
            Log.e(TAG, "//구매 유예");
        }
        else {
            //구매확정 취소됨(기타 다양한 사유...)
            Log.e(TAG, "//구매확정 취소됨(기타 다양한 사유...)");
        }    
    }

}

정기결제 예시

정기결제를 실행하는 화면은 다음과 왼쪽 그림과 같이 처리가 됩니다.

이렇게 정기 결제된 경우에는 다른 처리를 할 필요는 없습니다.

다만, 정기결제가 유지되고 있는 지를 확인하는 처리가 필요했습니다.

그래서 아래 코드와 같이 queryPurchaseAsync을 호출해서
구매된 내역을 확인하여 그 값 중에

isAutoRenewing()의 값이 true 가 오는 지를 보고 값이 true 인 경우는 정기결제가 유지되고 있음을 확인할 수 있었습니다.

list 값이 오지 않거나, false 가 오면 정기 구매가 되지 않고 있다고 보고 필요한 처리를 하면 됩니다.













mBillingClient.queryPurchasesAsync(payType, new PurchasesResponseListener() {
            @Override
            public void onQueryPurchasesResponse(@NonNull BillingResult billingResult, @NonNull List<Purchase> list) {
                Log.e(TAG, "onQueryPurchasesResponse=" + billingResult.getResponseCode()) ;
                SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
                if (list.size() < 1) {
                    editor = option.edit();
                    editor.putBoolean("isBill", false);
                    editor.commit();
                } else {
                    for (Purchase purchase : list) {
                        Log.e(TAG, "getPurchaseToken=" + purchase.getPurchaseToken());
                        for (String str : purchase.getSkus()) {
                            Log.e(TAG, "getSkus=" + str);
                        }
                        Date now = new Date();
                        now.setTime(purchase.getPurchaseTime());
                        Log.e(TAG, "getPurchaseTime=" + sdf.format(now));
                        Log.e(TAG, "getQuantity=" + purchase.getQuantity());
                        Log.e(TAG, "getSignature=" + purchase.getSignature());
                        Log.e(TAG, "isAutoRenewing=" + purchase.isAutoRenewing());

                        editor = option.edit();
                        editor.putBoolean("isBill", purchase.isAutoRenewing());
                        editor.commit();
                    }
                }
            }
        });


테스트 구매의 경우는 5분마다 한 번씩 갱신되므로 5분 뒤에 다시 확인하는 처리에 대한 검증을 해 볼 수 있습니다.
기타 추가적인 작업을 해야 할 것들이 남아 있는 것으로 생각이 됩니다. 아래 링크를 참고해서 추가 구현을 해 보도록 하겠습니다.

https://developer.android.com/google/play/billing/subscriptions?hl=ko

정기 결제 판매  |  Google Play 결제 시스템  |  Android Developers

알림: 2021년 11월 1일부터는 기존 앱의 모든 업데이트에도 결제 라이브러리 버전 3 이상이 요구됩니다. 자세히 알아보기 정기 결제 판매 이 주제에서는 갱신 및 만료와 같은 정기 결제 수명 주기

developer.android.com


이것으로 인앱 결제 정기결제에 대한 이해를 해 보았습니다.

정기구독의 경우 구독취소가 되지 않도록 하기 위해서는 구매한 것을 확인해 주는 처리를 꼭 거쳐야 합니다. 그렇지 않을 경우에는 구독했던 것이 취소되어 소모가 되지 않기 때문입니다.

if (purchase.getPurchaseState() == Purchase.PurchaseState.PURCHASED) {
    if (!purchase.isAcknowledged()) {
        AcknowledgePurchaseParams acknowledgePurchaseParams =
                AcknowledgePurchaseParams.newBuilder()
                        .setPurchaseToken(purchase.getPurchaseToken())
                        .build();
        mBillingClient.acknowledgePurchase(acknowledgePurchaseParams, new AcknowledgePurchaseResponseListener() {
            @Override
            public void onAcknowledgePurchaseResponse(@NonNull BillingResult billingResult) {
                Log.e(TAG, "getResponseCode=" + billingResult.getResponseCode());
            }
        });
    }
}
//PENDING
else if (purchase.getPurchaseState() == Purchase.PurchaseState.PENDING) {
    //구매 유예
    Log.e(TAG, "//구매 유예");
}
else {
    //구매확정 취소됨(기타 다양한 사유...)
    Log.e(TAG, "//구매확정 취소됨(기타 다양한 사유...)");
}

코드공유

여기까지 구현된 소스는 github에서 참고해 보시기 바랍니다.

https://github.com/nari4169/daycnt415

GitHub - nari4169/daycnt415

Contribute to nari4169/daycnt415 development by creating an account on GitHub.

github.com

마치며

테스트는 앱을 내부테스트로 게시한 이후에 진행하세요. 꼭이요~~~

p.s 이글은 예전에 포스팅 했던 내용을 다시 수정해서 올렸습니다.

반응형