feat: add five discount paywall variables - #1099
Open
dpannasch wants to merge 2 commits into
Open
Conversation
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>
Generated by 🚫 Danger |
This was referenced Aug 28, 2026
… 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>
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.
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
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.
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 unitrelative_discountnormalises 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 anabsolute_discount_per_day/_week/_month/_yearfamily just to disambiguate.offer_relative_discount/offer_absolute_discountThe 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
toDiscountPhasenormalises their period to a single unit with the count moved intocycleCount, 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_offerThe 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_discountdoes not produce that number, since it compares annual's offer to annual's own base.durationMode: "forever") there is no such term, so the absolute variant renders empty — matching whatrelative_discountalready does for a lifetime product. The relative variant still works, since a perpetual discount has a well-defined monthly rate.Notes
paywall_variables.sub_relative_discount, so none of the 30+ locale files are touched.floorMicrosToCurrencyUnit, so a saving is never overstated — matchinggetPriceVariablesandtoPricingPhase.NaN;NaN < 1is false, so the suppression failed open and rendered"NaN%". Now guarded.offerRatePeriodhandles every discount phase, whereas the pre-existinggetOfferPricingPeriodonly special-casestime_window. They disagree for aforeverdiscount on a multi-unit base plan, whereoffer_price_per_*still uses the normalised period. Documented in a comment — changing it would move a value already rendering on live paywalls.<1%suppression onoffer_relative_discount, unlike the package-levelrelative_discount, to keep it consistent with iOS and Android. Happy to add it to all three if reviewers prefer.offer_*pair renders empty for products with a free trial and a discounted intro phase, becauseprimaryOfferresolves to the trial. That needssecondary_offer_*_discount, which is Milestone 2.Verification
pnpm test— 851 passed (52 files), including 21 new testspnpm run typecheck— cleanpnpm lint— clean🤖 Generated with Claude Code