Skip to content

feat: add five discount paywall variables - #4134

Draft
dpannasch wants to merge 2 commits into
mainfrom
dan/paywall-discount-variables
Draft

feat: add five discount paywall variables#4134
dpannasch wants to merge 2 commits into
mainfrom
dan/paywall-discount-variables

Conversation

@dpannasch

@dpannasch dpannasch commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Warning

Draft: this has never been compiled or run. There is no JDK or Android SDK on the machine it was written on, so ./gradlew was never invoked — not the module build, not detekt/ktlint, not the tests. Everything below is reasoned from the source, not observed. Someone with a working Android toolchain should build and run it before this leaves draft.

Mirrors the iOS reference implementation (purchases-ios#7546) for five new Paywalls V2 variables. Part of Paywall variables: discounts & secondary offer parity — Milestone 1 of 2.

Siblings: purchases-js#1099, purchases-ui-js#376, revenuecat-app#11647.

Two comparison axes

Variable Compares Example
absolute_discount this package vs. the most expensive package $60.00
offer_relative_discount / offer_absolute_discount an offer vs. its own package's standard price 40% / $4.00
relative_discount_with_offer / absolute_discount_with_offer this package with its offer applied vs. the most expensive package 56% / $66.89

All five select the same anchor — most-expensive per-month base price — so they never disagree about which packages are being compared. PackageContext gains mostExpensivePricePerMonthMicros, defaulted to null so the two V1 construction sites in PackageConfigurationFactory compile untouched.

absolute_discount

(mostExpensivePricePerMonthMicros × periodInMonths) − packagePrice

The anchor is selected by per-month price, exactly as relative_discount does. The saving is then expressed over the purchased package's own period, not per month: a ratio is period-invariant so the unit is arbitrary, but a currency amount changes with whatever unit you quote it in, so it's anchored to the term actually being bought.

offer_relative_discount / offer_absolute_discount

The primary discount phase's price against the same package's standard renewal price, compared raw. Both render empty unless the phase's billing period matches the base period and the price is above zero.

comparableOfferPriceMicros compares the period's unit and value rather than the whole Period. Period is a @Poko class whose generated equals() also covers iso8601, so semantically identical periods spelled "P3M" and "P0Y3M0D" would compare unequal and silently blank a legitimate same-period offer. There's a test for exactly that.

relative_discount_with_offer / absolute_discount_with_offer

The same cross-package comparison as absolute_discount, but priced off the offer the customer would actually get — the "56% off annual" badge on a paywall where annual carries a paid intro offer.

  • The absolute variant spans the offer's full duration (billing period × cycles).
  • When the phase repeats until cancellation there is no such term, so it renders empty — matching what relative_discount already does for a lifetime product. The relative variant still works, since a perpetual discount has a well-defined monthly rate.
  • With no usable offer both collapse to the bare variables. A free offer counts as no offer rather than "100% off".

The cycle count is resolved from RecurrenceMode, not from billingCycleCount being null. Play's getBillingCycleCount() is a primitive int, so toRevenueCatPricingPhase passes through 0 — never null — for non-finite phases, despite what the property's doc comment claims. Treating 0 as "no cycles" multiplied a single-payment offer's total out to zero and silently blanked a fully determinable saving, which is exactly the "$52.99 once, then $99.99/yr" shape this feature targets. There's a regression test stubbing a real NON_RECURRING phase with billingCycleCount = 0.

Notes

  • No new localization strings — percentages reuse the existing PERCENT key, and amounts go through Price.localized, whose getFormatted already rounds down via RoundingMode.DOWN, so a saving is never overstated.
  • Price("", micros, currencyCode) is constructed with an empty formatted because localized() derives the display string from amountMicros; noted in a comment so a future refactor doesn't silently regress it.
  • comparableOfferPriceMicros takes the phase as a receiver so the secondary-offer variants only need secondaryDiscountPhase passed in.

Known limitation: the offer_* pair renders empty for products with a free trial and a discounted intro phase, since primaryDiscountPhase prefers freePhase. That needs secondary_offer_*_discount, which is Milestone 2.

What needs verifying before this leaves draft

  • ./gradlew :ui:revenuecatui:compileReleaseKotlin — the Kotlin has never been through a compiler
  • ./gradlew :ui:revenuecatui:testDebugUnitTest --tests "*DiscountVariableProcessingTests*" — 15 tests, all unrun. The mockk PricingPhase/SubscriptionOption stubs are modelled on OfferVariableProcessingTests, but the stubbed member set may need adjusting.
  • detekt and ktlint. No added line exceeds 120 chars, but nothing else was checked.
  • The $60.00 / $66.89 / $12.00 / 44% / 56% expectations were derived by hand from Period.valueInMonths and Price.getFormatted; confirm against real output.

🤖 Generated with Claude Code

Adds three Paywalls V2 variables to VariableProcessorV2, mirroring the
iOS implementation:

- product.absolute_discount — the currency saving against the most
  expensive package. The anchor is selected by per-month price, exactly as
  product.relative_discount does, so both variables always compare the same
  pair of packages. The saving is then expressed over the purchased
  package's own period: a ratio is period-invariant, so the unit
  relative_discount normalises on is arbitrary, but a currency amount
  changes with whatever unit it's quoted in, so it's anchored to the term
  the customer actually buys.

- product.offer_relative_discount / product.offer_absolute_discount — the
  primary discount phase's price against the same package's standard
  renewal price. Both render empty unless the phase's billing period
  matches the base period and the price is above zero, so a 7-day trial on
  a monthly product doesn't read as a full month's saving.

comparableOfferPriceMicros compares the period's unit and value rather
than the whole Period, whose generated equals() also covers iso8601 —
semantically identical periods spelled "P3M" and "P0Y3M0D" would otherwise
compare unequal and silently blank a legitimate offer. It takes the phase
as a receiver so the secondary-offer variants only need a different phase.

Percentages reuse the existing PERCENT localization key and amounts reuse
Price.localized, whose formatter already rounds down, so no new strings
are needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@RevenueCat-Danger-Bot

Copy link
Copy Markdown
1 Error
🚫 Label the PR using one of the change type labels. If you are not sure which label to use, choose pr:other.
Label Description
pr:feat A new feature. Use along with pr:breaking to force a major release.
pr:fix A bug fix. Use along with pr:force_minor to force a minor release.
pr:other Other changes. Catch-all for anything that doesn't fit the above categories. Releases that only contain this label will not be released. Use along with pr:force_patch, or pr:force_minor to force a patch or minor release.
pr:RevenueCatUI Use along any other tag to mark a PR that only contains RevenueCatUI changes
pr:next_release Preparing a new release
pr:dependencies Updating a dependency
pr:phc_dependencies Updating purchases-hybrid-common dependency
pr:changelog_ignore The PR will not be included in the changelog. This label doesn't determine the type of bump of the version and must be combined with pr:feat, pr:fix or pr:other.

Generated by 🚫 Danger

Adds product.relative_discount_with_offer and
product.absolute_discount_with_offer, mirroring the iOS implementation:
the same cross-package comparison as relative_discount /
absolute_discount, but priced off the offer the customer would actually
get rather than the package's base price.

- The anchor stays the most-expensive package's per-month base price.
- The absolute variant spans the offer's full duration (billing period x
  cycles). When the phase repeats until cancellation there is no such
  term, so it renders empty — matching what relative_discount already
  does for a lifetime product. The relative variant still works there.
- With no usable offer both collapse to the bare variables. A free offer
  counts as no offer rather than "100% off".

The cycle count is resolved from RecurrenceMode rather than from
billingCycleCount being null. Play's getBillingCycleCount() is a
primitive int, so toRevenueCatPricingPhase passes through 0 — never null
— for non-finite phases, despite the property's doc comment. Treating 0
as "no cycles" multiplied a single-payment offer's total out to zero and
silently blanked a fully determinable saving.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dpannasch dpannasch changed the title feat: add absolute_discount and offer discount paywall variables feat: add five discount paywall variables Aug 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants