Skip to content

Commit

Permalink
chore: add missing course properties in IAP restore & unfulfilled eve…
Browse files Browse the repository at this point in the history
…nts (#82)

* chore: add missing course properties in IAP unfulfilled event

* chore: add missing course properties in IAP restore purchases event

fixes: LEARNER-10309
  • Loading branch information
farhan-arshad-dev committed Jan 29, 2025
1 parent edeedcd commit aef1a27
Show file tree
Hide file tree
Showing 16 changed files with 219 additions and 61 deletions.
1 change: 1 addition & 0 deletions app/src/main/java/org/openedx/app/di/ScreenModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ val screenModule = module {
get(),
get(),
get(),
get(),
windowSize,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,18 @@ import org.openedx.core.R
import org.openedx.core.config.Config
import org.openedx.core.data.repository.iap.IAPRepository
import org.openedx.core.data.storage.CorePreferences
import org.openedx.core.domain.model.EnrolledCourse
import org.openedx.core.domain.model.iap.ProductInfo
import org.openedx.core.domain.model.iap.PurchaseFlowData
import org.openedx.core.exception.iap.IAPException
import org.openedx.core.extension.decodeToLong
import org.openedx.core.module.billing.BillingProcessor
import org.openedx.core.module.billing.getCourseSku
import org.openedx.core.module.billing.getPriceAmount
import org.openedx.core.module.billing.getUserId
import org.openedx.core.presentation.global.AppData
import org.openedx.core.presentation.iap.IAPRequestType
import org.openedx.core.utils.EmailUtil
import org.openedx.core.utils.TimeUtils

class IAPInteractor(
private val appData: AppData,
Expand Down Expand Up @@ -109,52 +112,83 @@ class IAPInteractor(
}
}

suspend fun processUnfulfilledPurchase(userId: Long): Boolean {
suspend fun processUnfulfilledPurchase(
userId: Long,
enrolledCourses: List<EnrolledCourse>,
verificationInitiated: (PurchaseFlowData) -> Unit = {},
): PurchaseFlowData? {
val purchases = billingProcessor.queryPurchases()
val userPurchases =
purchases.filter { it.accountIdentifiers?.obfuscatedAccountId?.decodeToLong() == userId }
val userPurchases = purchases.filter { purchase ->
val userAccountId = purchase.getUserId()
val courseSku = purchase.getCourseSku()

userAccountId == userId && enrolledCourses.any { enrolledCourse ->
courseSku == enrolledCourse.productInfo?.courseSku
}
}
if (userPurchases.isNotEmpty()) {
startUnfulfilledVerification(userPurchases)
return true
userPurchases.first().let { purchase ->
val courseVerified = enrolledCourses.find { enrolledCourse ->
enrolledCourse.productInfo?.courseSku == purchase.getCourseSku()
}
courseVerified?.let {
val productDetails =
billingProcessor.querySyncDetails(purchase.products[0]).productDetailsList?.firstOrNull()
val purchaseProductFlow = PurchaseFlowData(
courseId = courseVerified.course.id,
isSelfPaced = courseVerified.course.isSelfPaced,
productInfo = courseVerified.productInfo
).apply {
productDetails?.oneTimePurchaseOfferDetails?.let {
this.price = it.getPriceAmount()
this.currencyCode = it.priceCurrencyCode
}
this.flowStartTime = TimeUtils.getCurrentTime()
}
verificationInitiated(purchaseProductFlow)
startUnfulfilledVerification(purchase)
return purchaseProductFlow
}
}
} else {
purchases.forEach {
billingProcessor.consumePurchase(it.purchaseToken)
}
}
return false
return null
}

private suspend fun startUnfulfilledVerification(userPurchases: List<Purchase>) {
userPurchases.forEach { purchase ->
val productDetail =
billingProcessor.querySyncDetails(purchase.products.first()).productDetailsList?.firstOrNull()
productDetail?.oneTimePurchaseOfferDetails?.takeIf {
purchase.getCourseSku().isNullOrEmpty().not()
}?.let { oneTimeProductDetails ->
val courseSku = purchase.getCourseSku() ?: return@let
val basketId = addToBasket(courseSku)
executeOrder(
basketId = basketId,
purchaseToken = purchase.purchaseToken,
price = oneTimeProductDetails.getPriceAmount(),
currencyCode = oneTimeProductDetails.priceCurrencyCode,
)
consumePurchase(purchase.purchaseToken)
}
private suspend fun startUnfulfilledVerification(userPurchase: Purchase) {
val productDetail =
billingProcessor.querySyncDetails(userPurchase.products.first()).productDetailsList?.firstOrNull()
productDetail?.oneTimePurchaseOfferDetails?.takeIf {
userPurchase.getCourseSku().isNullOrEmpty().not()
}?.let { oneTimeProductDetails ->
val courseSku = userPurchase.getCourseSku() ?: return@let
val basketId = addToBasket(courseSku)
executeOrder(
basketId = basketId,
purchaseToken = userPurchase.purchaseToken,
price = oneTimeProductDetails.getPriceAmount(),
currencyCode = oneTimeProductDetails.priceCurrencyCode,
)
consumePurchase(userPurchase.purchaseToken)
}
}

suspend fun detectUnfulfilledPurchase(
onSuccess: () -> Unit,
enrolledCourses: List<EnrolledCourse>,
verificationInitiated: (PurchaseFlowData) -> Unit,
onSuccess: (PurchaseFlowData) -> Unit,
onFailure: (IAPException) -> Unit,
) {
if (isIAPEnabled) {
preferencesManager.user?.id?.let { userId ->
runCatching {
processUnfulfilledPurchase(userId)
}.onSuccess {
if (it) {
onSuccess()
processUnfulfilledPurchase(userId, enrolledCourses, verificationInitiated)
}.onSuccess { purchaseFlowData ->
purchaseFlowData?.let {
onSuccess(purchaseFlowData)
}
}.onFailure {
if (it is IAPException) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,8 @@ data class CourseEnrollments(
val enrollments: DashboardCourseList,
val configs: AppConfig,
val primary: EnrolledCourse?,
)
) {
fun hasEnrolledCourses(): Boolean {
return primary != null || enrollments.courses.isNotEmpty()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,20 @@ data class PurchaseFlowData(
basketId = -1
flowStartTime = 0
}

fun isSilentIAPFlow(): Boolean? {
return when (iapFlow) {
IAPFlow.SILENT -> {
true
}
IAPFlow.RESTORE -> {
false
}
else -> {
null
}
}
}
}

enum class IAPFlow(val value: String) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import org.openedx.core.domain.model.iap.ProductInfo
import org.openedx.core.extension.decodeToLong
import org.openedx.core.extension.decodeToString
import org.openedx.core.extension.encodeToString
import org.openedx.core.extension.safeResume
Expand Down Expand Up @@ -203,3 +204,7 @@ fun ProductDetails.OneTimePurchaseOfferDetails.getPriceAmount(): Double =
fun Purchase.getCourseSku(): String? {
return this.accountIdentifiers?.obfuscatedProfileId?.decodeToString()
}

fun Purchase.getUserId(): Long? {
return this.accountIdentifiers?.obfuscatedAccountId?.decodeToLong()
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import org.openedx.core.utils.TimeUtils

class IAPEventLogger(
private val analytics: IAPAnalytics,
private val purchaseFlowData: PurchaseFlowData? = null,
private val isSilentIAPFlow: Boolean? = null,
var isSilentIAPFlow: Boolean? = null,
var purchaseFlowData: PurchaseFlowData? = null,
) {
fun upgradeNowClickedEvent() {
logIAPEvent(IAPAnalyticsEvent.IAP_UPGRADE_NOW_CLICKED)
Expand Down Expand Up @@ -64,7 +64,7 @@ class IAPEventLogger(

IAPRequestType.PRICE_CODE,
IAPRequestType.NO_SKU_CODE,
-> {
-> {
priceLoadErrorEvent(feedbackErrorMessage)
}

Expand Down Expand Up @@ -186,7 +186,7 @@ class IAPEventLogger(
putAll(params)
putAll(getIAPEventParams())
putAll(getUnfulfilledIAPEventParams())
},
}
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import org.openedx.core.domain.model.iap.IAPFlow
import org.openedx.core.domain.model.iap.IAPFlowSource
import org.openedx.core.domain.model.iap.PurchaseFlowData
import org.openedx.core.exception.iap.IAPException
import org.openedx.core.extension.isNull
import org.openedx.core.module.billing.BillingProcessor
import org.openedx.core.module.billing.getCourseSku
import org.openedx.core.module.billing.getPriceAmount
Expand Down Expand Up @@ -55,6 +56,7 @@ class IAPViewModel(

val eventLogger = IAPEventLogger(
analytics = analytics,
isSilentIAPFlow = purchaseData.isSilentIAPFlow(),
purchaseFlowData = purchaseData
)

Expand Down Expand Up @@ -84,7 +86,9 @@ class IAPViewModel(
iapNotifier.notifier.onEach { event ->
when (event) {
is CourseDataUpdated -> {
eventLogger.upgradeSuccessEvent()
if (eventLogger.isSilentIAPFlow.isNull()) {
eventLogger.upgradeSuccessEvent()
}
_uiMessage.emit(UIMessage.ToastMessage(resourceManager.getString(R.string.iap_success_message)))
_uiState.value = IAPUIState.CourseDataUpdated
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
package org.openedx.core.system.notifier.app

import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow

class AppNotifier {

private val channel = MutableSharedFlow<AppEvent>(replay = 0, extraBufferCapacity = 0)
private val channel = MutableSharedFlow<AppEvent>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)

val notifier: Flow<AppEvent> = channel.asSharedFlow()

Expand All @@ -16,4 +21,7 @@ class AppNotifier {

suspend fun send(event: AppUpgradeEvent) = channel.emit(event)

suspend fun send(event: EnrolledCourseEvent) = channel.emit(event)

suspend fun send(event: RequestEnrolledCourseEvent) = channel.emit(event)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package org.openedx.core.system.notifier.app

import org.openedx.core.domain.model.EnrolledCourse

class EnrolledCourseEvent(val enrolledCourses: List<EnrolledCourse>,) : AppEvent
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package org.openedx.core.system.notifier.app

object RequestEnrolledCourseEvent : AppEvent
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import org.openedx.core.domain.model.iap.PurchaseFlowData
import org.openedx.core.exception.iap.IAPException
import org.openedx.core.extension.isFalse
import org.openedx.core.extension.isNotNull
import org.openedx.core.extension.isNull
import org.openedx.core.extension.isTrue
import org.openedx.core.module.billing.BillingProcessor
import org.openedx.core.module.billing.getCourseSku
Expand Down Expand Up @@ -286,10 +287,14 @@ class CourseContainerViewModel(
}
if (isIAPFlow) {
if (isExpiredCoursePurchase) {
eventLogger.upgradeSuccessEvent()
if (eventLogger.isSilentIAPFlow.isNull()) {
eventLogger.upgradeSuccessEvent()
}
_uiMessage.emit(
UIMessage.ToastMessage(
resourceManager.getString(CoreR.string.iap_success_message)
resourceManager.getString(
CoreR.string.iap_success_message
)
)
)
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -262,8 +262,8 @@ private fun DashboardGalleryView(
},
onIAPAction = onIAPAction,
)
LaunchedEffect(uiState.userCourses.enrollments.courses) {
if (uiState.userCourses.enrollments.courses.isNotEmpty()) {
LaunchedEffect(uiState.userCourses.hasEnrolledCourses()) {
if (uiState.userCourses.hasEnrolledCourses()) {
onIAPAction(IAPAction.ACTION_UNFULFILLED, null, null)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,12 @@ import org.openedx.core.system.notifier.DiscoveryNotifier
import org.openedx.core.system.notifier.IAPNotifier
import org.openedx.core.system.notifier.NavigationToDiscovery
import org.openedx.core.system.notifier.UpdateCourseData
import org.openedx.core.system.notifier.app.AppNotifier
import org.openedx.core.system.notifier.app.EnrolledCourseEvent
import org.openedx.core.system.notifier.app.RequestEnrolledCourseEvent
import org.openedx.core.ui.WindowSize
import org.openedx.core.utils.FileUtil
import org.openedx.dashboard.domain.CourseStatusFilter
import org.openedx.dashboard.domain.interactor.DashboardInteractor
import org.openedx.dashboard.presentation.DashboardRouter

Expand All @@ -57,6 +61,7 @@ class DashboardGalleryViewModel(
private val fileUtil: FileUtil,
private val dashboardRouter: DashboardRouter,
private val iapNotifier: IAPNotifier,
private val appNotifier: AppNotifier,
private val iapInteractor: IAPInteractor,
private val iapAnalytics: IAPAnalytics,
private val windowSize: WindowSize,
Expand All @@ -73,7 +78,7 @@ class DashboardGalleryViewModel(
val uiMessage: SharedFlow<UIMessage?>
get() = _uiMessage.asSharedFlow()

private val _updating = MutableStateFlow<Boolean>(false)
private val _updating = MutableStateFlow(false)
val updating: StateFlow<Boolean>
get() = _updating.asStateFlow()

Expand All @@ -93,11 +98,25 @@ class DashboardGalleryViewModel(
private var isLoading = false

init {
collectAppEvent()
collectDiscoveryNotifier()
collectIapNotifier()
getCourses()
}

private fun collectAppEvent() {
appNotifier.notifier
.onEach {
if (it is RequestEnrolledCourseEvent) {
val enrolledCourses =
interactor.getAllUserCourses(status = CourseStatusFilter.ALL).courses
appNotifier.send(EnrolledCourseEvent(enrolledCourses))
}
}
.distinctUntilChanged()
.launchIn(viewModelScope)
}

fun getCourses(isIAPFlow: Boolean = false) {
viewModelScope.launch {
try {
Expand Down Expand Up @@ -251,9 +270,21 @@ class DashboardGalleryViewModel(

private fun detectUnfulfilledPurchase() {
viewModelScope.launch(Dispatchers.IO) {
val enrolledCourses =
interactor.getAllUserCourses(status = CourseStatusFilter.ALL).courses
iapInteractor.detectUnfulfilledPurchase(
onSuccess = {
eventLogger.logUnfulfilledPurchaseInitiatedEvent()
enrolledCourses = enrolledCourses,
verificationInitiated = { purchaseFlowData ->
eventLogger.apply {
this.purchaseFlowData = purchaseFlowData
this.logUnfulfilledPurchaseInitiatedEvent()
}
},
onSuccess = { purchaseFlowData ->
eventLogger.apply {
this.purchaseFlowData = purchaseFlowData
this.upgradeSuccessEvent()
}
_iapUiState.tryEmit(IAPUIState.PurchasesFulfillmentCompleted)
},
onFailure = {
Expand Down
Loading

0 comments on commit aef1a27

Please sign in to comment.