feat(paywalls): add five discount variables - #7546
Conversation
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>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ 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 |
There was a problem hiding this comment.
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.
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>


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
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
product.relative_discountdoes. 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 unitrelative_discountnormalises 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 anabsolute_discount_per_day/_week/_month/_yearfamily just to disambiguate.offer_relative_discount/offer_absolute_discountThe 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:
40%/$4.0044%/$12.00""""The period comparison checks
unitandvalueexplicitly rather than==onSubscriptionPeriod, which is anNSObjectsubclass where value equality isn't obvious at the call site.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. 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.numberOfPeriods), since that's the term being purchased.effectiveOfferTermreturns the offer's total price and span rather than a monthly rate on purpose:StoreProductDiscount.pricePerMonthis 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
mostExpensivePricePerMonthrather than a precomputed amount, so the normalisation is unit-testable alongside every other variable instead of living inTextComponentViewModel. There's exactly one non-test construction site forVariableHandlerV2.NSDecimalNumberHandler, matchingSubscriptionPeriod.pricePerPeriod, so a saving is never overstated. A saving that rounds to zero renders empty rather than"Save $0.00".comparableOfferPricetakes the discount as a parameter, so adding the secondary-offer variants later only needs a different discount passed in.percent, the same keyrelative_discountuses.VariablesV2is 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, sinceresolvedDiscountprefers the trial. That needssecondary_offer_*_discount, which is Milestone 2 and depends on the iOS secondary-offer gap fix.Verification
swift build— cleanswift test --filter VariableHandlerV2Test— 100 passed, including 14 new🤖 Generated with Claude Code