Skip to content

fix(ios): report StoreKit 2 introductory/trial price, not full price#670

Open
NickSxti wants to merge 2 commits into
developfrom
nch/sup3-154-ios-sdk-storekit-2-reports-introductorytrial-price-as-the
Open

fix(ios): report StoreKit 2 introductory/trial price, not full price#670
NickSxti wants to merge 2 commits into
developfrom
nch/sup3-154-ios-sdk-storekit-2-reports-introductorytrial-price-as-the

Conversation

@NickSxti

@NickSxti NickSxti commented May 26, 2026

Copy link
Copy Markdown
Contributor

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) set introductory_offer.value = purchaseModel.price (the standard price) instead of purchaseModel.introductoryPrice. The model already carried the correct value from PurchasesMapper (StoreKit's introductoryOffer.price); the serializer simply read the wrong field.

While fixing this, the sibling promo_offer block 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_offer block on introductoryPrice != nil and read introductoryPrice for the value. One change fixes two defects:

  • Wrong value: the intro/trial price is now reported correctly (free trial -> "0", paid intro -> discounted price).
  • Spurious block: the old introOffer.count > 0 guard was always true because price is nonnull, so an introductory_offer was emitted even for purchases with no offer. The new guard omits it.

Promotional offer

The SK2 promo_offer block was built and attached unconditionally, so an empty promo_offer {} was emitted on every purchase that had no promotional offer - the same spurious-block pattern. Guard it on promoOfferId != nil.

Both fixes mirror the existing SK1 path (purchaseData:..., which guards introductory_offer on product.introductoryPrice != nil and promo_offer on the purchased offer id) and PurchasesMapper (which populates the introductory_* / promoOffer_* fields only when an offer exists). SK1 and SK2 send to the same purchase endpoint, which already tolerates an absent introductory_offer / promo_offer because 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 in QRequestSerializerTests.m:

  • paid intro: every introductory_offer field is asserted; value equals the discounted price and not the full price
  • free trial: value equals "0" (not dropped)
  • no offer: introductory_offer is omitted
  • promo present: every promo_offer field is reported
  • no promo: promo_offer is omitted

Verified with xcodebuild test -only-testing:QonversionTests/QNRequestSerializerTests (6/6 passing).

Notes

  • Analytics/reporting only; entitlements and Apple billing are unaffected.
  • Historical SK2 rows remain wrong and would need an App Store Server API backfill (the original payloads carry the wrong value) - out of scope here.

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>
@NickSxti NickSxti requested review from SpertsyanKM and nikita-ushakov and removed request for nikita-ushakov May 26, 2026 13:15
introOffer[@"payment_mode"] = purchaseModel.introductoryPaymentMode;

result[@"introductory_offer"] = introOffer.count > 0 ? introOffer : nil;
if (purchaseModel.introductoryPrice != nil) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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];

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant