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

[DO NOT MERGE] Support Redeeming iOS Win-Back Offers on Developer Paywalls #291

Open
wants to merge 7 commits into
base: main
Choose a base branch
from

Conversation

fire-at-will
Copy link
Contributor

@fire-at-will fire-at-will commented Jan 8, 2025

Motivation

This PR makes it possible for developers to fetch win-backs that a subscriber is eligible for for a given product or package, and then purchase them from their custom paywalls.

PHC Dependency

This PR depends on RevenueCat/purchases-hybrid-common#1008, and should not be merged until that PR has been deployed and the new PHC version adopted in the KMP SDK. This branch will not build until that change has been taken in. Please review that PHC PR when reviewing this PR.

Description

New Interfaces

This PR introduces a new WinBackOffer interface, which contains a StoreDiscount:

public interface WinBackOffer {
    public val discount: StoreProductDiscount
}

New Public Functions

It also introduces four new functions, each of which are only implemented on iOS:

public fun getEligibleWinBackOffersForProduct(
    storeProduct: StoreProduct,
    onError: (error: PurchasesError) -> Unit,
    onSuccess: (List<WinBackOffer>) -> Unit,
)

public fun getEligibleWinBackOffersForPackage(
    packageToCheck: Package,
    onError: (error: PurchasesError) -> Unit,
    onSuccess: (List<WinBackOffer>) -> Unit,
)
    
public fun purchase(
    storeProduct: StoreProduct,
    winBackOffer: WinBackOffer,
    onError: (error: PurchasesError) -> Unit,
    onSuccess: (transaction: StoreTransaction, customerInfo: CustomerInfo) -> Unit,
)

public fun purchase(
    packageToPurchase: Package,
    winBackOffer: WinBackOffer,
    onError: (error: PurchasesError) -> Unit,
    onSuccess: (transaction: StoreTransaction, customerInfo: CustomerInfo) -> Unit,
)

The idea here is that a developer will first fetch the eligible win-back offers for a product/package with one of the getEligibleWinBackOffers functions, and display those offers on their paywall, and then redeem it with one of the new purchase functions. This mirrors the API design in the other hybrids and the native iOS SDK.

New DiscountType Cases

Adds a new WINBACK case to the DiscountType enum

Purchase Tester Additions

This PR adds a new screen to test the various win-back functions, accessible from a new button on the main screen.

@@ -446,4 +605,12 @@ public actual class Purchases private constructor(private val iosPurchases: IosP

public actual fun setCreative(creative: String?): Unit =
iosPurchases.setCreative(creative)

private fun isIOSVersion18OrAbove(): Boolean {
return currentOSVersion() > "18.0.0"
Copy link
Contributor Author

@fire-at-will fire-at-will Jan 8, 2025

Choose a reason for hiding this comment

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

It's necessary for us to check the OS version here. Despite the native functions being marked as available on iOS 18.0+, macOS 15.0+, tvOS 18.0+, watchOS 11.0+, and visionOS 2.0+, KMP allows us to call these functions regardless of the OS versions 🤠

Other hybrids skirt around this issue in two ways:

  • They perform an availability check in their Obj-C/Swift wrappers
  • PHC also performs an availability check

Since we're calling the native functions here directly from Kotlin, we can't take advantage of those mechanisms. This technically works on iOS, but it's not great for a few reasons:

  • It depends on the UIDevice class, which isn't available in all OS's
  • It's only checking for the iOS version, ignoring the OS requirements for other OS's.

Do you happen to know of a better way to handle this @JayShortway?

Copy link
Member

Choose a reason for hiding this comment

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

As of now, our KMP SDK only supports iOS. (See our configured compilation targets here.) So I think that avoids those downsides?

(I would like the KMP SDK to support more Apple targets in the future. When we do, we can use expect/actual to make sure this works on all Apple targets. There's #126 to track it.)

The comparison might be a bit brittle, as it's comparing strings, but I think it should work (Unlikely, but if currentOSVersion ever prefixes a 0, it won't work anymore.)

Although shouldn't it be this?

Suggested change
return currentOSVersion() > "18.0.0"
return currentOSVersion() >= "18.0.0"

@@ -0,0 +1,164 @@
{
"appPolicies" : {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Adding the SKConfig file allows us to test win-back offers on products :)

Copy link
Member

@JayShortway JayShortway left a comment

Choose a reason for hiding this comment

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

Really nice! Adding this to the tester app is amazing! Just had some minor comments.

Comment on lines +303 to +319
onError = { error -> },
onSuccess = { products ->
val product = products.first()

// Fetch the eligible win-back offers for the product
Purchases.sharedInstance.getEligibleWinBackOffersForProduct(
storeProduct = product,
onError = { error -> },
onSuccess = { eligibleWinBackOffers ->
val winBackOffer = eligibleWinBackOffers.first()

// Purchase the product with the win-back offer
Purchases.sharedInstance.purchase(
storeProduct = product,
winBackOffer = winBackOffer,
onError = { error -> },
onSuccess = { transaction, customerInfo -> }
Copy link
Member

Choose a reason for hiding this comment

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

In other SDKs we should define the types here instead of relying on type inference, as type inference won't catch it if we accidentally change the type. It is not strictly necessary in KMP because it uses the binary-compatibility-validator to enforce this. But maybe it's a good idea to do it for consistency?

I'm talking about doing things like this:

-            onError = { error -> },
-            onSuccess = { products ->
-                val product = products.first()
+            onError = { error: PurchasesError -> },
+            onSuccess = { products: List<StoreProduct> ->
+                val product: StoreProduct = products.first()
// etc.

This would also apply to checkRedeemingWinBackOffersForPackage() below.

@@ -67,6 +69,8 @@ fun App() {
)
}
}

is Screen.WinBackTesting -> WinBackTestingScreen()
Copy link
Member

Choose a reason for hiding this comment

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

Love this!

@@ -53,6 +53,7 @@ import kotlinx.coroutines.flow.onStart
@Composable
fun MainScreen(
onShowPaywallClick: (offering: Offering?, footer: Boolean) -> Unit,
setScreen: (Screen) -> Unit,
Copy link
Member

Choose a reason for hiding this comment

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

Naming suggestion: navigateTo.

@@ -53,6 +53,7 @@ import kotlinx.coroutines.flow.onStart
@Composable
fun MainScreen(
onShowPaywallClick: (offering: Offering?, footer: Boolean) -> Unit,
Copy link
Member

Choose a reason for hiding this comment

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

Would be nice to remove this one and just use setScreen/navigateTo for the paywall too. I think that should be possible?

Comment on lines +24 to +26
Text("Use this screen to fetch eligible win-back offers, purchase products without a win-back offer, and purchase products with an eligible win-back offer.")

Text("This test relies on products and offers defined in the SKConfig file, so be sure to launch the PurchaseTester app from Xcode with the SKConfig file configured.")
Copy link
Member

Choose a reason for hiding this comment

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

This is great! I love a tester app that explains on-screen how it should be used 😗👌

@@ -446,4 +605,12 @@ public actual class Purchases private constructor(private val iosPurchases: IosP

public actual fun setCreative(creative: String?): Unit =
iosPurchases.setCreative(creative)

private fun isIOSVersion18OrAbove(): Boolean {
return currentOSVersion() > "18.0.0"
Copy link
Member

Choose a reason for hiding this comment

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

As of now, our KMP SDK only supports iOS. (See our configured compilation targets here.) So I think that avoids those downsides?

(I would like the KMP SDK to support more Apple targets in the future. When we do, we can use expect/actual to make sure this works on all Apple targets. There's #126 to track it.)

The comparison might be a bit brittle, as it's comparing strings, but I think it should work (Unlikely, but if currentOSVersion ever prefixes a 0, it won't work anymore.)

Although shouldn't it be this?

Suggested change
return currentOSVersion() > "18.0.0"
return currentOSVersion() >= "18.0.0"

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants