Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Omnisub 5499 - Upgrade/downgrade subscription #98

Open
wants to merge 27 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
79acc6c
feat: Added Change Product method in CBPurchase
cb-aravindradhakrishnan Jan 18, 2024
08d0006
feat: Added Change Product option
cb-aravindradhakrishnan Jan 18, 2024
942e453
feat: Created ChangeProduct params classwith oldProductId and prorati…
cb-aravindradhakrishnan Jan 24, 2024
94f2f6f
feat: Code refactored
cb-aravindradhakrishnan Jan 24, 2024
203dc88
feat: Return Valid errors
cb-aravindradhakrishnan Jan 24, 2024
e8cd2e6
feat: Added Code comments
cb-aravindradhakrishnan Jan 25, 2024
c6b1758
feat: Modified logs
cb-aravindradhakrishnan Jan 25, 2024
83b7702
feat: Replace empty string with empty optional
cb-aravindradhakrishnan Jan 27, 2024
e66769f
feat: Remove OTP callback on error
cb-aravindradhakrishnan Jan 27, 2024
08616b0
feat: Remove prorationMode from ChangeProductParams and set supported…
cb-aravindradhakrishnan Jan 29, 2024
484d8b9
Feat: Error handling
cb-aravindradhakrishnan Jan 29, 2024
27c85e6
feat: Rename changeProductParams props
cb-aravindradhakrishnan Jan 30, 2024
7b706ca
feat: Rename changeProductParams props
cb-aravindradhakrishnan Jan 30, 2024
56593af
feat: Proper error message
cb-aravindradhakrishnan Jan 30, 2024
62c3248
feat: Updated readme
cb-aravindradhakrishnan Jan 30, 2024
803326f
feat: Proper error message
cb-aravindradhakrishnan Jan 30, 2024
159809a
feat: Rename changeProductParams props
cb-aravindradhakrishnan Jan 30, 2024
7e0d6da
feat: Proper error message
cb-aravindradhakrishnan Jan 30, 2024
ecb90c4
feat: Reset currentProductId
cb-aravindradhakrishnan Jan 31, 2024
90f39e3
feat: Handled within subscription case
cb-aravindradhakrishnan Feb 5, 2024
2c98f6a
feat: Revert OTP callback on error
cb-aravindradhakrishnan Feb 6, 2024
86676e1
feat: Revert OTP callback on error
cb-aravindradhakrishnan Feb 6, 2024
f8b63b0
feat: Updated readme
cb-aravindradhakrishnan Feb 6, 2024
ba2d1a7
feat: Function name change
cb-aravindradhakrishnan Feb 6, 2024
e9c5422
feat: Updated readme
cb-aravindradhakrishnan Feb 6, 2024
418c231
feat: Add relevant error messages
cb-aravindradhakrishnan Feb 22, 2024
fb2ae76
feat: Update readme
cb-aravindradhakrishnan Feb 26, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions app/src/main/java/com/chargebee/example/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import com.chargebee.example.plan.PlansActivity
import com.chargebee.example.subscription.SubscriptionActivity
import com.chargebee.example.token.TokenizeActivity
import com.chargebee.example.util.CBMenu
import com.chargebee.example.util.Constants.OLD_PRODUCT_ID
import com.chargebee.example.util.Constants.PRODUCTS_LIST_KEY
import com.google.gson.Gson
import kotlinx.coroutines.CoroutineScope
Expand Down Expand Up @@ -133,6 +134,9 @@ class MainActivity : BaseActivity(), ListItemsAdapter.ItemClickListener {
CBMenu.GetProducts.value -> {
getProductIdFromCustomer()
}
CBMenu.ChangeProducts.value -> {
getOldAndNewProductIdFromCustomer()
}
CBMenu.SubsStatus.value,
CBMenu.SubsList.value -> {
val intent = Intent(this, SubscriptionActivity::class.java)
Expand All @@ -152,9 +156,10 @@ class MainActivity : BaseActivity(), ListItemsAdapter.ItemClickListener {
}
}

private fun launchProductDetailsScreen(productDetails: String) {
private fun launchProductDetailsScreen(productDetails: String, oldProductId: String? = null) {
val intent = Intent(this, BillingActivity::class.java)
intent.putExtra(PRODUCTS_LIST_KEY, productDetails)
intent.putExtra(OLD_PRODUCT_ID, oldProductId)
this.startActivity(intent)
}

Expand Down Expand Up @@ -206,15 +211,33 @@ class MainActivity : BaseActivity(), ListItemsAdapter.ItemClickListener {
dialog.show()
}

private fun getProductIdList(productIdList: ArrayList<String>) {
private fun getOldAndNewProductIdFromCustomer() {
val dialog = Dialog(this)
dialog.setContentView(R.layout.dialog_input_update_layout)
val productIds = dialog.findViewById<View>(R.id.productIdInput) as EditText
productIds.hint = "Please enter Product IDs(Comma separated)"
val oldProductId = dialog.findViewById<View>(R.id.oldProductIdInput) as EditText
oldProductId.hint = "Please enter old Product ID"
val dialogButton = dialog.findViewById<View>(R.id.btn_ok) as Button
dialogButton.text = "Submit"
dialogButton.setOnClickListener {
val productIdList = productIds.text.toString().trim().split(",")
val oldProductId = oldProductId.text.toString().trim()
getProductIdList(productIdList.toCollection(ArrayList()), oldProductId)
dialog.dismiss()
}
dialog.show()
}

private fun getProductIdList(productIdList: ArrayList<String>, oldProductId: String? = null) {
CBPurchase.retrieveProducts(
this,
productIdList,
object : CBCallback.ListProductsCallback<ArrayList<CBProduct>> {
override fun onSuccess(productIDs: ArrayList<CBProduct>) {
CoroutineScope(Dispatchers.Main).launch {
if (productIDs.size > 0) {
launchProductDetailsScreen(gson.toJson(productIDs))
launchProductDetailsScreen(gson.toJson(productIDs), oldProductId)
} else {
alertSuccess("Items not available to buy")
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.chargebee.example.billing;

import static com.chargebee.example.util.Constants.OLD_PRODUCT_ID;
import static com.chargebee.example.util.Constants.PRODUCTS_LIST_KEY;

import android.app.Dialog;
Expand All @@ -17,6 +18,7 @@
import com.chargebee.android.billingservice.OneTimeProductType;
import com.chargebee.android.billingservice.ProductType;
import com.chargebee.android.models.CBProduct;
import com.chargebee.android.models.ChangeProductParams;
import com.chargebee.android.models.PurchaseProductParams;
import com.chargebee.android.network.CBCustomer;
import com.chargebee.example.BaseActivity;
Expand All @@ -35,6 +37,7 @@
public class BillingActivity extends BaseActivity implements ProductListAdapter.ProductClickListener, ProgressBarListener {

private List<PurchaseProduct> purchaseProducts = null;
private String oldProductId = null;
private ProductListAdapter productListAdapter = null;
private LinearLayoutManager linearLayoutManager;
private RecyclerView mItemsRecyclerView = null;
Expand All @@ -53,6 +56,7 @@ protected void onCreate(Bundle savedInstanceState) {

mItemsRecyclerView = findViewById(R.id.rv_product_list);
String productDetails = getIntent().getStringExtra(PRODUCTS_LIST_KEY);
this.oldProductId = getIntent().getStringExtra(OLD_PRODUCT_ID);

if(productDetails != null) {
Gson gson = new Gson();
Expand Down Expand Up @@ -163,7 +167,11 @@ private void getCustomerID() {
dialog.dismiss();
}
} else {
purchaseProduct();
if (this.oldProductId != null){
changeProduct();
}else {
purchaseProduct();
}
dialog.dismiss();
}
});
Expand All @@ -189,6 +197,14 @@ private void purchaseProduct() {
this.billingViewModel.purchaseProduct(this, purchaseParams, cbCustomer);
}

private void changeProduct() {
showProgressDialog();
PurchaseProduct selectedPurchaseProduct = purchaseProducts.get(position);
PurchaseProductParams productParams = new PurchaseProductParams(selectedPurchaseProduct.getCbProduct(), selectedPurchaseProduct.getOfferToken());
ChangeProductParams changeProductParams = new ChangeProductParams(productParams, oldProductId, null);
this.billingViewModel.changeProduct(this, changeProductParams, cbCustomer);
}

private void purchaseNonSubscriptionProduct(OneTimeProductType productType) {
showProgressDialog();
CBProduct selectedProduct = purchaseProducts.get(position).getCbProduct();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,31 @@ class BillingViewModel : ViewModel() {
})
}

fun changeProduct(context: Context, changeProductParams: ChangeProductParams, customer: CBCustomer) {
// Cache the product id in sharedPreferences and retry validating the receipt if in case server is not responding or no internet connection.
sharedPreference = context.getSharedPreferences("PREFERENCE_NAME",Context.MODE_PRIVATE)
CBPurchase.changeProduct(changeProductParams = changeProductParams, customer = customer, object : CBCallback.PurchaseCallback<String>{
override fun onSuccess(result: ReceiptDetail, status:Boolean) {
Log.i(TAG, "Subscription ID: ${result.subscription_id}")
Log.i(TAG, "Plan ID: ${result.plan_id}")
productPurchaseResult.postValue(status)
}
override fun onError(error: CBException) {
try {
// Handled server not responding and offline
if (error.httpStatusCode!! in 500..599) {
storeInLocal(changeProductParams.purchaseProductParams.product.id)
validateReceipt(context = context, product = changeProductParams.purchaseProductParams.product)
} else {
cbException.postValue(error)
}
} catch (exp: Exception) {
Log.i(TAG, "Exception :${exp.message}")
}
}
})
}

private fun validateReceipt(context: Context, product: CBProduct) {
val customer = CBCustomer(
id = "sync_receipt_android",
Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/com/chargebee/example/util/CBMenu.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ enum class CBMenu(val value: String) {
Tokenize("Tokenize"),
ProductIDs("Get Google Play Product Identifiers"),
GetProducts("Get Products"),
ChangeProducts("Change Product"),
SubsStatus("Get Subscription Status"),
SubsList("Get Subscriptions List"),
GetEntitlements("Get Entitlements"),
Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/com/chargebee/example/util/Constants.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ package com.chargebee.example.util

object Constants {
const val PRODUCTS_LIST_KEY = "products"
const val OLD_PRODUCT_ID = "oldProductId"
}
66 changes: 66 additions & 0 deletions app/src/main/res/layout/dialog_input_update_layout.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="300dp"
android:layout_height="300dp"
tools:context=".MainActivity">

<com.google.android.material.textfield.TextInputLayout
android:id="@+id/planIdInputLayout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="96dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView">

<EditText
android:id="@+id/productIdInput"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ems="13"
android:inputType="textPersonName" />
</com.google.android.material.textfield.TextInputLayout>

<com.google.android.material.textfield.TextInputLayout
android:id="@+id/oldPlanIdInputLayout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.497"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView">

<EditText
android:id="@+id/oldProductIdInput"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ems="13"
android:inputType="textPersonName" />
</com.google.android.material.textfield.TextInputLayout>

<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="Chargebee"
android:textSize="24sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

<Button
android:id="@+id/btn_ok"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="34dp"
android:text="Submit"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/planIdInputLayout" />

</androidx.constraintlayout.widget.ConstraintLayout>
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import com.chargebee.android.exceptions.CBException
import com.chargebee.android.exceptions.ChargebeeResult
import com.chargebee.android.models.CBNonSubscriptionResponse
import com.chargebee.android.models.CBProduct
import com.chargebee.android.models.ChangeProductParams
import com.chargebee.android.models.PricingPhase
import com.chargebee.android.models.PurchaseProductParams
import com.chargebee.android.models.PurchaseTransaction
Expand All @@ -29,6 +30,7 @@ class BillingClientManager(context: Context) : PurchasesUpdatedListener {
private var purchaseCallBack: CBCallback.PurchaseCallback<String>? = null
private val TAG = javaClass.simpleName
private lateinit var purchaseProductParams: PurchaseProductParams
private lateinit var changeProductParams: ChangeProductParams
private lateinit var restorePurchaseCallBack: CBCallback.RestorePurchaseCallback
private var oneTimePurchaseCallback: CBCallback.OneTimePurchaseCallback? = null

Expand Down Expand Up @@ -253,6 +255,102 @@ class BillingClientManager(context: Context) : PurchasesUpdatedListener {

}

internal fun changeProduct(
changeProductParams: ChangeProductParams,
purchaseCallBack: CBCallback.PurchaseCallback<String>
) {
this.purchaseCallBack = purchaseCallBack
onConnected({ status ->
if (status) {
changeProduct(changeProductParams)
} else
purchaseCallBack.onError(
connectionError
)
}, { error ->
purchaseCallBack.onError(error)
})

}

private fun changeProduct(changeProductParams: ChangeProductParams) {
this.changeProductParams = changeProductParams
this.purchaseProductParams = changeProductParams.purchaseProductParams
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to set this?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes its used in acknowledgePurchase method

val offerToken = changeProductParams.purchaseProductParams.offerToken
val oldProductId = changeProductParams.oldProductId
val prorationMode = changeProductParams.prorationMode ?: BillingFlowParams.ProrationMode.IMMEDIATE_WITH_TIME_PRORATION
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's start with support for IMMEDIATE_WITH_TIME_PRORATION. We would need to build in support for other proration modes in the backend.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done


val queryProductDetails = arrayListOf(QueryProductDetailsParams.Product.newBuilder()
.setProductId(this.purchaseProductParams.product.id)
.setProductType(this.purchaseProductParams.product.type.value)
.build())

val productDetailsParams = QueryProductDetailsParams.newBuilder().setProductList(queryProductDetails).build()

billingClient?.queryProductDetailsAsync(
productDetailsParams
) { billingResult, productsDetail ->
if (billingResult.responseCode == OK && productsDetail != null) {
val productDetailsBuilder = BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(productsDetail.first())
offerToken?.let { productDetailsBuilder.setOfferToken(it) }
val productDetailsParamsList =
listOf(productDetailsBuilder.build())

queryAllSubsPurchaseHistory(ProductType.SUBS.value) { subscriptionHistory ->
val oldPurchaseToken: String = getOldPurchaseToken(subscriptionHistory, oldProductId)

val billingFlowParams =
BillingFlowParams.newBuilder()
.setProductDetailsParamsList(productDetailsParamsList)
.setSubscriptionUpdateParams(
BillingFlowParams.SubscriptionUpdateParams.newBuilder()
.setOldPurchaseToken(oldPurchaseToken)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would be the response if the oldPurchaseToken is empty/null?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's loading infinitely. Handled it now with an error message.

.setReplaceProrationMode(prorationMode).build()
).build()

billingClient?.launchBillingFlow(mContext as Activity, billingFlowParams)
.takeIf { billingResult ->
billingResult?.responseCode != OK
}?.let { billingResult ->
Log.e(TAG, "Failed to launch billing flow $billingResult")
val billingError = CBException(
ErrorDetail(
message = GPErrorCode.LaunchBillingFlowError.errorMsg,
httpStatusCode = billingResult.responseCode
)
)
if (ProductType.SUBS == this.purchaseProductParams.product.type) {
purchaseCallBack?.onError(
billingError
)
} else {
oneTimePurchaseCallback?.onError(
billingError
)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to handle for oneTimePurchaseCallback? Since OTP cannot be changed.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missed it!!... Removed now.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this removed yet?

}
}
} else {
Log.e(TAG, "Failed to fetch product :" + billingResult.responseCode)
if (ProductType.SUBS == this.purchaseProductParams.product.type) {
purchaseCallBack?.onError(throwCBException(billingResult))
} else {
oneTimePurchaseCallback?.onError(throwCBException(billingResult))
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to handle for oneTimePurchaseCallback? Since OTP cannot be changed.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missed it!!... Removed now.


}
}

}

private fun getOldPurchaseToken(subscriptionHistory: List<PurchaseTransaction>?, oldProductId: String): String {
val purchaseTransactionHistory = mutableListOf<PurchaseTransaction>()
purchaseTransactionHistory.addAll(subscriptionHistory ?: emptyList())
val prevProduct: PurchaseTransaction? = purchaseTransactionHistory.find { it.productId.first() == oldProductId }
return prevProduct?.purchaseToken ?: ""
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: return an empty optional instead of empty string

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

}

/**
* This method will provide all the purchases associated with the current account based on the [includeInActivePurchases] flag set.
* And the associated purchases will be synced with Chargebee.
Expand Down Expand Up @@ -333,9 +431,21 @@ class BillingClientManager(context: Context) : PurchasesUpdatedListener {

else -> {
if (purchaseProductParams.product.type == ProductType.SUBS)
purchaseCallBack?.onError(
throwCBException(billingResult)
)
if (this.changeProductParams.oldProductId.isNotEmpty() && billingResult.responseCode == ERROR) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where do we reset the changeProductParams variable?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

purchaseCallBack?.onError(
CBException(
ErrorDetail(
message = GPErrorCode.InvalidProductIdError.errorMsg,
httpStatusCode = billingResult.responseCode
)
)
)
}
else {
purchaseCallBack?.onError(
throwCBException(billingResult)
)
}
else
oneTimePurchaseCallback?.onError(
throwCBException(billingResult)
Expand Down
Loading