Skip to content

feat: add five discount paywall variables - #1099

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

feat: add five discount paywall variables#1099
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

Adds five Paywalls V2 variables. Part of Paywall variables: discounts & secondary offer parity — Milestone 1 of 2; the secondary-offer variables are Milestone 2.

Pairs with purchases-ui-js#376 for autocomplete only — not a build dependency.

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.

absolute_discount

(mostExpensivePricePerMonth × thisPackagePeriodInMonths) − thisPackagePrice

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 relative_discount normalises on is arbitrary; a currency amount changes with whatever unit you quote it in, so it's anchored to the term actually being bought — and that avoids shipping an absolute_discount_per_day/_week/_month/_year family just to disambiguate.

offer_relative_discount / offer_absolute_discount

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

Discount phases are exempt from the period check: they always replace the base plan's own renewal price, and toDiscountPhase normalises their period to a single unit with the count moved into cycleCount, so a discount on a 3-month plan carries {number: 1, unit: Month} and a raw comparison would wrongly reject it. There's a regression test for that.

relative_discount_with_offer / absolute_discount_with_offer

The same cross-package comparison as the first two, but priced off the offer the customer would actually get. This is the "56% off annual" badge on a paywall where annual carries a paid intro offer — offer_relative_discount does not produce that number, since it compares annual's offer to annual's own base.

  • The absolute variant spans the offer's full duration (billing period × cycles), since that's the term being purchased.
  • When the offer never reverts (durationMode: "forever") there is no such term, so the absolute variant 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, so they can be used unconditionally rather than behind show/hide rules. A free trial counts as no offer rather than "100% off".

Notes

  • No locale changes. Percentages reuse paywall_variables.sub_relative_discount, so none of the 30+ locale files are touched.
  • Derived amounts are floored via floorMicrosToCurrencyUnit, so a saving is never overstated — matching getPriceVariables and toPricingPhase.
  • Drive-by fix: the existing per-month discount ratio divided by zero when every package was free, producing NaN; NaN < 1 is false, so the suppression failed open and rendered "NaN%". Now guarded.
  • Latent bug flagged, not fixed: offerRatePeriod handles every discount phase, whereas the pre-existing getOfferPricingPeriod only special-cases time_window. They disagree for a forever discount on a multi-unit base plan, where offer_price_per_* still uses the normalised period. Documented in a comment — changing it would move a value already rendering on live paywalls.
  • No <1% suppression on offer_relative_discount, unlike the package-level relative_discount, to keep it consistent with iOS and Android. Happy to add it to all three if reviewers prefer.
  • Known limitation: the offer_* pair renders empty for products with a free trial and a discounted intro phase, because primaryOffer resolves to the trial. That needs secondary_offer_*_discount, which is Milestone 2.

Verification

  • pnpm test — 851 passed (52 files), including 21 new tests
  • pnpm run typecheck — clean
  • pnpm lint — clean

🤖 Generated with Claude Code

Adds three Paywalls V2 variables:

- `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 normalising it per
  month is harmless, 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 offer's price against the same package's standard renewal
  price. Both render empty unless the two prices cover the same period and
  the offer isn't free, so a 7-day trial on a monthly product doesn't read
  as a full month's saving and a free trial doesn't read as "100% off".
  Discount phases are exempt from the period check because they always
  replace the base plan's own renewal price, and `toDiscountPhase`
  normalises their period to a single unit with the count in `cycleCount`.

Percentages reuse the existing `paywall_variables.sub_relative_discount`
string, so no locale files change. Derived amounts are floored via
`floorMicrosToCurrencyUnit` so a saving is never overstated, matching
`getPriceVariables` and `toPricingPhase`.

Also guards the existing per-month discount ratio against a zero-priced
anchor, which produced `NaN` and rendered "NaN%".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dpannasch
dpannasch requested a review from a team as a code owner August 28, 2026 15:01
@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

… offer

Adds `product.relative_discount_with_offer` and
`product.absolute_discount_with_offer`: 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.

This is the case a paywall hits when annual carries a paid intro offer and
the badge should read "56% off" against monthly. `offer_relative_discount`
does not cover it — that compares an offer to its own package's standard
price, a different number.

- The anchor stays the most-expensive package's per-month base price, so
  it doesn't move with per-customer offer eligibility.
- The absolute variant spans the offer's full duration (billing period x
  cycles), since that is the term being purchased. When the offer never
  reverts 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, since a perpetual discount has a monthly rate.
- With no usable offer both collapse to the bare variables, so they can be
  used unconditionally rather than behind show/hide rules. A free trial
  counts as no offer rather than "100% off".

`getOfferRate` prices discount phases against the base period, because
`toDiscountPhase` normalizes a discount's own period to a single unit and
moves the count into `cycleCount`.

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