Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import com.revenuecat.purchases.common.ReceiptInfo
import com.revenuecat.purchases.common.caching.DeviceCache
import com.revenuecat.purchases.common.caching.LocalTransactionMetadata
import com.revenuecat.purchases.common.caching.LocalTransactionMetadataStore
import com.revenuecat.purchases.common.caching.WorkflowMetadata
import com.revenuecat.purchases.common.errorLog
import com.revenuecat.purchases.common.networking.PostReceiptResponse
import com.revenuecat.purchases.common.offlineentitlements.OfflineEntitlementsManager
Expand Down Expand Up @@ -172,6 +173,7 @@ constructor(
receiptInfo = transactionMetadata.receiptInfo,
initiationSource = PostReceiptInitiationSource.UNSYNCED_ACTIVE_PURCHASES,
paywallData = transactionMetadata.paywallPostReceiptData,
workflowMetadata = transactionMetadata.workflowMetadata,
purchasesAreCompletedBy = transactionMetadata.purchasesAreCompletedBy,
hasCachedTransactionMetadata = true,
onSuccess = {
Expand Down Expand Up @@ -238,6 +240,8 @@ constructor(

val effectivePaywallData = cachedTransactionMetadata?.paywallPostReceiptData
?: presentedPaywall?.toPaywallPostReceiptData()
val effectiveWorkflowMetadata = cachedTransactionMetadata?.workflowMetadata
?: presentedPaywall?.data?.let { WorkflowMetadata.from(it.workflowId, it.stepId) }
val effectiveReceiptInfo = cachedTransactionMetadata?.receiptInfo
?: receiptInfo
val effectivePurchasesAreCompletedBy = cachedTransactionMetadata?.purchasesAreCompletedBy
Expand All @@ -259,6 +263,7 @@ constructor(
receiptInfo = effectiveReceiptInfo,
initiationSource = initiationSource,
paywallData = effectivePaywallData,
workflowMetadata = effectiveWorkflowMetadata,
purchasesAreCompletedBy = effectivePurchasesAreCompletedBy,
hasCachedTransactionMetadata = cachedTransactionMetadata != null || didCacheData,
onSuccess = onSuccess,
Expand Down Expand Up @@ -316,6 +321,9 @@ constructor(
receiptInfo = effectiveReceiptInfo,
paywallPostReceiptData = presentedPaywall?.toPaywallPostReceiptData(),
purchasesAreCompletedBy = purchasesAreCompletedBy,
workflowMetadata = presentedPaywall?.data?.let {
WorkflowMetadata.from(it.workflowId, it.stepId)
},
)
cacheLocalTransactionMetadata(purchaseToken, dataToCache)
}
Expand All @@ -335,6 +343,7 @@ constructor(
receiptInfo: ReceiptInfo,
initiationSource: PostReceiptInitiationSource,
paywallData: PaywallPostReceiptData?,
workflowMetadata: WorkflowMetadata?,
purchasesAreCompletedBy: PurchasesAreCompletedBy,
hasCachedTransactionMetadata: Boolean,
onSuccess: (PostReceiptResponse) -> Unit,
Expand All @@ -350,6 +359,7 @@ constructor(
receiptInfo = receiptInfo,
initiationSource = initiationSource,
paywallPostReceiptData = paywallData,
workflowMetadata = workflowMetadata,
purchasesAreCompletedBy = purchasesAreCompletedBy,
onSuccess = { postReceiptResponse ->
if (hasCachedTransactionMetadata) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import com.revenuecat.purchases.RewardVerificationError
import com.revenuecat.purchases.RewardVerificationResult
import com.revenuecat.purchases.api.BuildConfig
import com.revenuecat.purchases.backendName
import com.revenuecat.purchases.common.caching.WorkflowMetadata
import com.revenuecat.purchases.common.events.EventsRequest
import com.revenuecat.purchases.common.networking.Endpoint
import com.revenuecat.purchases.common.networking.HTTPResult
Expand Down Expand Up @@ -304,6 +305,7 @@ internal class Backend(
receiptInfo: ReceiptInfo,
initiationSource: PostReceiptInitiationSource,
paywallPostReceiptData: PaywallPostReceiptData?,
workflowMetadata: WorkflowMetadata? = null,
// This reflects the value at the time of the purchase, which might come from the LocalTransactionMetadataStore
purchasesAreCompletedBy: PurchasesAreCompletedBy,
onSuccess: PostReceiptDataSuccessCallback,
Expand Down Expand Up @@ -341,6 +343,8 @@ internal class Backend(
"proration_mode" to receiptInfo.replacementMode?.backendName(store = appConfig.store),
"initiation_source" to initiationSource.postReceiptFieldValue,
"paywall" to paywallPostReceiptData?.toMap(),
"presented_workflow_id" to workflowMetadata?.workflowId,
"presented_step_id" to workflowMetadata?.stepId,
"sdk_originated" to receiptInfo.sdkOriginated,
"payload_version" to POST_RECEIPT_PAYLOAD_VERSION,
).filterNotNullValues()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,7 @@ internal data class LocalTransactionMetadata(

@SerialName("purchases_are_completed_by")
val purchasesAreCompletedBy: PurchasesAreCompletedBy,

@SerialName("workflow_metadata")
val workflowMetadata: WorkflowMetadata? = null,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.revenuecat.purchases.common.caching

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
internal data class WorkflowMetadata(
@SerialName("workflow_id")
val workflowId: String,
@SerialName("step_id")
val stepId: String,
) {
companion object {
fun from(workflowId: String?, stepId: String?): WorkflowMetadata? =
if (workflowId != null && stepId != null) WorkflowMetadata(workflowId, stepId) else null
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ public data class PaywallEvent(
val errorCode: Int? = null,
val errorMessage: String? = null,
val workflowId: String? = null,
val stepId: String? = null,
)

internal fun toPaywallPostReceiptData(): PaywallPostReceiptData {
Expand Down Expand Up @@ -210,6 +211,7 @@ internal object PaywallEventDataSerializer : KSerializer<PaywallEvent.Data> {
private const val ERROR_CODE_INDEX = 11
private const val ERROR_MESSAGE_INDEX = 12
private const val WORKFLOW_ID_INDEX = 14
private const val STEP_ID_INDEX = 15

private val nullableStringSerializer = String.serializer().nullable
private val nullableIntSerializer = Int.serializer().nullable
Expand All @@ -232,6 +234,7 @@ internal object PaywallEventDataSerializer : KSerializer<PaywallEvent.Data> {
// Legacy field for backward compatibility
element("offeringIdentifier", String.serializer().descriptor)
element("workflowId", nullableStringSerializer.descriptor)
element("stepId", nullableStringSerializer.descriptor)
}

override fun serialize(encoder: Encoder, value: PaywallEvent.Data) {
Expand Down Expand Up @@ -275,6 +278,9 @@ internal object PaywallEventDataSerializer : KSerializer<PaywallEvent.Data> {
value.workflowId?.let {
encodeStringElement(descriptor, WORKFLOW_ID_INDEX, it)
}
value.stepId?.let {
encodeStringElement(descriptor, STEP_ID_INDEX, it)
}
}
}

Expand Down Expand Up @@ -338,6 +344,9 @@ internal object PaywallEventDataSerializer : KSerializer<PaywallEvent.Data> {
val workflowId = jsonObject["workflowId"]?.let {
decoder.json.decodeFromJsonElement(String.serializer(), it)
}
val stepId = jsonObject["stepId"]?.let {
decoder.json.decodeFromJsonElement(String.serializer(), it)
}

return PaywallEvent.Data(
paywallIdentifier = paywallIdentifier,
Expand All @@ -354,6 +363,7 @@ internal object PaywallEventDataSerializer : KSerializer<PaywallEvent.Data> {
errorCode = errorCode,
errorMessage = errorMessage,
workflowId = workflowId,
stepId = stepId,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2381,7 +2381,7 @@ class PostReceiptHelperTest {
assertThat(errorCallCount).isEqualTo(1)
verify(exactly = 0) {
backend.postReceiptData(
any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()
any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import com.revenuecat.purchases.models.PricingPhase
import com.revenuecat.purchases.models.RecurrenceMode
import com.revenuecat.purchases.models.StoreProduct
import com.revenuecat.purchases.models.StoreReplacementMode
import com.revenuecat.purchases.common.caching.WorkflowMetadata
import com.revenuecat.purchases.paywalls.events.PaywallPostReceiptData
import com.revenuecat.purchases.utils.Responses
import com.revenuecat.purchases.utils.filterNotNullValues
Expand Down Expand Up @@ -1458,6 +1459,80 @@ class BackendTest {
))
}

@Test
fun `postReceipt passes presented_workflow_id and presented_step_id at top level and paywall_id in the paywall object`() {
val receiptInfo = createReceiptInfoFromProduct(
productIDs = productIDs,
storeProduct = storeProduct,
storeUserID = "user-id",
)

mockPostReceiptResponseAndPost(
backend,
responseCode = 200,
isRestore = false,
clientException = null,
resultBody = null,
finishTransactions = false,
receiptInfo = receiptInfo,
initiationSource = initiationSource,
paywallPostReceiptData = PaywallPostReceiptData(
paywallID = "paywall-id-abc",
sessionID = "session-123",
revision = 1,
displayMode = "full_screen",
darkMode = false,
localeIdentifier = "en_US",
offeringId = "offering-id",
),
workflowMetadata = WorkflowMetadata(
workflowId = "workflow-id-xyz",
stepId = "step-id-123",
),
)

assertThat(requestBodySlot.isCaptured).isTrue
// workflow_id and step_id are sent at the top level; the backend reads `presented_workflow_id`
// and `presented_step_id`.
assertThat(requestBodySlot.captured["presented_workflow_id"]).isEqualTo("workflow-id-xyz")
assertThat(requestBodySlot.captured["presented_step_id"]).isEqualTo("step-id-123")
// paywall_id is sent inside the nested `paywall` object (the backend reads it there),
// not as a top-level `presented_paywall_id`.
assertThat(requestBodySlot.captured.keys).doesNotContain("presented_paywall_id")
@Suppress("UNCHECKED_CAST")
val paywall = requestBodySlot.captured["paywall"] as Map<String, Any?>
assertThat(paywall["paywall_id"]).isEqualTo("paywall-id-abc")
// workflow_id and step_id must not leak into the nested `paywall` object.
assertThat(paywall.keys).doesNotContain("workflow_id")
assertThat(paywall.keys).doesNotContain("step_id")
}

@Test
fun `postReceipt omits presented_workflow_id and presented_step_id when no paywallPostReceiptData`() {
val receiptInfo = createReceiptInfoFromProduct(
productIDs = productIDs,
storeProduct = storeProduct,
storeUserID = "user-id",
)

mockPostReceiptResponseAndPost(
backend,
responseCode = 200,
isRestore = false,
clientException = null,
resultBody = null,
finishTransactions = false,
receiptInfo = receiptInfo,
initiationSource = initiationSource,
)

assertThat(requestBodySlot.isCaptured).isTrue
assertThat(requestBodySlot.captured.keys).doesNotContain("presented_paywall_id")
assertThat(requestBodySlot.captured.keys).doesNotContain("presented_workflow_id")
assertThat(requestBodySlot.captured.keys).doesNotContain("presented_step_id")
assertThat(requestBodySlot.captured.keys).doesNotContain("paywall")
}

@Test
fun `postReceipt passes presented_placement_identifier in body`() {
val expectedStoreUserId = "id"
Expand Down Expand Up @@ -3074,6 +3149,7 @@ class BackendTest {
initiationSource: PostReceiptInitiationSource,
delayed: Boolean = false,
paywallPostReceiptData: PaywallPostReceiptData? = null,
workflowMetadata: WorkflowMetadata? = null,
purchasesAreCompletedBy: PurchasesAreCompletedBy = PurchasesAreCompletedBy.REVENUECAT,
onSuccess: (PostReceiptResponse) -> Unit = onReceivePostReceiptSuccessHandler,
onError: PostReceiptDataErrorCallback = postReceiptErrorCallback
Expand All @@ -3086,6 +3162,7 @@ class BackendTest {
finishTransactions = finishTransactions,
receiptInfo = receiptInfo,
paywallPostReceiptData = paywallPostReceiptData,
workflowMetadata = workflowMetadata,
delayed = delayed
)

Expand All @@ -3099,6 +3176,7 @@ class BackendTest {
receiptInfo = receiptInfo,
initiationSource = initiationSource,
paywallPostReceiptData = paywallPostReceiptData,
workflowMetadata = workflowMetadata,
onSuccess = onSuccess,
onError = onError
)
Expand All @@ -3116,6 +3194,7 @@ class BackendTest {
finishTransactions: Boolean,
receiptInfo: ReceiptInfo,
paywallPostReceiptData: PaywallPostReceiptData? = null,
workflowMetadata: WorkflowMetadata? = null,
): CustomerInfo {
val body = mapOf(
"fetch_token" to token,
Expand All @@ -3130,6 +3209,8 @@ class BackendTest {
"normal_duration" to receiptInfo.duration,
"store_user_id" to receiptInfo.storeUserID,
"paywall" to paywallPostReceiptData?.toMap(),
"presented_workflow_id" to workflowMetadata?.workflowId,
"presented_step_id" to workflowMetadata?.stepId,
).filterNotNullValues()

return mockResponse(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ class PaywallEventSerializationTests {
localeIdentifier = "en_US",
darkMode = false,
workflowId = "workflow-xyz",
stepId = "step-xyz",
),
type = PaywallEventType.IMPRESSION,
),
Expand Down Expand Up @@ -569,20 +570,39 @@ class PaywallEventSerializationTests {
fun `can encode paywall event with workflowId correctly`() {
val eventString = PaywallStoredEvent.json.encodeToString(workflowImpressionEvent)
assertThat(eventString).contains("\"workflowId\":\"workflow-xyz\"")
assertThat(eventString).contains("\"stepId\":\"step-xyz\"")
}

@Test
fun `can round-trip encode and decode event with workflowId`() {
val eventString = PaywallStoredEvent.json.encodeToString(workflowImpressionEvent)
val decoded = PaywallStoredEvent.json.decodeFromString<PaywallStoredEvent>(eventString)
assertThat(decoded.event.data.workflowId).isEqualTo("workflow-xyz")
assertThat(decoded.event.data.stepId).isEqualTo("step-xyz")
}

@Test
fun `toPaywallPostReceiptData does not include workflowId or stepId`() {
val paywallPostReceiptData = workflowImpressionEvent.event.toPaywallPostReceiptData()

assertThat(paywallPostReceiptData.paywallID).isEqualTo("paywallID")
assertThat(paywallPostReceiptData.toMap()).doesNotContainKey("workflow_id")
assertThat(paywallPostReceiptData.toMap()).doesNotContainKey("step_id")
}

@Test
fun `workflowId and stepId on PaywallEvent Data are available for WorkflowMetadata construction`() {
val data = workflowImpressionEvent.event.data
assertThat(data.workflowId).isEqualTo("workflow-xyz")
assertThat(data.stepId).isEqualTo("step-xyz")
}

@Test
fun `can decode legacy event without workflowId field`() {
val legacyJson = """{"event":{"creationData":{"id":"111207f4-87af-4b57-a581-eb27bcc6e001","date":1699270688884},"data":{"paywallIdentifier":"paywallID","presentedOfferingContext":{"offeringIdentifier":"offeringID","placementIdentifier":null,"targetingContext":null},"paywallRevision":5,"sessionIdentifier":"222107f4-98bf-4b68-a582-eb27bcb6e002","displayMode":"footer","localeIdentifier":"en_US","darkMode":false},"type":"IMPRESSION"},"userID":"testAppUserId"}"""
val decoded = PaywallStoredEvent.fromString(legacyJson)
assertThat(decoded.event.data.workflowId).isNull()
assertThat(decoded.event.data.stepId).isNull()
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1435,10 +1435,11 @@ internal class PaywallViewModelImpl(
@Suppress("ReturnCount")
private fun createEventData(): PaywallEvent.Data? {
val workflowId = currentWorkflowResult?.workflow?.id
val stepId = _workflowState.value?.currentStepId
Comment thread
cursor[bot] marked this conversation as resolved.
return when (val currentState = state.value) {
is PaywallState.Loaded.Legacy -> currentState.createEventData(workflowId)
is PaywallState.Loaded.Legacy -> currentState.createEventData(workflowId, stepId)

is PaywallState.Loaded.Components -> currentState.createEventData(workflowId)
is PaywallState.Loaded.Components -> currentState.createEventData(workflowId, stepId)

is PaywallState.Error,
is PaywallState.Loading,
Expand All @@ -1449,7 +1450,7 @@ internal class PaywallViewModelImpl(
}
}

private fun PaywallState.Loaded.Legacy.createEventData(workflowId: String?): PaywallEvent.Data? {
private fun PaywallState.Loaded.Legacy.createEventData(workflowId: String?, stepId: String?): PaywallEvent.Data? {
val offering = offering
val revision = this.offering.paywall?.revision ?: this.offering.paywallComponents?.data?.revision ?: run {
Logger.e("Null paywall revision trying to create event data")
Expand All @@ -1466,10 +1467,14 @@ internal class PaywallViewModelImpl(
localeIdentifier = locale.toString(),
darkMode = isDarkMode,
workflowId = workflowId,
stepId = stepId,
)
}

private fun PaywallState.Loaded.Components.createEventData(workflowId: String?): PaywallEvent.Data? {
private fun PaywallState.Loaded.Components.createEventData(
workflowId: String?,
stepId: String?,
): PaywallEvent.Data? {
val offering = offering
val paywallData = this.offering.paywallComponents ?: run {
Logger.e("Null paywall revision trying to create event data")
Expand All @@ -1484,6 +1489,7 @@ internal class PaywallViewModelImpl(
localeIdentifier = locale.toString(),
darkMode = isDarkMode,
workflowId = workflowId,
stepId = stepId,
)
}

Expand Down