fix(ios): report StoreKit 2 introductory/trial price, not full price#670
fix(ios): report StoreKit 2 introductory/trial price, not full price#670NickSxti wants to merge 2 commits into
Conversation
QNRequestSerializer purchaseInfo:receipt: (the StoreKit 2 path) set introductory_offer.value to purchaseModel.price (the full subscription price) instead of purchaseModel.introductoryPrice. Intro and free-trial purchases were therefore reported at the full price, overstating first-period revenue. The StoreKit 1 path was unaffected. Guard the introductory_offer block on introductoryPrice != nil, mirroring the SK1 path and PurchasesMapper (which populates the introductory_* fields only when an offer exists), and read introductoryPrice for the value. This also stops a spurious introductory_offer being emitted for no-offer purchases: the previous introOffer.count > 0 guard was always true because price is nonnull. Add regression tests for the SK2 path: paid intro, free trial (0), and no-offer omission. Fixes SUP3-154 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| introOffer[@"payment_mode"] = purchaseModel.introductoryPaymentMode; | ||
|
|
||
| result[@"introductory_offer"] = introOffer.count > 0 ? introOffer : nil; | ||
| if (purchaseModel.introductoryPrice != nil) { |
There was a problem hiding this comment.
Behavioral note: this guard also changes the no-offer case. Previously an introductory_offer block was emitted on every purchase here, because the old introOffer.count > 0 check was always true (the value was set from price, which is nonnull). With this guard, purchases without an introductory offer omit the block entirely.
That matches the StoreKit 1 path above (purchaseData: guards on product.introductoryPrice != nil), so it should be safe - just flagging that the request body shape changes for no-offer StoreKit 2 purchases, in case anything consuming the payload expects the block to always be present.
| result[@"introductory_offer"] = introOffer; | ||
| } | ||
|
|
||
| NSMutableDictionary *promoOffer = [[NSMutableDictionary alloc] init]; |
There was a problem hiding this comment.
Minor, and out of scope for this fix: the promo_offer block is still built and assigned unconditionally (result[@"promo_offer"] = [promoOffer copy] a few lines down), so a purchase with no promotional offer sends an empty promo_offer: {}. That is the same always-emitted-block pattern this PR just fixed for introductory_offer.
The StoreKit 1 path guards the equivalent block on offerId.length > 0. Might be worth a follow-up to guard this on promoOfferId != nil for parity.
The SK2 purchaseInfo:receipt: path built and attached promo_offer
unconditionally, so an empty promo_offer {} was emitted on every purchase
without a promotional offer. This is the same spurious-block pattern just
fixed for introductory_offer in this PR.
Guard the block on promoOfferId != nil, mirroring the SK1 purchaseData:
path (which guards on the purchased offer id) and PurchasesMapper (which
populates the promoOffer_* fields only when a promotional offer was
applied). Both SK1 and SK2 feed the same purchase endpoint, which already
tolerates an absent promo_offer, so this is a no-op for the wire format of
real promo purchases and only drops the empty dict.
Strengthen the paid-intro test to assert every introductory_offer field
(not just value), and add SK2 promo_offer coverage: values reported when a
promo is present, block omitted when it is not.
Verified with xcodebuild test -only-testing:QonversionTests/QNRequestSerializerTests (6/6 passing).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Fixes SUP3-154. On iOS, the StoreKit 2 purchase flow reported the introductory/trial price as the full subscription price, overstating first-period revenue. StoreKit 1 was unaffected.
QNRequestSerializer.purchaseInfo:receipt:(the SK2 path) setintroductory_offer.value = purchaseModel.price(the standard price) instead ofpurchaseModel.introductoryPrice. The model already carried the correct value fromPurchasesMapper(StoreKit'sintroductoryOffer.price); the serializer simply read the wrong field.While fixing this, the sibling
promo_offerblock in the same method turned out to have the same spurious-block defect, so it is corrected in a follow-up commit.Fix
Introductory offer
Guard the
introductory_offerblock onintroductoryPrice != niland readintroductoryPricefor the value. One change fixes two defects:introOffer.count > 0guard was always true becausepriceisnonnull, so anintroductory_offerwas emitted even for purchases with no offer. The new guard omits it.Promotional offer
The SK2
promo_offerblock was built and attached unconditionally, so an emptypromo_offer {}was emitted on every purchase that had no promotional offer - the same spurious-block pattern. Guard it onpromoOfferId != nil.Both fixes mirror the existing SK1 path (
purchaseData:..., which guardsintroductory_offeronproduct.introductoryPrice != nilandpromo_offeron the purchased offer id) andPurchasesMapper(which populates theintroductory_*/promoOffer_*fields only when an offer exists). SK1 and SK2 send to the same purchase endpoint, which already tolerates an absentintroductory_offer/promo_offerbecause the SK1 path omits them - so real offer purchases are unchanged on the wire and only the empty/phantom blocks are dropped.Tests
Adds regression coverage for the previously-untested SK2
purchaseInfo:receipt:path inQRequestSerializerTests.m:introductory_offerfield is asserted;valueequals the discounted price and not the full pricevalueequals "0" (not dropped)introductory_offeris omittedpromo_offerfield is reportedpromo_offeris omittedVerified with
xcodebuild test -only-testing:QonversionTests/QNRequestSerializerTests(6/6 passing).Notes