이전 포스팅
이전에 작성했던 포스팅을 참고하여 인앱 결제를 구현했던 기억을 되살펴 보겠습니다.
https://billcorea.tistory.com/27
이전 포스팅에서는 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
이것으로 인앱 결제 정기결제에 대한 이해를 해 보았습니다.
정기구독의 경우 구독취소가 되지 않도록 하기 위해서는 구매한 것을 확인해 주는 처리를 꼭 거쳐야 합니다. 그렇지 않을 경우에는 구독했던 것이 취소되어 소모가 되지 않기 때문입니다.
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
마치며
테스트는 앱을 내부테스트로 게시한 이후에 진행하세요. 꼭이요~~~
p.s 이글은 예전에 포스팅 했던 내용을 다시 수정해서 올렸습니다.
'모바일 앱(안드로이드)' 카테고리의 다른 글
개발일기 #? 쉬어가는 페이지... Figma 와 Android Studio 의 UI 연동 이란... (0) | 2022.10.27 |
---|---|
개발일기 #6 메뉴판 구성을 위해서 TabLayout 만들어 보기 (0) | 2022.10.26 |
개발일기 #5-1 연동과 관련된 HTTP 통신은 어떻게 ? (PAYAPP API 연동) (0) | 2022.10.21 |
개발일기 #5 PAYAPP 연동을 위한 준비 (0) | 2022.10.20 |
개발일기 #4 dialog box 쉽게 만들어 보기 (2) | 2022.10.12 |