Skip to content

feat: pass optional primaryAccountIdentifier to Apple Wallet - #91

Merged
JakubKorytko merged 8 commits into
Expensify:mainfrom
Brunonascdev:feat/ios-primary-account-identifier
Oct 29, 2026
Merged

JakubKorytko merged 8 commits into
Expensify:mainfrom
Brunonascdev:feat/ios-primary-account-identifier

Conversation

@Brunonascdev

@Brunonascdev Brunonascdev commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Details

IOSCardData accepts an optional primaryAccountIdentifier. Issuers that reissue a card keep one opaque id while the PAN suffix changes, and PassKit uses that id to recognize the existing pass and to offer only the devices that do not already have it. The native bridge forwards the field on both the new and the old architecture, and only when it is non-empty. Callers that omit it keep the previous behavior.

If no pass matches the given id exactly, the library uses the id of the only pass with the same last digits that is not deactivated. An exact match still wins. Two different active cards with the same last digits keep the id the caller provided. addCardToAppleWallet, the inferred deviceType, and canAddCardToAppleWallet when given lastDigits share that lookup. getCardStatusByIdentifier matches the id exactly and does not fall back. Passing the id PassKit stored on the pass skips the lookup. An older card that is still active and ends in the same digits can be picked. Passes on a paired Apple Watch are never counted as iPhone passes.

canAddSecureElementPass is not used to block provisioning, because it has been reported to return false after an iPhone add even when a paired Watch can still take the card. If PassKit itself refuses to present the flow and that check says the card is already on every device, the call fails with "This card has already been added to all available devices." instead of the generic controller configuration error.

canAddCardToAppleWallet(primaryAccountIdentifier, lastDigits?) reports whether the card can still be added to the iPhone or a paired Apple Watch. Pass lastDigits so it uses the same id as the add. It returns false on Android. Keep the Add button visible while it returns true.

The issuer callback now receives a best-effort deviceType (phone, watch, or unknown). PassKit does not report the device the user selected, so the library infers it from existing passes when primaryAccountIdentifier is set: watch when the card is only on the iPhone, phone when it is only on the paired Watch, and unknown when the user can still choose either device or no identifier was provided. Passes in the deactivated state count as not present. The value is computed when Apple asks for the encrypted payload, including when the user tries again, from a ProvisioningDeviceType enum.

Merged current main.

Related Issues

Fixes #56
Fixes #89

Manual Tests

This repo has no automated tests. Typecheck and lint pass. The example app builds for the iOS simulator with the new architecture. Android was not compiled here: the example app does not include the TapAndPay SDK.

Confirming that PassKit filters the device list, and that deviceType matches the remaining device, still needs a device and an issuer that supplies the id.

Linked PRs

#96, #132, #133, and #134 also edit the spec, the README, or WalletManager.swift / RNWallet.mm. Whichever lands second will need a small merge.

PassKit uses this id to recognize a reissued card after the PAN suffix changes.
@github-actions

github-actions Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@Brunonascdev

Copy link
Copy Markdown
Contributor Author

I have read the CLA Document and I hereby sign the CLA

exfy-clabot Bot added a commit to Expensify/CLA that referenced this pull request Sep 21, 2026
@Brunonascdev

Copy link
Copy Markdown
Contributor Author

recheck

@JakubKorytko JakubKorytko left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

note: #56 also asks for the selected device in the issuer callback, so this covers only the first part of it and the issue should stay open after merge, unless you want to expand this PR

Comment thread src/NativeWallet.ts Resolved
Comment thread ios/WalletManager.swift Resolved
@JakubKorytko

Copy link
Copy Markdown
Member

Heads up: #93 just landed and adds an old arch branch to the cardDataDict block in IOSPresentAddPaymentPassView (ios/RNWallet.mm), so this needs a rebase/merge. When resolving it, please also read the field in the #else branch, e.g. cardData[@"primaryAccountIdentifier"], otherwise it gets silently dropped on the old arch. The same safeString + length > 0 check should apply there too.

@JakubKorytko

Copy link
Copy Markdown
Member

@Brunonascdev gentle bump

Keep primaryAccountIdentifier on both the new and old architecture paths so the old arch does not drop the field.
PassKit returns a nil enrollment controller in that case, which surfaced as a generic configuration error.
PassKit does not report the device the user picked, so infer it from existing passes and pass it to the issuer callback. Expose canAddCardToAppleWallet so the Add button stays up until every device has the card.
@Brunonascdev

Brunonascdev commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor Author

@JakubKorytko, I merged main and kept primaryAccountIdentifier on both architecture paths. The old-arch branch reads cardData[@"primaryAccountIdentifier"] with the same non-empty check. A missing or null value is ignored, so NSNull does not reach safeString.

This also covers the rest of #56 and #89. canAddCardToAppleWallet(primaryAccountIdentifier) tells the app whether another device can still take the card. The issuer callback now receives a best-effort deviceType (phone, watch, or unknown). PassKit does not report the device the user selected, so that value is inferred from existing passes and is unknown when the user can still choose either device.

Ignore remote passes in the iPhone list, recover the stored account id from a unique last-digits match, and only use the all-devices check when PassKit itself refuses to present.

@JakubKorytko JakubKorytko left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Main thing is the last-digits fallback for primaryAccountIdentifier, the rest is smaller

Comment thread ios/WalletManager.swift Resolved
Comment thread ios/WalletManager.swift Resolved
Comment thread ios/WalletManager.swift Resolved
Comment thread ios/WalletManager.swift Resolved
Comment thread src/index.tsx Resolved
Comment thread README.md Resolved
Comment thread README.md Resolved
The button check, the add, and the inferred device now resolve the same id, and the device is read again when the user tries again.
@Brunonascdev

Copy link
Copy Markdown
Contributor Author

Merged current main. The review threads are updated: the last-digits lookup stays, with the tighter rules from the replies, and canAddCardToAppleWallet, deviceType, and the README match it.

@Brunonascdev

Copy link
Copy Markdown
Contributor Author

@JakubKorytko this is ready for another look. Requesting a review from the fork returns a permissions error, so this comment is the ping.

@JakubKorytko JakubKorytko left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

thanks, LGTM

Comment thread ios/stucts/AddPassResponse.swift Resolved
@JakubKorytko
JakubKorytko merged commit 9951efd into Expensify:main Oct 29, 2026
7 of 9 checks passed
@os-botify

os-botify Bot commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

🚀 Published to npm in 0.2.23 🎉

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants