Skip to content

Fix: Include product ID in Stripe checkout swipe-dismiss abandon#453

Closed
yusuftor wants to merge 4 commits intodevelopfrom
fix/stripe-checkout-abandon-product-id
Closed

Fix: Include product ID in Stripe checkout swipe-dismiss abandon#453
yusuftor wants to merge 4 commits intodevelopfrom
fix/stripe-checkout-abandon-product-id

Conversation

@yusuftor
Copy link
Collaborator

@yusuftor yusuftor commented Mar 12, 2026

Goes with this PR: https://github.com/superwall/paywall-next/pull/2833/changes

Summary

  • When a user swipes to dismiss the Stripe checkout sheet (instead of the JS stripe_checkout_abandon message firing), the fallback abandon path used StoreProduct.blank() with no product identifier, resulting in an empty abandoned_product_id.
  • Adds handleStripeCheckoutStart(productId:) to PaywallMessageHandlerDelegate to forward the product ID from stripeCheckoutStart.
  • Stores the product ID in lastStripeCheckoutProductId on PaywallViewController and uses it in the fallback abandon: StoreProduct.blank(productIdentifier:).

Test plan

  • Verify that swiping to dismiss the Stripe checkout sheet tracks the correct product ID in the transaction abandon event.
  • Verify that the normal JS stripe_checkout_abandon path still works as before.
  • Verify that lastStripeCheckoutProductId is reset when opening a new checkout and after handling dismiss.

  • All unit tests pass.
  • All UI tests pass.
  • Demo project builds and runs on iOS.
  • Demo project builds and runs on Mac Catalyst.
  • Demo project builds and runs on visionOS.
  • I added/updated tests or detailed why my change isn't tested.
  • I added an entry to the CHANGELOG.md for any breaking changes, enhancements, or bug fixes.
  • I have run swiftlint in the main directory and fixed any issues.
  • I have updated the SDK documentation as well as the online docs.
  • I have reviewed the contributing guide

🤖 Generated with Claude Code

Greptile Summary

This PR fixes a bug where swiping to dismiss the Stripe checkout sheet produced a transaction abandon event with an empty abandoned_product_id. It does so by threading the productIdentifier from the open_url (payment_sheet) JS message all the way through PaywallMessage, PaywallMessageHandler, and PaywallMessageHandlerDelegate into PaywallViewController, where it is stored in lastStripeCheckoutProductId and used in the swipe-dismiss fallback abandon path.

Key changes:

  • PaywallMessage.openPaymentSheet gains an optional productId: String? associated value, decoded with try? for backward compatibility.
  • PaywallMessageHandlerDelegate.openPaymentSheet(_:productId:) signature updated and propagated through PaywallMessageHandler.
  • PaywallViewController stores lastStripeCheckoutProductId when opening checkout, captures it as a local constant before the DispatchWorkItem, and resets it to nil in onDismiss — correctly avoiding any race with the reset at end of the closure.
  • The mock delegate is updated to capture openPaymentSheetProductId for test assertions.

One area worth revisiting: lastStripeCheckoutProductId is sourced exclusively from the optional productIdentifier field in the open_url message. The PR description originally intended to also hook into the stripeCheckoutStart event (which carries a non-optional productId), providing a reliable second chance to populate the field when older backends don't include productIdentifier in the open_url payload. If that source is dropped, a warning log when falling back to "" would improve debuggability.

Confidence Score: 4/5

  • PR is safe to merge; the fix is correct and backward-compatible, with one minor robustness concern about the empty-string fallback path.
  • The core logic is sound: the product ID is captured as a local constant before the workItem and before the nil-reset, eliminating any race condition. The change is backward-compatible — when productIdentifier is absent from the open_url payload the behaviour is identical to before. The only concern is that the stripeCheckoutStart event's non-optional productId (planned in the PR description as an additional source) was not wired up, leaving a silent empty-string fallback if the new backend field is absent. This is a minor robustness gap, not a correctness bug, so a score of 4 is appropriate.
  • No files require special attention, though the onDismiss block in PaywallViewController.swift around line 1163 is worth a final read to confirm the empty-string fallback behaviour is intentional.

Important Files Changed

Filename Overview
Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift Adds lastStripeCheckoutProductId: String? to store the product ID from openPaymentSheet, captures it as a local constant before the onDismiss reset, and uses it in the swipe-dismiss abandon event. Logic is sound and backward-compatible; captures abandonProductId as a local let before the workItem, avoiding any race with the nil-reset at the end of the closure.
Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessage.swift Adds optional productId: String? to the openPaymentSheet enum case and decodes it with try? from the open_url message payload when browserType == "payment_sheet". The decode is safely optional, preserving backward compatibility with backends that don't yet send the field.
Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift Propagates the new optional productId through the private openPaymentSheet helper and the PaywallMessageHandlerDelegate protocol. Purely mechanical pass-through with no logic changes; no issues found.
Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerDelegateMock.swift Mock updated to match the new openPaymentSheet(_:productId:) signature and captures the passed-in productId for test assertions. No issues found.

Sequence Diagram

sequenceDiagram
    participant JS as Paywall JS
    participant MH as PaywallMessageHandler
    participant PVC as PaywallViewController
    participant CVC as CheckoutWebViewController

    JS->>MH: open_url (browserType=payment_sheet, productIdentifier?)
    MH->>PVC: openPaymentSheet(url, productId?)
    PVC->>PVC: lastStripeCheckoutProductId = productId
    PVC->>CVC: present(checkoutVC)

    alt Stripe JS fires checkout start
        JS->>MH: stripe_checkout_start (productId)
        MH->>MH: trackStripeCheckoutEvent(.start)
        note over MH,PVC: lastStripeCheckoutProductId NOT updated here
    end

    alt User swipe-dismisses (no JS abandon message)
        CVC-->>PVC: onDismiss callback
        PVC->>PVC: abandonProductId = lastStripeCheckoutProductId ?? ""
        PVC->>PVC: track Transaction(.abandon(StoreProduct.blank(productIdentifier: abandonProductId)))
        PVC->>PVC: lastStripeCheckoutProductId = nil
    else JS fires stripe_checkout_abandon
        JS->>MH: stripe_checkout_abandon (productId)
        MH->>PVC: handleStripeCheckoutAbandon(checkoutContextId, productId)
        PVC->>PVC: didReceiveStripeCheckoutAbandonMessage = true
        CVC-->>PVC: onDismiss callback
        PVC->>PVC: skip abandon tracking (flag already set)
        PVC->>PVC: lastStripeCheckoutProductId = nil
    end
Loading

Comments Outside Diff (2)

  1. Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift, line 1163-1168 ([link](https://github.com/superwall/superwall-ios/blob/f75af6a148e3f156512f38f1828f7cae2fe8ec46/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift#L1163-L1168))

    lastStripeCheckoutProductId already nil when work item fires

    lastStripeCheckoutProductId is set to nil on line 1186 immediately after the work item is scheduled with a 1-second delay. The work item captures self weakly and reads self.lastStripeCheckoutProductId at execution time — which is always 1 second later — so it will always read nil and fall back to "", completely defeating the purpose of this fix.

    The product ID must be captured by value in the closure, not by reference through self:

    Alternatively, capture lastStripeCheckoutProductId as a local let constant before creating the DispatchWorkItem.

  2. Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerDelegateMock.swift, line 39-112 ([link](https://github.com/superwall/superwall-ios/blob/f75af6a148e3f156512f38f1828f7cae2fe8ec46/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerDelegateMock.swift#L39-L112))

    Mock missing new protocol method — will not compile

    PaywallMessageHandlerDelegateMock conforms to PaywallMessageHandlerDelegate but does not implement the newly required handleStripeCheckoutStart(productId:) method. Swift will refuse to compile the test target until this stub is added.

  3. Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift, line 1163 ([link](https://github.com/superwall/superwall-ios/blob/8405571e0060d3507e9be03b4693e77ac676b68f/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift#L1163))

    Potential ordering issue: lastStripeCheckoutProductId reset before being used

    openPaymentSheet resets lastStripeCheckoutProductId = nil at its start (line 1139). If stripeCheckoutStart fires from JS before openPaymentSheet (which is plausible — the JS layer may signal checkout start before requesting the native sheet to open), the product ID stored by handleStripeCheckoutStart will be overwritten by nil when openPaymentSheet runs. The onDismiss closure would then fall back to "".

    If the JS ordering is stripeCheckoutStartopenPaymentSheet, the fix would not work as intended. It would be worth verifying (or documenting) the guaranteed message ordering, or alternatively, only resetting lastStripeCheckoutProductId inside openPaymentSheet when the new product ID is not already set for the current checkout session.

  4. Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerDelegateMock.swift, line 95 ([link](https://github.com/superwall/superwall-ios/blob/8405571e0060d3507e9be03b4693e77ac676b68f/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerDelegateMock.swift#L95))

    Mock doesn't capture productId for test assertions

    The new mock method discards the productId argument, which prevents unit tests from asserting that the correct product ID was forwarded from stripeCheckoutStart. Adding a stored property (similar to how stripeCheckoutSubmit is tracked as a tuple) would allow tests to verify the fix end-to-end.

  5. Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift, line 1163 ([link](https://github.com/superwall/superwall-ios/blob/b64a33b770c138dd6b6986cd9f868f6b00f47ea4/Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift#L1163))

    Silent empty-string fallback when product ID is missing

    lastStripeCheckoutProductId ?? "" falls back to an empty string when the open_url message didn't carry a productIdentifier field (e.g., an older backend). This silently produces the same broken abandoned_product_id: "" behaviour that the PR is meant to fix, with no indication anything went wrong.

    Consider either logging a warning when the fallback is triggered, or — since stripeCheckoutStart already contains a non-optional productId and fires before the user can dismiss — also updating lastStripeCheckoutProductId from that message as an additional safety net (the PR description originally planned this via handleStripeCheckoutStart(productId:)):

    // In PaywallMessageHandler stripeCheckoutStart handler:
    case let .stripeCheckoutStart(_, productId):
        trackStripeCheckoutEvent(state: .start, productId: productId)
        delegate?.handleStripeCheckoutStart(productId: productId)  // forward to update lastStripeCheckoutProductId

    This would ensure the product ID is stored even if productIdentifier was absent from the open_url payload.

Prompt To Fix All With AI
This is a comment left during a code review.
Path: Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift
Line: 1163

Comment:
**Silent empty-string fallback when product ID is missing**

`lastStripeCheckoutProductId ?? ""` falls back to an empty string when the `open_url` message didn't carry a `productIdentifier` field (e.g., an older backend). This silently produces the same broken `abandoned_product_id: ""` behaviour that the PR is meant to fix, with no indication anything went wrong.

Consider either logging a warning when the fallback is triggered, or — since `stripeCheckoutStart` already contains a non-optional `productId` and fires before the user can dismiss — also updating `lastStripeCheckoutProductId` from that message as an additional safety net (the PR description originally planned this via `handleStripeCheckoutStart(productId:)`):

```swift
// In PaywallMessageHandler stripeCheckoutStart handler:
case let .stripeCheckoutStart(_, productId):
    trackStripeCheckoutEvent(state: .start, productId: productId)
    delegate?.handleStripeCheckoutStart(productId: productId)  // forward to update lastStripeCheckoutProductId
```

This would ensure the product ID is stored even if `productIdentifier` was absent from the `open_url` payload.

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: b64a33b

yusuftor and others added 4 commits March 12, 2026 15:45
When a user swipes to dismiss the Stripe checkout sheet before the JS
stripe_checkout_abandon message fires, the fallback abandon path now
uses the product ID from stripeCheckoutStart instead of an empty string.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…productId in mock

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…tStart

stripe_checkout_start fires too late (from within the Stripe checkout
page), so the product ID was always nil on swipe-dismiss. Now the
product ID is passed via the open_url message with browserType
payment_sheet, which arrives before the checkout sheet opens.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@yusuftor yusuftor closed this Mar 13, 2026
@yusuftor yusuftor deleted the fix/stripe-checkout-abandon-product-id branch March 13, 2026 13:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant