Skip to content

feat(paywalls): add five discount variables - #7546

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

feat(paywalls): add five discount variables#7546
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

Reference implementation for five new Paywalls V2 variables. Part of Paywall variables: discounts & secondary offer parity — Milestone 1 of 2; the secondary-offer variables and the iOS secondary-offer gap fix are Milestone 2.

Siblings: purchases-android#4134, 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.

absolute_discount

(mostExpensivePricePerMonth × thisPackagePeriodInMonths) − thisPackagePrice

The anchor is selected by per-month price, exactly as product.relative_discount does. The saving is then expressed over the purchased package's own period, not per month. A ratio is period-invariant — per week, month or year all give the same percentage — so the unit relative_discount normalises on is arbitrary and harmless. A currency amount has no such property, so the unit has to be a deliberate choice. Anchoring it to the term actually being purchased is what makes it meaningful, and avoids shipping an absolute_discount_per_day/_week/_month/_year family just to disambiguate.

offer_relative_discount / offer_absolute_discount

The resolved 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:

Offer shape Renders
3 cycles @ $5.99 on a $9.99/mo base 40% / $4.00
3 months @ $15.00 on a $27.00/3mo base 44% / $12.00
7-day trial on a monthly product ""
Pay-up-front "$5.99 for 3 months" on a $9.99/mo base ""

The period comparison checks unit and value explicitly rather than == on SubscriptionPeriod, which is an NSObject subclass where value equality isn't obvious at the call site.

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. 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 × numberOfPeriods), since that's the term being purchased.
  • 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".

effectiveOfferTerm returns the offer's total price and span rather than a monthly rate on purpose: StoreProductDiscount.pricePerMonth is already rounded to two places, and scaling that back up by the duration amplifies the rounding error by the number of months. A $52.99 intro year against a $9.99/mo anchor rendered $200.88 instead of $66.89 before this was fixed; there's a test pinning the correct value.

Notes on shape

  • The anchor reaches the handler as mostExpensivePricePerMonth rather than a precomputed amount, so the normalisation is unit-testable alongside every other variable instead of living in TextComponentViewModel. There's exactly one non-test construction site for VariableHandlerV2.
  • Derived savings round down via NSDecimalNumberHandler, matching SubscriptionPeriod.pricePerPeriod, so a saving is never overstated. A saving that rounds to zero renders empty rather than "Save $0.00".
  • comparableOfferPrice takes the discount as a parameter, so adding the secondary-offer variants later only needs a different discount passed in.
  • No new localization keys — percentages reuse percent, the same key relative_discount uses.
  • VariablesV2 is internal, so no public API surface changes and no new public enum.

Known limitation: the offer_* pair renders empty for products with a free trial and a discounted intro phase, since resolvedDiscount prefers the trial. That needs secondary_offer_*_discount, which is Milestone 2 and depends on the iOS secondary-offer gap fix.

Verification

  • swift build — clean
  • swift test --filter VariableHandlerV2Test — 100 passed, including 14 new
  • swiftlint not run locally (not installed on this machine); no added line exceeds 120 chars

🤖 Generated with Claude Code

Adds three Paywalls V2 variables to VariableHandlerV2:

- 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
  resolved offer's price against the same package's standard renewal
  price. 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.

The anchor reaches the handler as mostExpensivePricePerMonth rather than a
precomputed amount, so the normalisation is unit-testable alongside every
other variable. Derived savings round down via NSDecimalNumberHandler,
matching SubscriptionPeriod.pricePerPeriod, and a saving that rounds to
zero renders empty rather than "$0.00".

comparableOfferPrice takes the discount as a parameter so the
secondary-offer variants only need a different discount passed in.

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

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit dda403e. Configure here.

// Round down, matching `SubscriptionPeriod.pricePerPeriod`, so a derived saving
// never overstates what the customer actually saves.
let saving = ((anchorPriceForThisPeriod - package.storeProduct.price) as NSDecimalNumber)
.rounding(accordingToBehavior: Self.savingRoundingBehavior) as Decimal

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Float conversion understates absolute discount

Medium Severity

productAbsoluteDiscount builds the savings amount by converting mostExpensivePricePerMonth from Double to Decimal and then rounding that result down to two places. Many real price points (such as $1.99 and $19.99) are slightly under their true decimal value as Double, so multiplying by the package period and flooring can show a cent less than the real saving, or hide a one-cent saving entirely. The new tests use $10.00, which is exact in binary floating point, so they do not catch this.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit dda403e. Configure here.

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
  numberOfPeriods), since that is the term being purchased.
- 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".

effectiveOfferTerm returns the offer's total price and span rather than a
monthly rate on purpose: StoreProductDiscount.pricePerMonth is already
rounded to two places, and scaling that back up by the duration amplified
the rounding error by the number of months (a $52.99 intro year against a
$9.99/mo anchor rendered $200.88 instead of $66.89).

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

Labels

pr:feat A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant