feat(paywall): free-trial/intro pricing, gift framing, one-time SKUs

- 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 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-06 20:53:15 -05:00
parent 8464cb9219
commit 6ecc98eec2
3 changed files with 151 additions and 6 deletions

View File

@ -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"}"
}
}

View File

@ -292,10 +292,12 @@ private fun PlanOptions(
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface 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(
text = "Plans are selected here. Restore and legal links stay below the purchase action.", text = "One subscription covers you both — treat your partner.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
color = MaterialTheme.colorScheme.onSurfaceVariant color = Color(0xFF56306F)
) )
if (packages.isEmpty()) { if (packages.isEmpty()) {
@ -351,21 +353,24 @@ private fun PlanRow(
) )
) )
val pricing = remember(pkg) { PaywallPricing.fromPackage(pkg) }
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = pkg.product.title, text = pkg.product.title,
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
) )
// Prefer the offer line (free trial / intro / one-time) when present; else the store copy.
Text( Text(
text = pkg.product.description, text = pricing.offer ?: pkg.product.description,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = if (pricing.offer != null) Color(0xFF56306F) else MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
Text( Text(
text = pkg.product.price.formatted, text = pricing.price,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = Color(0xFF56306F) color = Color(0xFF56306F)
) )

View File

@ -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)
}
}