Skip to content

Commit 55ec8a7

Browse files
vegaroclaude
andcommitted
feat(paywalls): fall back to the offerings paywall when the workflow fetch fails
The /v1/config endpoint can be disabled for the session after a 4xx (the kill switch), be unreachable, or simply have no workflow for the offering yet. In all of those cases the offering still carries the paywall that /offerings delivered, so render that through the regular components path instead of surfacing PaywallState.Error — apps keep showing a paywall when the config layer can't serve one. The error state remains for offerings with nothing to fall back to. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 9503ffc commit 55ec8a7

2 files changed

Lines changed: 74 additions & 4 deletions

File tree

ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -801,14 +801,26 @@ internal class PaywallViewModelImpl(
801801
val resolvedOfferingSelection = resolveOfferingSelection(offeringSelection)
802802
val selectedOffering = resolvedOfferingSelection.selectedOffering
803803

804-
// When workflows are enabled, every non-legacy paywall is served through the /workflows
805-
// endpoint. `offering.paywall == null` is the durable marker of a non-legacy (workflow)
804+
// When workflows are enabled, every non-legacy paywall is served through the workflows
805+
// path. `offering.paywall == null` is the durable marker of a non-legacy (workflow)
806806
// paywall: a legacy v1 paywall always carries `offering.paywall`, and that field stays
807807
// even after `paywallComponents` is removed and all V2 paywalls move to workflows. We
808808
// deliberately do NOT gate on `paywallComponents`, which is going away.
809809
if (useWorkflowsEndpoint && selectedOffering != null && selectedOffering.paywall == null) {
810-
presentWorkflow(selectedOffering, resolvedOfferingSelection.offeringsForExitOfferLookup)
811-
return
810+
try {
811+
presentWorkflow(selectedOffering, resolvedOfferingSelection.offeringsForExitOfferLookup)
812+
return
813+
} catch (e: PurchasesException) {
814+
// The workflow could not be served — the config endpoint is disabled for the session
815+
// (4xx kill switch), unreachable, or the offering has no workflow yet. Degrade to the
816+
// paywall /offerings already delivered so the app still shows something; rethrow
817+
// (→ PaywallState.Error) only when there is nothing to fall back to.
818+
if (selectedOffering.paywallComponents == null) throw e
819+
Logger.w(
820+
"Paywalls: Failed to fetch workflow for offering '${selectedOffering.identifier}' " +
821+
"(${e.message}). Falling back to the offerings-provided paywall.",
822+
)
823+
}
812824
}
813825

814826
val exitOfferingId = selectedOffering?.paywallComponents?.data?.exitOffers?.dismiss?.offeringId

ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelTest.kt

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3223,6 +3223,64 @@ class PaywallViewModelTest {
32233223
coVerify(exactly = 1) { purchases.awaitGetWorkflow(offeringWithWPL.identifier) }
32243224
}
32253225

3226+
@Test
3227+
fun `when useWorkflows is true and the workflow fetch fails, falls back to the offerings-provided paywall`() {
3228+
// The config endpoint may be disabled for the session (4xx kill switch) or unreachable, or the
3229+
// offering may have no workflow yet. The offering still carries the paywall /offerings delivered,
3230+
// so the paywall renders through the regular components path instead of erroring.
3231+
coEvery { purchases.workflowIdForOfferingId(offeringWithWPL.identifier) } returns null
3232+
coEvery { purchases.awaitGetWorkflow(offeringWithWPL.identifier) } throws PurchasesException(
3233+
PurchasesError(PurchasesErrorCode.UnknownError, "Workflow is unavailable from remote config."),
3234+
)
3235+
3236+
val model = PaywallViewModelImpl(
3237+
MockResourceProvider(),
3238+
purchases,
3239+
PaywallOptions.Builder(dismissRequest = { dismissInvoked = true })
3240+
.setListener(listener)
3241+
.setOffering(offeringWithWPL)
3242+
.build(),
3243+
TestData.Constants.currentColorScheme,
3244+
isDarkMode = false,
3245+
shouldDisplayBlock = null,
3246+
useWorkflowsEndpoint = true,
3247+
)
3248+
3249+
assertThat(model.state.value).isInstanceOf(PaywallState.Loaded.Components::class.java)
3250+
coVerify(exactly = 1) { purchases.awaitGetWorkflow(offeringWithWPL.identifier) }
3251+
}
3252+
3253+
@Test
3254+
fun `when useWorkflows is true and the workflow fetch fails with no fallback paywall, surfaces the error`() {
3255+
val offeringWithoutPaywallData = Offering(
3256+
identifier = "offering-no-paywall-data",
3257+
serverDescription = "description",
3258+
metadata = emptyMap(),
3259+
availablePackages = listOf(TestData.Packages.monthly),
3260+
paywallComponents = null,
3261+
webCheckoutURL = null,
3262+
)
3263+
coEvery { purchases.workflowIdForOfferingId(offeringWithoutPaywallData.identifier) } returns null
3264+
coEvery { purchases.awaitGetWorkflow(offeringWithoutPaywallData.identifier) } throws PurchasesException(
3265+
PurchasesError(PurchasesErrorCode.UnknownError, "Workflow is unavailable from remote config."),
3266+
)
3267+
3268+
val model = PaywallViewModelImpl(
3269+
MockResourceProvider(),
3270+
purchases,
3271+
PaywallOptions.Builder(dismissRequest = { dismissInvoked = true })
3272+
.setListener(listener)
3273+
.setOffering(offeringWithoutPaywallData)
3274+
.build(),
3275+
TestData.Constants.currentColorScheme,
3276+
isDarkMode = false,
3277+
shouldDisplayBlock = null,
3278+
useWorkflowsEndpoint = true,
3279+
)
3280+
3281+
assertThat(model.state.value).isInstanceOf(PaywallState.Error::class.java)
3282+
}
3283+
32263284
private fun create(
32273285
offering: Offering? = null,
32283286
customPurchaseLogic: PaywallPurchaseLogic? = null,

0 commit comments

Comments
 (0)