Skip to content

Expose abandoned transaction product params in filters#459

Open
SebastianSzturo wants to merge 2 commits intosuperwall:developfrom
SebastianSzturo:SebastianSzturo/abandoned-duration
Open

Expose abandoned transaction product params in filters#459
SebastianSzturo wants to merge 2 commits intosuperwall:developfrom
SebastianSzturo:SebastianSzturo/abandoned-duration

Conversation

@SebastianSzturo
Copy link
Copy Markdown

@SebastianSzturo SebastianSzturo commented Apr 9, 2026

Problem

When configuring a paywall trigger for transaction_abandon, the dashboard filter UI only exposes abandoned_product_id.

The SDK already includes richer abandoned product metadata in the internal $product_* parameter set, but those fields are not copied into the explicit event parameter payload that powers the filter picker. As a result, you cannot target different paywalls based on the abandoned product duration.

Intended use case

The goal is to show a different rescue paywall depending on which subscription duration was abandoned. For example:

  • If the user abandons a yearly product, show a discounted yearly paywall
  • If the user abandons a monthly product, show a discounted monthly paywall
  • If the user abandons a weekly product, show a discounted weekly paywall

Right now that workflow is blocked because transaction_abandon does not expose duration-related fields in the filter UI.

Solution

  • Copy available abandoned product attributes into explicit abandoned_product_* event parameters
  • Preserve the existing abandoned_product_id field
  • Avoid emitting misleading empty duration fields for blank products used by Stripe/web checkout fallback paths
  • Add test coverage for the new transaction_abandon parameter set

Notes

This addresses the App Store abandon path where a real StoreProduct is available. Stripe/web checkout abandon still only has product ID in the current model, so duration-based filtering for that path would require additional Stripe product metadata to be tracked.

Greptile Summary

This PR copies StoreProduct.attributes into abandoned_product_* audience filter params when a transaction is abandoned, enabling dashboard filter rules based on product period and pricing. Existing abandoned_product_id is preserved, and a hasRicherProductAttributes guard skips attribute copying for blank Stripe/web checkout placeholder products.

Confidence Score: 5/5

Safe to merge; changes are additive and isolated to the transaction abandon event parameter set with no risk of regression.

All findings are P2: one design observation about the guard condition semantics and one test quality note. No P0/P1 issues exist. The core logic correctly copies product attributes and preserves backward compatibility with abandoned_product_id.

No files require special attention.

Vulnerabilities

No security concerns identified.

Important Files Changed

Filename Overview
Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift Adds abandoned_product_* params by iterating product.attributes for the .abandon transaction state; the hasRicherProductAttributes guard uses localizedPrice which is always non-empty for real App Store products, so empty subscription period fields get emitted for non-subscription products too.
Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackTests.swift New transaction_abandon test covers key presence but uses != nil checks for period fields that would pass even for empty strings, reducing confidence that the feature works correctly for subscription products.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Transaction abandoned] --> B{product != nil?}
    B -- No --> E[Return paywallInfo.audienceFilterParams only]
    B -- Yes --> C[Set abandoned_product_id]
    C --> D{hasRicherProductAttributes?\nlocalizedPrice.isEmpty OR\nlocalizedSubscriptionPeriod.isEmpty OR\nperiod.isEmpty}
    D -- False\nStripe/web blank product --> E
    D -- True\nReal App Store product --> F[Iterate product.attributes\nadd abandoned_product_ prefix\ncamelCase to snake_case]
    F --> G[Return merged params]
    E --> H[Audience filter evaluation]
    G --> H
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift
Line: 674-677

Comment:
**Guard condition always passes for real App Store products**

`localizedPrice` is non-empty for every real App Store product (subscriptions, consumables, non-consumables alike), so the `hasRicherProductAttributes` OR-condition is effectively always `true` for any StoreKit product. This means non-subscription products will also have their subscription-specific period fields (e.g. `abandoned_product_period_months: ""`, `abandoned_product_period_days: ""`) emitted as empty strings in the audience filter params, which may create noise in the filter picker UI.

If the intent is to expose duration fields only for subscription products, consider checking `localizedSubscriptionPeriod` alone (or check `!product.period.isEmpty`), since those are empty for non-subscription products:

```swift
let hasRicherProductAttributes = !product.localizedSubscriptionPeriod.isEmpty
  || !product.period.isEmpty
```

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

---

This is a comment left during a code review.
Path: Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackTests.swift
Line: 1729-1732

Comment:
**`!= nil` assertions don't verify period values are meaningful**

`periodMonthsString`, `periodYearsString`, etc. return `""` (empty string) for non-subscription products, and an empty string is still non-nil — so these assertions pass even when the feature has not populated real period data. Consider asserting the expected string values from `MockSkProduct`'s configured period, or at least asserting `as? String != ""` to confirm the fields are actually populated.

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

Reviews (1): Last reviewed commit: "Expose abandoned product params" | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

(2/5) Greptile learns from your feedback when you react with thumbs up/down!

Comment on lines +674 to +677
let hasRicherProductAttributes =
!product.localizedPrice.isEmpty
|| !product.localizedSubscriptionPeriod.isEmpty
|| !product.period.isEmpty
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Guard condition always passes for real App Store products

localizedPrice is non-empty for every real App Store product (subscriptions, consumables, non-consumables alike), so the hasRicherProductAttributes OR-condition is effectively always true for any StoreKit product. This means non-subscription products will also have their subscription-specific period fields (e.g. abandoned_product_period_months: "", abandoned_product_period_days: "") emitted as empty strings in the audience filter params, which may create noise in the filter picker UI.

If the intent is to expose duration fields only for subscription products, consider checking localizedSubscriptionPeriod alone (or check !product.period.isEmpty), since those are empty for non-subscription products:

let hasRicherProductAttributes = !product.localizedSubscriptionPeriod.isEmpty
  || !product.period.isEmpty
Prompt To Fix With AI
This is a comment left during a code review.
Path: Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift
Line: 674-677

Comment:
**Guard condition always passes for real App Store products**

`localizedPrice` is non-empty for every real App Store product (subscriptions, consumables, non-consumables alike), so the `hasRicherProductAttributes` OR-condition is effectively always `true` for any StoreKit product. This means non-subscription products will also have their subscription-specific period fields (e.g. `abandoned_product_period_months: ""`, `abandoned_product_period_days: ""`) emitted as empty strings in the audience filter params, which may create noise in the filter picker UI.

If the intent is to expose duration fields only for subscription products, consider checking `localizedSubscriptionPeriod` alone (or check `!product.period.isEmpty`), since those are empty for non-subscription products:

```swift
let hasRicherProductAttributes = !product.localizedSubscriptionPeriod.isEmpty
  || !product.period.isEmpty
```

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

Comment on lines +1729 to +1732
#expect(result.parameters.audienceFilterParams["abandoned_product_period"] != nil)
#expect(result.parameters.audienceFilterParams["abandoned_product_period_months"] != nil)
#expect(result.parameters.audienceFilterParams["abandoned_product_period_years"] != nil)
#expect(result.parameters.audienceFilterParams["abandoned_product_localized_period"] != nil)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 != nil assertions don't verify period values are meaningful

periodMonthsString, periodYearsString, etc. return "" (empty string) for non-subscription products, and an empty string is still non-nil — so these assertions pass even when the feature has not populated real period data. Consider asserting the expected string values from MockSkProduct's configured period, or at least asserting as? String != "" to confirm the fields are actually populated.

Prompt To Fix With AI
This is a comment left during a code review.
Path: Tests/SuperwallKitTests/Analytics/Internal Tracking/TrackTests.swift
Line: 1729-1732

Comment:
**`!= nil` assertions don't verify period values are meaningful**

`periodMonthsString`, `periodYearsString`, etc. return `""` (empty string) for non-subscription products, and an empty string is still non-nil — so these assertions pass even when the feature has not populated real period data. Consider asserting the expected string values from `MockSkProduct`'s configured period, or at least asserting `as? String != ""` to confirm the fields are actually populated.

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

@SebastianSzturo
Copy link
Copy Markdown
Author

@yusuftor @jakemor Lmk what you think!

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