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:
parent
8464cb9219
commit
6ecc98eec2
|
|
@ -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"}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue