Skip to content

Add DeferredPurchasesListener, deprecate EntitlementsUpdateListener#423

Open
NickSxti wants to merge 4 commits intodevelopfrom
feature/dev-643-deferred-purchases-listener
Open

Add DeferredPurchasesListener, deprecate EntitlementsUpdateListener#423
NickSxti wants to merge 4 commits intodevelopfrom
feature/dev-643-deferred-purchases-listener

Conversation

@NickSxti
Copy link
Contributor

@NickSxti NickSxti commented Mar 10, 2026

Summary

  • Introduce DeferredPurchasesListener interface with onDeferredPurchaseCompleted method for specifically handling deferred (pending) purchase completions
  • Deprecate EntitlementsUpdateListener (still works for backward compat — fires for ALL entitlement updates)
  • Add JS-layer correlation filtering: track product IDs from purchaseWithResult when status is PENDING, match against onEntitlementsUpdated events to detect deferred purchase completions
  • Both listeners coexist: old fires for all updates, new fires only for tracked deferred purchases
  • Native side unchanged — single onEntitlementsUpdated event, filtering is JS-only
  • 12 TDD tests covering all edge cases

Architecture

purchaseWithResult(status: PENDING)
  → pendingPurchaseProductIds.add(productId)

onEntitlementsUpdated (native event)
  → entitlementsUpdateListener?.onEntitlementsUpdated(entitlements)  // always fires
  → if hasPendingPurchaseCompleted(entitlements):
      deferredPurchasesListener?.onDeferredPurchaseCompleted(entitlements)  // only for deferred
      pendingPurchaseProductIds.delete(productId)  // prevent double-fire

New public API

  • DeferredPurchasesListener interface (exported from index)
  • QonversionConfigBuilder.setDeferredPurchasesListener(listener)
  • QonversionApi.setDeferredPurchasesListener(listener) (runtime setter)

Backward compatibility

  • Existing setEntitlementsUpdateListener still works (marked @deprecated)
  • Old listener set via config constructor still works
  • No changes to native iOS/Android modules

Test plan

  • 12 unit tests (TDD: Red → Green) covering:
    • Pending purchase tracking from purchaseWithResult
    • Filtering: fires only for tracked pending products
    • No-fire when no pending purchases exist
    • Cleanup after deferred completion (no double-fire)
    • Success purchases are NOT tracked
    • Listener replacement
    • Old listener backward compat (fires for all updates)
    • Both listeners coexist independently
    • Single native event subscription (no duplication)
  • TypeScript compilation passes
  • iOS example app builds

Known limitations

  • Pending purchase tracking is in-memory — lost on app restart (acceptable: sandwich will add native filtering later)
  • Only purchaseWithResult tracks pending status (deprecated purchase/purchaseProduct don't return PurchaseResult)

🤖 Generated with Claude Code

NickSxti and others added 2 commits March 10, 2026 21:03
…Listener

Introduces a new DeferredPurchasesListener with onDeferredPurchaseCompleted
callback for deferred purchase completions (SCA, Ask to Buy, etc.).

- New DeferredPurchasesListener interface and TurboModule event
- Adapter pattern: deprecated setEntitlementsUpdateListener wraps to new interface
- Config supports both listeners, new takes priority
- iOS/Android native bridges emit both events from entitlements delegate
- 9 unit tests covering new listener, adapter, config, backward compat

DEV-643

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The adapter pattern had a future-breaking bug: when sandwich adds real
deferred purchase filtering, old customers using setEntitlementsUpdateListener
would silently lose non-deferred updates because the adapter routed them
through onDeferredPurchaseCompleted.

Fix: two independent listener slots, each with its own native event.
- setEntitlementsUpdateListener → onEntitlementsUpdated (all updates)
- setDeferredPurchasesListener → onDeferredPurchaseCompleted (deferred only)

Both can coexist. Old code is future-proof when filtering is added.
Tests updated: 11 cases (was 9).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@NickSxti
Copy link
Contributor Author

Self-Review

What this PR does now

  • New DeferredPurchasesListener interface with onDeferredPurchaseCompleted
  • Deprecated EntitlementsUpdateListener (JSDoc @deprecated)
  • Two independent listener slots — each subscribed to its own native event
  • Native bridges (iOS/Android) emit both onEntitlementsUpdated and onDeferredPurchaseCompleted from the same delegate callback
  • 11 unit tests

What this PR does NOT do

No actual deferred purchase filtering yet. Both events currently fire from the same source (qonversionDidReceiveUpdatedEntitlements). A customer setting setDeferredPurchasesListener will receive ALL entitlement updates — not just deferred ones.

True filtering requires one of:

  1. QonversionSandwich change (preferred): add qonversionDidCompleteDeferredPurchase protocol method that only fires when a pending transaction completes
  2. JS-layer correlation heuristic: track product IDs from purchaseWithResult calls that return status: PENDING, then match against subsequent entitlement updates. Limitations: lost on app restart, possible false positives.

Architecture decision: separate slots, not adapter

The initial commit used an adapter pattern (setEntitlementsUpdateListener → wraps → setDeferredPurchasesListener). This was reverted in the second commit because it had a future-breaking bug: when sandwich adds real filtering, old customers using setEntitlementsUpdateListener would silently lose non-deferred updates.

Current architecture:

Method Native event Fires for
setEntitlementsUpdateListener (deprecated) onEntitlementsUpdated All updates (always)
setDeferredPurchasesListener (new) onDeferredPurchaseCompleted All updates now → deferred only after sandwich change

Both can coexist. Old code is future-proof.

Remaining work

  • Build & test Android (no JDK 17+ locally — CI will verify)
  • Sandwich SDK: add dedicated deferred purchase callback
  • Wire onDeferredPurchaseCompleted exclusively to new sandwich callback
  • Remove onDeferredPurchaseCompleted emission from general entitlements delegate

Track pending purchase product IDs from purchaseWithResult, correlate
against entitlement updates to fire deferredPurchasesListener only for
actual deferred purchase completions. Revert native-side dual emission
back to single onEntitlementsUpdated — filtering is handled in JS.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@NickSxti
Copy link
Contributor Author

LEVER Self-Review

Logic

✅ Correlation filtering correctly tracks pendingPurchaseProductIds from purchaseWithResult when isPending is true, and matches against entitlement.productId + entitlement.isActive in updates.

⚠️ In-memory tracking limitation: If the app is killed between a pending purchase and the entitlement update, the deferred listener won't fire. This is acceptable because:

  1. The sandwich layer will add native-side filtering in a future version
  2. The entitlementsUpdateListener (deprecated) still fires for ALL updates as a fallback

⚠️ Only purchaseWithResult tracks pending: The deprecated purchase() and purchaseProduct() methods don't return PurchaseResult, so they can't detect pending status. Customers using old purchase methods won't get deferred callbacks — they should use purchaseWithResult which is the recommended API.

Edge cases

✅ No double-fire: pendingPurchaseProductIds.delete() removes tracking after first match
✅ Success purchases not tracked (only PENDING triggers tracking)
✅ Unrelated entitlement updates don't trigger deferred listener
✅ Listener replacement works (old listener stops receiving, new one takes over)
✅ Single event subscription even when both listeners are set

Risks

  • Low: If multiple products are pending simultaneously, hasPendingPurchaseCompleted returns true on first match, so a single entitlement update containing multiple pending products will only fire the deferred listener once. This seems correct — one callback per update event.

Backward compatibility

✅ Old EntitlementsUpdateListener set via config or setEntitlementsUpdateListener() still fires for ALL entitlement updates
✅ No native code changes — native modules only emit onEntitlementsUpdated
onDeferredPurchaseCompleted event kept in TurboModule spec as reserved for future sandwich support

Verification

✅ 12 TDD tests pass (Red → Green methodology)
✅ TypeScript compiles cleanly (tsc --noEmit)
✅ iOS example app builds

Replace JS-layer filtering with native DeferredPurchasesListener from
Sandwich SDK. The listener now receives a DeferredTransaction object
with full transaction details instead of entitlements.

- Add DeferredTransaction model
- Wire native onDeferredPurchaseCompleted event (iOS + Android)
- Remove JS-side pendingPurchaseProductIds tracking
- Update DeferredPurchasesListener to use DeferredTransaction
- 10 tests passing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Contributor Author

@NickSxti NickSxti left a comment

Choose a reason for hiding this comment

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

Self-review notes after rework to native events:

Architecture change - Replaced JS-layer pendingPurchaseProductIds filtering with direct native onDeferredPurchaseCompleted event from Sandwich SDK. This is more reliable (no in-memory state lost on app restart) and consistent with how the native SDKs work.

Breaking change in DeferredPurchasesListener interface - onDeferredPurchaseCompleted now receives a DeferredTransaction object (with productId, transactionId, originalTransactionId, type, value, currency) instead of Map<string, Entitlement>. Since this was not yet released, this is fine.

Native module changes:

  • iOS: Added qonversionDidCompleteDeferredPurchase to QonversionEventDelegate protocol and QonversionEventHandler, emits via emitOnDeferredPurchaseCompleted in RNQonversion.mm
  • Android: Added onDeferredPurchaseCompleted override in QonversionModule.kt, emits via emitOnDeferredPurchaseCompleted

Event subscriptions are now independent - setDeferredPurchasesListener subscribes to onDeferredPurchaseCompleted (not onEntitlementsUpdated), so each listener has its own native event source.

10 tests passing covering: native event subscription, DeferredTransaction payload, listener replacement, independent event subscriptions, coexistence with EntitlementsUpdateListener.

Depends on: Sandwich SDK PR qonversion/sandwich-sdk#302

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