feat: add five discount paywall variables - #4134
Draft
dpannasch wants to merge 2 commits into
Draft
Conversation
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Warning
Draft: this has never been compiled or run. There is no JDK or Android SDK on the machine it was written on, so
./gradlewwas 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
absolute_discount$60.00offer_relative_discount/offer_absolute_discount40%/$4.00relative_discount_with_offer/absolute_discount_with_offer56%/$66.89All five select the same anchor — most-expensive per-month base price — so they never disagree about which packages are being compared.
PackageContextgainsmostExpensivePricePerMonthMicros, defaulted to null so the two V1 construction sites inPackageConfigurationFactorycompile untouched.absolute_discountThe anchor is selected by per-month price, exactly as
relative_discountdoes. 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_discountThe 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.
comparableOfferPriceMicroscompares the period'sunitandvaluerather than the wholePeriod.Periodis a@Pokoclass whose generatedequals()also coversiso8601, 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_offerThe 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.relative_discountalready does for a lifetime product. The relative variant still works, since a perpetual discount has a well-defined monthly rate.The cycle count is resolved from
RecurrenceMode, not frombillingCycleCountbeing null. Play'sgetBillingCycleCount()is a primitiveint, sotoRevenueCatPricingPhasepasses through0— never null — for non-finite phases, despite what the property's doc comment claims. Treating0as "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 realNON_RECURRINGphase withbillingCycleCount = 0.Notes
PERCENTkey, and amounts go throughPrice.localized, whosegetFormattedalready rounds down viaRoundingMode.DOWN, so a saving is never overstated.Price("", micros, currencyCode)is constructed with an emptyformattedbecauselocalized()derives the display string fromamountMicros; noted in a comment so a future refactor doesn't silently regress it.comparableOfferPriceMicrostakes the phase as a receiver so the secondary-offer variants only needsecondaryDiscountPhasepassed in.Known limitation: the
offer_*pair renders empty for products with a free trial and a discounted intro phase, sinceprimaryDiscountPhaseprefersfreePhase. That needssecondary_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 mockkPricingPhase/SubscriptionOptionstubs are modelled onOfferVariableProcessingTests, but the stubbed member set may need adjusting.$60.00/$66.89/$12.00/44%/56%expectations were derived by hand fromPeriod.valueInMonthsandPrice.getFormatted; confirm against real output.🤖 Generated with Claude Code