From 6ecc98eec22e909eeac4e95e7c828e63739eff69 Mon Sep 17 00:00:00 2001 From: null Date: Mon, 6 Jul 2026 20:53:15 -0500 Subject: [PATCH] feat(paywall): free-trial/intro pricing, gift framing, one-time SKUs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PaywallPricing turns a RevenueCat Package into a headline price + an offer line (free trial, intro price, or one-time), so a dashboard-configured trial or a lifetime SKU renders without an app release; unknown shapes degrade to the plain price. Pure formatting is unit-tested (PaywallPricingTest); the SDK extraction is type-checked against purchases 8.20. - Gift framing on the plan chooser ("One subscription covers you both — treat your partner"): couple-shared premium already unlocks both partners, so this is copy only, no billing change. Live paywall (real offerings) still needs a real RC key + sandbox per docs/SUBSCRIPTION_GO_LIVE.md (owner). Co-Authored-By: Claude Fable 5 --- .../app/closer/ui/paywall/PaywallPricing.kt | 69 ++++++++++++++++++ .../app/closer/ui/paywall/PaywallScreen.kt | 17 +++-- .../closer/ui/paywall/PaywallPricingTest.kt | 71 +++++++++++++++++++ 3 files changed, 151 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/app/closer/ui/paywall/PaywallPricing.kt create mode 100644 app/src/test/java/app/closer/ui/paywall/PaywallPricingTest.kt diff --git a/app/src/main/java/app/closer/ui/paywall/PaywallPricing.kt b/app/src/main/java/app/closer/ui/paywall/PaywallPricing.kt new file mode 100644 index 00000000..53f06236 --- /dev/null +++ b/app/src/main/java/app/closer/ui/paywall/PaywallPricing.kt @@ -0,0 +1,69 @@ +package app.closer.ui.paywall + +import com.revenuecat.purchases.Package +import com.revenuecat.purchases.ProductType +import com.revenuecat.purchases.models.Period + +/** + * Turns a RevenueCat [Package] into what the paywall row shows: the headline price plus an optional + * offer line (free trial / intro price / one-time). Pricing/offers are configured entirely in the + * RevenueCat dashboard, so this renders whatever the remote offering returns — a new intro offer or a + * lifetime SKU lights up without an app release. + * + * The pure [describe] holds the formatting rules (unit-tested); [fromPackage] only pulls values off the + * SDK types and is defensive so an unexpected shape degrades to the plain price rather than crashing. + */ +data class PriceDisplay(val price: String, val offer: String?) + +object PaywallPricing { + + fun describe( + isSubscription: Boolean, + formattedPrice: String, + freeTrialPeriod: String?, + introPrice: String?, + introPeriod: String?, + ): PriceDisplay { + if (!isSubscription) { + return PriceDisplay(price = formattedPrice, offer = "One-time · yours to keep") + } + val offer = when { + !freeTrialPeriod.isNullOrBlank() -> "$freeTrialPeriod free, then $formattedPrice" + !introPrice.isNullOrBlank() && !introPeriod.isNullOrBlank() -> + "$introPrice for your first $introPeriod, then $formattedPrice" + else -> null + } + return PriceDisplay(price = formattedPrice, offer = offer) + } + + fun fromPackage(pkg: Package): PriceDisplay = runCatching { + val product = pkg.product + val isSub = product.type == ProductType.SUBS + val formatted = product.price.formatted + val option = product.defaultOption + val freeTrial = option?.freePhase?.billingPeriod?.let { humanPeriod(it) } + val introPhase = option?.introPhase + val introPrice = introPhase?.takeIf { (it.price.amountMicros) > 0L }?.price?.formatted + val introPeriod = introPhase?.billingPeriod?.let { humanPeriod(it) } + describe( + isSubscription = isSub, + formattedPrice = formatted, + freeTrialPeriod = freeTrial, + introPrice = introPrice, + introPeriod = introPeriod, + ) + }.getOrElse { PriceDisplay(price = pkg.product.price.formatted, offer = null) } + + /** "7 days", "1 week", "3 months", "1 year" from a billing [Period]. */ + internal fun humanPeriod(period: Period): String { + val n = period.value + val unit = when (period.unit) { + Period.Unit.DAY -> "day" + Period.Unit.WEEK -> "week" + Period.Unit.MONTH -> "month" + Period.Unit.YEAR -> "year" + else -> "period" + } + return "$n $unit${if (n == 1) "" else "s"}" + } +} diff --git a/app/src/main/java/app/closer/ui/paywall/PaywallScreen.kt b/app/src/main/java/app/closer/ui/paywall/PaywallScreen.kt index fc53d0f4..735b0e91 100644 --- a/app/src/main/java/app/closer/ui/paywall/PaywallScreen.kt +++ b/app/src/main/java/app/closer/ui/paywall/PaywallScreen.kt @@ -292,10 +292,12 @@ private fun PlanOptions( style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.onSurface ) + // Couple-shared premium: one purchase unlocks BOTH partners (CouplePremiumChecker). + // Framed as a gift — the real, honest hook, no billing changes needed. Text( - text = "Plans are selected here. Restore and legal links stay below the purchase action.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = "One subscription covers you both — treat your partner.", + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium), + color = Color(0xFF56306F) ) if (packages.isEmpty()) { @@ -351,21 +353,24 @@ private fun PlanRow( ) ) + val pricing = remember(pkg) { PaywallPricing.fromPackage(pkg) } + Column(modifier = Modifier.weight(1f)) { Text( text = pkg.product.title, style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.onSurface ) + // Prefer the offer line (free trial / intro / one-time) when present; else the store copy. Text( - text = pkg.product.description, + text = pricing.offer ?: pkg.product.description, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = if (pricing.offer != null) Color(0xFF56306F) else MaterialTheme.colorScheme.onSurfaceVariant ) } Text( - text = pkg.product.price.formatted, + text = pricing.price, style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), color = Color(0xFF56306F) ) diff --git a/app/src/test/java/app/closer/ui/paywall/PaywallPricingTest.kt b/app/src/test/java/app/closer/ui/paywall/PaywallPricingTest.kt new file mode 100644 index 00000000..7ae7b803 --- /dev/null +++ b/app/src/test/java/app/closer/ui/paywall/PaywallPricingTest.kt @@ -0,0 +1,71 @@ +package app.closer.ui.paywall + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class PaywallPricingTest { + + @Test + fun `free trial is surfaced as the offer line`() { + val d = PaywallPricing.describe( + isSubscription = true, + formattedPrice = "$4.99/mo", + freeTrialPeriod = "7 days", + introPrice = null, + introPeriod = null, + ) + assertEquals("$4.99/mo", d.price) + assertEquals("7 days free, then $4.99/mo", d.offer) + } + + @Test + fun `intro price is surfaced when there is no free trial`() { + val d = PaywallPricing.describe( + isSubscription = true, + formattedPrice = "$4.99/mo", + freeTrialPeriod = null, + introPrice = "$0.99", + introPeriod = "1 month", + ) + assertEquals("$0.99 for your first 1 month, then $4.99/mo", d.offer) + } + + @Test + fun `free trial wins over an intro price when both exist`() { + val d = PaywallPricing.describe( + isSubscription = true, + formattedPrice = "$4.99/mo", + freeTrialPeriod = "3 days", + introPrice = "$0.99", + introPeriod = "1 month", + ) + assertEquals("3 days free, then $4.99/mo", d.offer) + } + + @Test + fun `plain subscription has no offer line`() { + val d = PaywallPricing.describe( + isSubscription = true, + formattedPrice = "$39.99/yr", + freeTrialPeriod = null, + introPrice = null, + introPeriod = null, + ) + assertNull(d.offer) + assertEquals("$39.99/yr", d.price) + } + + @Test + fun `non-subscription renders as a one-time purchase`() { + val d = PaywallPricing.describe( + isSubscription = false, + formattedPrice = "$79.99", + freeTrialPeriod = null, + introPrice = null, + introPeriod = null, + ) + assertEquals("$79.99", d.price) + assertEquals("One-time · yours to keep", d.offer) + } +}