diff --git a/apiTester/src/androidMain/kotlin/com/revenuecat/purchases/kmp/apitester/PurchasesErrorCodeAPI.kt b/apiTester/src/androidMain/kotlin/com/revenuecat/purchases/kmp/apitester/PurchasesErrorCodeAPI.kt
index c5d1b247..66ee9454 100644
--- a/apiTester/src/androidMain/kotlin/com/revenuecat/purchases/kmp/apitester/PurchasesErrorCodeAPI.kt
+++ b/apiTester/src/androidMain/kotlin/com/revenuecat/purchases/kmp/apitester/PurchasesErrorCodeAPI.kt
@@ -42,7 +42,10 @@ private class PurchasesErrorCodeAPI {
PurchasesErrorCode.InvalidPromotionalOfferError,
PurchasesErrorCode.OfflineConnectionError,
PurchasesErrorCode.SignatureVerificationError,
- PurchasesErrorCode.FeatureNotAvailableInCustomEntitlementsComputationMode -> {
+ PurchasesErrorCode.FeatureNotAvailableInCustomEntitlementsComputationMode,
+ PurchasesErrorCode.InvalidWebPurchaseToken,
+ PurchasesErrorCode.PurchaseBelongsToOtherUser,
+ PurchasesErrorCode.ExpiredWebPurchaseToken -> {
}
}.exhaustive
}
diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml
index f0692533..17e2f1f8 100644
--- a/composeApp/src/androidMain/AndroidManifest.xml
+++ b/composeApp/src/androidMain/AndroidManifest.xml
@@ -19,6 +19,13 @@
+
+
+
+
+
+
+
diff --git a/composeApp/src/androidMain/kotlin/com/revenuecat/purchases/kmp/sample/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/revenuecat/purchases/kmp/sample/MainActivity.kt
index 7ddc8892..4edb19c8 100644
--- a/composeApp/src/androidMain/kotlin/com/revenuecat/purchases/kmp/sample/MainActivity.kt
+++ b/composeApp/src/androidMain/kotlin/com/revenuecat/purchases/kmp/sample/MainActivity.kt
@@ -1,23 +1,35 @@
package com.revenuecat.purchases.kmp.sample
+import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
import org.jetbrains.compose.ui.tooling.preview.Preview
class MainActivity : ComponentActivity() {
+ private var urlString by mutableStateOf(null)
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ urlString = intent.dataString
setContent {
- App()
+ App(urlString = urlString, urlProcessed = { urlString = null })
}
}
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ urlString = intent.dataString
+ }
}
@Preview
@Composable
fun AppAndroidPreview() {
- App()
+ App(urlString = null, urlProcessed = {})
}
diff --git a/composeApp/src/commonMain/kotlin/com/revenuecat/purchases/kmp/sample/App.kt b/composeApp/src/commonMain/kotlin/com/revenuecat/purchases/kmp/sample/App.kt
index 7df6d1ac..0d917d15 100644
--- a/composeApp/src/commonMain/kotlin/com/revenuecat/purchases/kmp/sample/App.kt
+++ b/composeApp/src/commonMain/kotlin/com/revenuecat/purchases/kmp/sample/App.kt
@@ -3,8 +3,11 @@ package com.revenuecat.purchases.kmp.sample
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.material.AlertDialog
import androidx.compose.material.Button
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
@@ -17,13 +20,19 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.revenuecat.purchases.kmp.Purchases
+import com.revenuecat.purchases.kmp.models.RedeemWebPurchaseListener
import com.revenuecat.purchases.kmp.ui.revenuecatui.Paywall
import com.revenuecat.purchases.kmp.ui.revenuecatui.PaywallFooter
import com.revenuecat.purchases.kmp.ui.revenuecatui.PaywallOptions
@Composable
-fun App() {
+fun App(urlString: String?, urlProcessed: () -> Unit) {
MaterialTheme {
+ if (urlString != null) {
+ ProcessDeepLink(urlString, urlProcessed)
+ }
Column(
modifier = Modifier
.fillMaxSize(),
@@ -92,3 +101,47 @@ private fun CustomPaywallContent(
}
}
}
+
+@Composable
+private fun ProcessDeepLink(urlString: String, urlProcessed: () -> Unit) {
+ var alertMessage by remember { mutableStateOf(null) }
+
+ val webPurchaseRedemption = Purchases.parseAsWebPurchaseRedemption(urlString)
+ if (webPurchaseRedemption != null && Purchases.isConfigured) {
+ Purchases.sharedInstance.redeemWebPurchase(webPurchaseRedemption) { result ->
+ alertMessage = when (result) {
+ is RedeemWebPurchaseListener.Result.Error ->
+ "Error redeeming web purchase: ${result.error.message}"
+ is RedeemWebPurchaseListener.Result.Expired ->
+ "Web purchase redemption token expired. Email sent to: ${result.obfuscatedEmail}"
+ RedeemWebPurchaseListener.Result.InvalidToken ->
+ "Invalid web purchase redemption token"
+ RedeemWebPurchaseListener.Result.PurchaseBelongsToOtherUser ->
+ "Web purchase belongs to another user"
+ is RedeemWebPurchaseListener.Result.Success ->
+ "Web purchase redeemed successfully. Entitlements: ${result.customerInfo.entitlements.active}"
+ }
+ }
+ }
+
+ alertMessage?.let {
+ AlertDialog(
+ onDismissRequest = { urlProcessed() },
+ title = { Text("Web Purchase Redemption result") },
+ text = { Text(it) },
+ buttons = {
+ Row(
+ modifier = Modifier. padding(all = 8.dp),
+ horizontalArrangement = Arrangement. Center
+ ) {
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ onClick = { urlProcessed() }
+ ) {
+ Text("Dismiss")
+ }
+ }
+ }
+ )
+ }
+}
diff --git a/core/src/androidMain/kotlin/com/revenuecat/purchases/kmp/Purchases.android.kt b/core/src/androidMain/kotlin/com/revenuecat/purchases/kmp/Purchases.android.kt
index 8f37feb4..8925ffce 100644
--- a/core/src/androidMain/kotlin/com/revenuecat/purchases/kmp/Purchases.android.kt
+++ b/core/src/androidMain/kotlin/com/revenuecat/purchases/kmp/Purchases.android.kt
@@ -1,10 +1,14 @@
package com.revenuecat.purchases.kmp
+import android.content.Intent
+import android.net.Uri
+import com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI
import com.revenuecat.purchases.PurchaseParams
import com.revenuecat.purchases.common.PlatformInfo
import com.revenuecat.purchases.getCustomerInfoWith
import com.revenuecat.purchases.getOfferingsWith
import com.revenuecat.purchases.getProductsWith
+import com.revenuecat.purchases.hybridcommon.isWebPurchaseRedemptionURL
import com.revenuecat.purchases.kmp.di.AndroidProvider
import com.revenuecat.purchases.kmp.di.requireActivity
import com.revenuecat.purchases.kmp.di.requireApplication
@@ -22,6 +26,7 @@ import com.revenuecat.purchases.kmp.mappings.toPurchasesError
import com.revenuecat.purchases.kmp.mappings.toStore
import com.revenuecat.purchases.kmp.mappings.toStoreProduct
import com.revenuecat.purchases.kmp.mappings.toStoreTransaction
+import com.revenuecat.purchases.kmp.mappings.toWebPurchaseResult
import com.revenuecat.purchases.kmp.models.BillingFeature
import com.revenuecat.purchases.kmp.models.CacheFetchPolicy
import com.revenuecat.purchases.kmp.models.CustomerInfo
@@ -32,6 +37,7 @@ import com.revenuecat.purchases.kmp.models.Package
import com.revenuecat.purchases.kmp.models.PromotionalOffer
import com.revenuecat.purchases.kmp.models.PurchasesError
import com.revenuecat.purchases.kmp.models.PurchasesErrorCode
+import com.revenuecat.purchases.kmp.models.RedeemWebPurchaseListener
import com.revenuecat.purchases.kmp.models.ReplacementMode
import com.revenuecat.purchases.kmp.models.Store
import com.revenuecat.purchases.kmp.models.StoreMessageType
@@ -39,6 +45,7 @@ import com.revenuecat.purchases.kmp.models.StoreProduct
import com.revenuecat.purchases.kmp.models.StoreProductDiscount
import com.revenuecat.purchases.kmp.models.StoreTransaction
import com.revenuecat.purchases.kmp.models.SubscriptionOption
+import com.revenuecat.purchases.kmp.models.WebPurchaseRedemption
import com.revenuecat.purchases.kmp.strings.ConfigureStrings
import com.revenuecat.purchases.logInWith
import com.revenuecat.purchases.logOutWith
@@ -128,6 +135,14 @@ public actual class Purchases private constructor(private val androidPurchases:
private fun DangerousSettings.toAndroidDangerousSettings(): AndroidDangerousSettings =
AndroidDangerousSettings(autoSyncPurchases)
+
+ public actual fun parseAsWebPurchaseRedemption(url: String): WebPurchaseRedemption? {
+ return if (isWebPurchaseRedemptionURL(url)) {
+ WebPurchaseRedemption(url)
+ } else {
+ null
+ }
+ }
}
public actual val appUserID: String by androidPurchases::appUserID
@@ -417,6 +432,38 @@ public actual class Purchases private constructor(private val androidPurchases:
public actual fun setCreative(creative: String?): Unit =
androidPurchases.setCreative(creative)
+ @OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class)
+ public actual fun redeemWebPurchase(
+ webPurchaseRedemption: WebPurchaseRedemption,
+ listener: RedeemWebPurchaseListener
+ ) {
+ val respondInvalidUrlError = {
+ listener.handleResult(
+ RedeemWebPurchaseListener.Result.Error(
+ PurchasesError(
+ PurchasesErrorCode.ConfigurationError,
+ "Invalid URL: ${webPurchaseRedemption.redemptionUrl}"
+ )
+ ))
+ }
+ val nativeWebPurchaseRedemption = try {
+ // Replace this with parseAsWebPurchaseRedemption overload
+ // accepting strings once it's available.
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(webPurchaseRedemption.redemptionUrl))
+ AndroidPurchases.parseAsWebPurchaseRedemption(intent)
+ } catch (@Suppress("TooGenericExceptionCaught") e: Throwable) {
+ respondInvalidUrlError()
+ return
+ }
+ if (nativeWebPurchaseRedemption == null) {
+ respondInvalidUrlError()
+ return
+ }
+ androidPurchases.redeemWebPurchase(nativeWebPurchaseRedemption) { result ->
+ listener.handleResult(result.toWebPurchaseResult())
+ }
+ }
+
private fun StoreMessageType.toInAppMessageTypeOrNull(): InAppMessageType? =
when (this) {
StoreMessageType.BILLING_ISSUES -> InAppMessageType.BILLING_ISSUES
diff --git a/core/src/commonMain/kotlin/com/revenuecat/purchases/kmp/Purchases.kt b/core/src/commonMain/kotlin/com/revenuecat/purchases/kmp/Purchases.kt
index 66459d99..feb5cc99 100644
--- a/core/src/commonMain/kotlin/com/revenuecat/purchases/kmp/Purchases.kt
+++ b/core/src/commonMain/kotlin/com/revenuecat/purchases/kmp/Purchases.kt
@@ -8,6 +8,7 @@ import com.revenuecat.purchases.kmp.models.Offerings
import com.revenuecat.purchases.kmp.models.Package
import com.revenuecat.purchases.kmp.models.PromotionalOffer
import com.revenuecat.purchases.kmp.models.PurchasesError
+import com.revenuecat.purchases.kmp.models.RedeemWebPurchaseListener
import com.revenuecat.purchases.kmp.models.ReplacementMode
import com.revenuecat.purchases.kmp.models.Store
import com.revenuecat.purchases.kmp.models.StoreMessageType
@@ -15,6 +16,7 @@ import com.revenuecat.purchases.kmp.models.StoreProduct
import com.revenuecat.purchases.kmp.models.StoreProductDiscount
import com.revenuecat.purchases.kmp.models.StoreTransaction
import com.revenuecat.purchases.kmp.models.SubscriptionOption
+import com.revenuecat.purchases.kmp.models.WebPurchaseRedemption
import kotlin.jvm.JvmSynthetic
/**
@@ -97,6 +99,13 @@ public expect class Purchases {
features: List = listOf(),
callback: (Boolean) -> Unit,
)
+
+ /**
+ * Given a url string, parses the link and returns a [WebPurchaseRedemption], which can
+ * be used to redeem a web purchase using [Purchases.redeemWebPurchase]
+ * @return A parsed version of the link or null if it's not a valid RevenueCat web purchase redemption link.
+ */
+ public fun parseAsWebPurchaseRedemption(url: String): WebPurchaseRedemption?
}
/**
@@ -650,6 +659,14 @@ public expect class Purchases {
*/
public fun setCreative(creative: String?)
+ /**
+ * Redeem a web purchase using a [WebPurchaseRedemption] object obtained
+ * through [Purchases.parseAsWebPurchaseRedemption].
+ */
+ public fun redeemWebPurchase(
+ webPurchaseRedemption: WebPurchaseRedemption,
+ listener: RedeemWebPurchaseListener,
+ )
}
/**
diff --git a/core/src/iosMain/kotlin/com/revenuecat/purchases/kmp/Purchases.ios.kt b/core/src/iosMain/kotlin/com/revenuecat/purchases/kmp/Purchases.ios.kt
index 4549f8d3..398668ab 100644
--- a/core/src/iosMain/kotlin/com/revenuecat/purchases/kmp/Purchases.ios.kt
+++ b/core/src/iosMain/kotlin/com/revenuecat/purchases/kmp/Purchases.ios.kt
@@ -4,7 +4,10 @@ import cocoapods.PurchasesHybridCommon.RCCommonFunctionality
import cocoapods.PurchasesHybridCommon.RCPurchasesDelegateProtocol
import cocoapods.PurchasesHybridCommon.RCStoreProduct
import cocoapods.PurchasesHybridCommon.configureWithAPIKey
+import cocoapods.PurchasesHybridCommon.isWebPurchaseRedemptionURL
+import cocoapods.PurchasesHybridCommon.parseAsWebPurchaseRedemptionWithUrlString
import cocoapods.PurchasesHybridCommon.recordPurchaseForProductID
+import cocoapods.PurchasesHybridCommon.redeemWebPurchaseWithUrlString
import cocoapods.PurchasesHybridCommon.setAirshipChannelID
import cocoapods.PurchasesHybridCommon.setOnesignalUserID
import cocoapods.PurchasesHybridCommon.showStoreMessagesForTypes
@@ -30,6 +33,7 @@ import com.revenuecat.purchases.kmp.models.Package
import com.revenuecat.purchases.kmp.models.PromotionalOffer
import com.revenuecat.purchases.kmp.models.PurchasesError
import com.revenuecat.purchases.kmp.models.PurchasesErrorCode
+import com.revenuecat.purchases.kmp.models.RedeemWebPurchaseListener
import com.revenuecat.purchases.kmp.models.ReplacementMode
import com.revenuecat.purchases.kmp.models.Store
import com.revenuecat.purchases.kmp.models.StoreMessageType
@@ -37,6 +41,7 @@ import com.revenuecat.purchases.kmp.models.StoreProduct
import com.revenuecat.purchases.kmp.models.StoreProductDiscount
import com.revenuecat.purchases.kmp.models.StoreTransaction
import com.revenuecat.purchases.kmp.models.SubscriptionOption
+import com.revenuecat.purchases.kmp.models.WebPurchaseRedemption
import com.revenuecat.purchases.kmp.strings.ConfigureStrings
import platform.Foundation.NSURL
import cocoapods.PurchasesHybridCommon.RCDangerousSettings as IosDangerousSettings
@@ -113,6 +118,19 @@ public actual class Purchases private constructor(private val iosPurchases: IosP
private fun DangerousSettings.toIosDangerousSettings(): IosDangerousSettings =
IosDangerousSettings(autoSyncPurchases)
+
+ /**
+ * Given a url string, parses the link and returns a [WebPurchaseRedemption], which can
+ * be used to redeem a web purchase using [Purchases.redeemWebPurchase]
+ * @return A parsed version of the link or null if it's not a valid RevenueCat web purchase redemption link.
+ */
+ public actual fun parseAsWebPurchaseRedemption(url: String): WebPurchaseRedemption? {
+ return if (RCCommonFunctionality.isWebPurchaseRedemptionURL(url)) {
+ WebPurchaseRedemption(url)
+ } else {
+ null
+ }
+ }
}
public actual val appUserID: String
@@ -446,4 +464,59 @@ public actual class Purchases private constructor(private val iosPurchases: IosP
public actual fun setCreative(creative: String?): Unit =
iosPurchases.setCreative(creative)
+
+ public actual fun redeemWebPurchase(
+ webPurchaseRedemption: WebPurchaseRedemption,
+ listener: RedeemWebPurchaseListener,
+ ) {
+ val nativeWebPurchaseRedemption = RCCommonFunctionality.parseAsWebPurchaseRedemptionWithUrlString(
+ webPurchaseRedemption.redemptionUrl,
+ )
+ if (nativeWebPurchaseRedemption == null) {
+ listener.handleResult(RedeemWebPurchaseListener.Result.Error(
+ PurchasesError(
+ code = PurchasesErrorCode.ConfigurationError,
+ underlyingErrorMessage = "Invalid web purchase redemption URL."
+ )
+ ))
+ return
+ }
+ iosPurchases.redeemWebPurchaseWithWebPurchaseRedemption(
+ nativeWebPurchaseRedemption,
+ ) { rcCustomerInfo, nsError ->
+ if (nsError != null) {
+ val errorCode = nsError.code.toInt()
+ val result = when (errorCode) {
+ PurchasesErrorCode.InvalidWebPurchaseToken.code ->
+ RedeemWebPurchaseListener.Result.InvalidToken
+ PurchasesErrorCode.PurchaseBelongsToOtherUser.code ->
+ RedeemWebPurchaseListener.Result.PurchaseBelongsToOtherUser
+ PurchasesErrorCode.ExpiredWebPurchaseToken.code ->
+ RedeemWebPurchaseListener.Result.Expired(
+ nsError.userInfo["rc_obfuscated_email"] as String? ?: ""
+ )
+ else ->
+ RedeemWebPurchaseListener.Result.Error(nsError.toPurchasesErrorOrThrow())
+ }
+ listener.handleResult(result)
+ return@redeemWebPurchaseWithWebPurchaseRedemption
+ }
+ if (rcCustomerInfo == null) {
+ listener.handleResult(
+ RedeemWebPurchaseListener.Result.Error(
+ PurchasesError(
+ code = PurchasesErrorCode.UnknownError,
+ underlyingErrorMessage = "Expected a non-null RCCustomerInfo when error is null."
+ )
+ )
+ )
+ return@redeemWebPurchaseWithWebPurchaseRedemption
+ }
+ listener.handleResult(
+ RedeemWebPurchaseListener.Result.Success(
+ rcCustomerInfo.toCustomerInfo()
+ )
+ )
+ }
+ }
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 066731fb..0c76345f 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -8,6 +8,7 @@ java = "1.8"
kotlin = "1.9.23"
revenuecat-common = "13.15.0"
revenuecat-kmp = "1.4.0-SNAPSHOT"
+rinku = "1.3.1"
[libraries]
android-gradlePlugin = { module = "com.android.tools.build:gradle", version.ref = "agp" }
@@ -21,6 +22,8 @@ kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version = "0.6.0" }
revenuecat-common = { module = "com.revenuecat.purchases:purchases-hybrid-common", version.ref = "revenuecat-common" }
revenuecat-commonUi = { module = "com.revenuecat.purchases:purchases-hybrid-common-ui", version.ref = "revenuecat-common" }
+rinku = { module = "dev.theolm:rinku", version.ref = "rinku" }
+rinku-compose-ext = { module = "dev.theolm:rinku-compose-ext", version.ref = "rinku" }
[plugins]
adamko-dokkatoo-html = { id = "dev.adamko.dokkatoo-html", version = "2.3.1" }
diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist
index aaf5e182..7104d971 100644
--- a/iosApp/iosApp/Info.plist
+++ b/iosApp/iosApp/Info.plist
@@ -18,6 +18,19 @@
$(PRODUCT_BUNDLE_PACKAGE_TYPE)
CFBundleShortVersionString
1.0
+ CFBundleURLTypes
+
+
+ CFBundleTypeRole
+ Editor
+ CFBundleURLName
+ RevenueCat Redemption Links
+ CFBundleURLSchemes
+
+ rc-39976e83d0
+
+
+
CFBundleVersion
1
LSRequiresIPhoneOS
diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift
index 0648e860..12f635d9 100644
--- a/iosApp/iosApp/iOSApp.swift
+++ b/iosApp/iosApp/iOSApp.swift
@@ -1,10 +1,27 @@
import SwiftUI
+import ComposeApp
-@main
-struct iOSApp: App {
- var body: some Scene {
- WindowGroup {
- ContentView()
- }
- }
-}
\ No newline at end of file
+@UIApplicationMain
+class AppDelegate: NSObject, UIApplicationDelegate {
+ var window: UIWindow?
+ let rinku = RinkuIos.init(deepLinkFilter: nil, deepLinkMapper: nil)
+
+ func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
+ self.window = UIWindow(frame: UIScreen.main.bounds)
+ let mainViewController = UIHostingController(rootView: ContentView())
+ self.window!.rootViewController = mainViewController
+ self.window!.makeKeyAndVisible()
+
+ return true
+ }
+
+ func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
+ rinku.onDeepLinkReceived(url: url.absoluteString)
+ return true
+ }
+
+ func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
+ rinku.onDeepLinkReceived(userActivity: userActivity)
+ return true
+ }
+}
diff --git a/mappings/src/androidMain/kotlin/com/revenuecat/purchases/kmp/mappings/RedeemWebPurchaseListener.android.kt b/mappings/src/androidMain/kotlin/com/revenuecat/purchases/kmp/mappings/RedeemWebPurchaseListener.android.kt
new file mode 100644
index 00000000..7064fc2a
--- /dev/null
+++ b/mappings/src/androidMain/kotlin/com/revenuecat/purchases/kmp/mappings/RedeemWebPurchaseListener.android.kt
@@ -0,0 +1,21 @@
+package com.revenuecat.purchases.kmp.mappings
+
+import com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI
+import com.revenuecat.purchases.kmp.models.RedeemWebPurchaseListener
+import com.revenuecat.purchases.interfaces.RedeemWebPurchaseListener as NativeRedeemWebPurchaseListener
+
+@OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class)
+public fun NativeRedeemWebPurchaseListener.Result.toWebPurchaseResult(): RedeemWebPurchaseListener.Result {
+ return when (this) {
+ is NativeRedeemWebPurchaseListener.Result.Success ->
+ RedeemWebPurchaseListener.Result.Success(customerInfo.toCustomerInfo())
+ is NativeRedeemWebPurchaseListener.Result.Error ->
+ RedeemWebPurchaseListener.Result.Error(error.toPurchasesError())
+ NativeRedeemWebPurchaseListener.Result.InvalidToken ->
+ RedeemWebPurchaseListener.Result.InvalidToken
+ is NativeRedeemWebPurchaseListener.Result.Expired ->
+ RedeemWebPurchaseListener.Result.Expired(obfuscatedEmail)
+ NativeRedeemWebPurchaseListener.Result.PurchaseBelongsToOtherUser ->
+ RedeemWebPurchaseListener.Result.PurchaseBelongsToOtherUser
+ }
+}
diff --git a/models/src/commonMain/kotlin/com/revenuecat/purchases/kmp/models/RedeemWebPurchaseListener.kt b/models/src/commonMain/kotlin/com/revenuecat/purchases/kmp/models/RedeemWebPurchaseListener.kt
new file mode 100644
index 00000000..4a55141c
--- /dev/null
+++ b/models/src/commonMain/kotlin/com/revenuecat/purchases/kmp/models/RedeemWebPurchaseListener.kt
@@ -0,0 +1,55 @@
+package com.revenuecat.purchases.kmp.models
+
+/**
+ * Interface to handle the redemption of a RevenueCat Web purchase.
+ */
+public fun interface RedeemWebPurchaseListener {
+ /**
+ * Result of the redemption of a RevenueCat Web purchase.
+ */
+ public sealed class Result {
+ /**
+ * Indicates that the web purchase was redeemed successfully.
+ */
+ public data class Success(val customerInfo: CustomerInfo) : Result()
+
+ /**
+ * Indicates that an unknown error occurred during the redemption.
+ */
+ public data class Error(val error: PurchasesError) : Result()
+
+ /**
+ * Indicates that the redemption token is invalid.
+ */
+ public object InvalidToken : Result()
+
+ /**
+ * Indicates that the redemption token has expired. An email with a new redemption token
+ * might be sent if a new one wasn't already sent recently.
+ * The email where it will be sent is indicated by the [obfuscatedEmail].
+ */
+ public data class Expired(val obfuscatedEmail: String) : Result()
+
+ /**
+ * Indicates that the redemption couldn't be performed because the purchase belongs to a different user.
+ */
+ public object PurchaseBelongsToOtherUser : Result()
+
+ /**
+ * Whether the redemption was successful or not.
+ */
+ public val isSuccess: Boolean
+ get() = when (this) {
+ is Success -> true
+ is Error -> false
+ InvalidToken -> false
+ is Expired -> false
+ PurchaseBelongsToOtherUser -> false
+ }
+ }
+
+ /**
+ * Called when a RevenueCat Web purchase redemption finishes with the result of the operation.
+ */
+ public fun handleResult(result: Result)
+}
diff --git a/models/src/commonMain/kotlin/com/revenuecat/purchases/kmp/models/WebPurchaseRedemption.kt b/models/src/commonMain/kotlin/com/revenuecat/purchases/kmp/models/WebPurchaseRedemption.kt
new file mode 100644
index 00000000..7b72103b
--- /dev/null
+++ b/models/src/commonMain/kotlin/com/revenuecat/purchases/kmp/models/WebPurchaseRedemption.kt
@@ -0,0 +1,8 @@
+package com.revenuecat.purchases.kmp.models
+
+/**
+ * Represents a web redemption link, that can be redeemed using [Purchases.redeemWebPurchase]
+ */
+public class WebPurchaseRedemption(
+ public val redemptionUrl: String,
+)
diff --git a/models/src/commonMain/kotlin/com/revenuecat/purchases/kmp/models/errors.kt b/models/src/commonMain/kotlin/com/revenuecat/purchases/kmp/models/errors.kt
index 33aba020..b2ecef3b 100644
--- a/models/src/commonMain/kotlin/com/revenuecat/purchases/kmp/models/errors.kt
+++ b/models/src/commonMain/kotlin/com/revenuecat/purchases/kmp/models/errors.kt
@@ -96,6 +96,18 @@ public enum class PurchasesErrorCode(public val code: Int, public val descriptio
),
FeatureNotAvailableInCustomEntitlementsComputationMode(
37,
- "This feature is not available when utilizing the customEntitlementsComputation dangerousSetting."
+ "This feature is not available when utilizing the customEntitlementsComputation dangerousSetting.",
+ ),
+ InvalidWebPurchaseToken(
+ 39,
+ "The link you provided does not contain a valid purchase token.",
+ ),
+ PurchaseBelongsToOtherUser(
+ 40,
+ "The web purchase already belongs to other user.",
+ ),
+ ExpiredWebPurchaseToken(
+ 41,
+ "The link you provided has expired. A new one will be sent to the email used to make the purchase.",
)
}