Skip to content

feat: pass optional primaryAccountIdentifier to Apple Wallet - #91

Open
Brunonascdev wants to merge 6 commits into
Expensify:mainfrom
Brunonascdev:feat/ios-primary-account-identifier
Open

Brunonascdev wants to merge 6 commits into
Expensify:mainfrom
Brunonascdev:feat/ios-primary-account-identifier

Conversation

@Brunonascdev

@Brunonascdev Brunonascdev commented Sep 21, 2026 •

Copy link
Copy Markdown

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 covers the case where Wallet stores a different id than the one the app passed. Two different cards with the same last digits fall back to the id the caller provided. 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) reports whether the card can still be added to the iPhone or a paired Apple Watch. 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.

Related Issues

Fixes #56
Fixes #89

Manual Tests

No automated coverage for the PassKit configuration. Typecheck, lint, and the example app typecheck pass. The wallet Swift sources typecheck against the iOS simulator 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.

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
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
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
Comment thread ios/WalletManager.swift
@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
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
return identifier
}

let suffixMatches = passes.filter { $0.primaryAccountNumberSuffix.hasSuffix(lastDigits) }

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.

Not sure about this fallback. With no exact match we use the id of the only pass with the same last digits, deactivated passes included. A reissued card (new FPAN ID, same last 4) would get the old card's id, so PassKit hides the devices that have the old card and the add can fail with "already added to all available devices". I would keep the caller's id unless it matches exactly. Is there a real case where the issuer id differs from what PassKit reports?

Comment thread ios/WalletManager.swift
}

@objc public func canAddCardToAppleWallet(primaryAccountIdentifier: NSString) -> Bool {
return passLibrary.canAddSecureElementPass(primaryAccountIdentifier: primaryAccountIdentifier as String)

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.

If the fallback stays: this checks the raw id, but the add flow may swap it for the last-digits match. Then this returns true, the button shows, and the add fails as "already added". Both should use the same resolved id

Comment thread ios/WalletManager.swift
configuration.primaryAccountIdentifier = primaryAccountIdentifier
}

let targetDevice = provisioningTargetDevice(for: primaryAccountIdentifier)

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.

This re-reads both pass lists right after resolvedPrimaryAccountIdentifier already did. Also deviceType is computed once before the sheet opens and reused on "Try again", so it can go stale. Computing it in the delegate callback would fix both

Comment thread ios/WalletManager.swift
let onWatch = hasProvisionedPass(identifier: primaryAccountIdentifier, in: passLibrary.remoteSecureElementPasses)

if onPhone && !onWatch {
return "watch"

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.

The device names are bare strings here and again in src/index.tsx. A typo on either side quietly turns into unknown. A small Swift enum with raw values for phone, watch and unknown would keep them in one place on this side.

Comment thread src/index.tsx

async function canAddCardToAppleWallet(primaryAccountIdentifier: string): Promise<boolean> {
if (Platform.OS === 'android') {
throw new Error('canAddCardToAppleWallet is not available on Android');

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.

This is a yes/no check apps will call when deciding whether to show the button. Throwing on Android means every caller needs its own platform check. listTokens returns an empty value on iOS instead of throwing, so returning false here would match that and be easier to use.

Comment thread README.md
- **Certificates** - an array of NSData objects. Each object contains a DER encoded X.509 certificate, with the leaf first and root last. You must download the root CA to validate the entire chain.
- **Encrypted Pass Data**- an encrypted JSON file containing the sensitive information needed to add a card to Apple Pay (Base 64 encoded). [More details here.](https://developer.apple.com/documentation/passkit/pkaddpaymentpassrequest/encryptedpassdata)
- **Ephemeral Public Key** - a key used by elliptic curve cryptography (ECC) (Base 64 encoded).
- **Primary Account Identifier** - an opaque issuer id (also called the FPAN ID) that PassKit uses to recognize the same card across reissues and devices. Pass it in `IOSCardData` and to `canAddCardToAppleWallet`. On iOS, `getCardStatusByIdentifier` uses this same value. If no pass matches the given id exactly, the library uses the id of the only pass with the same last digits.

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.

This reads like getCardStatusByIdentifier also does the last-digits fallback, but it only does an exact match, and canAddCardToAppleWallet does not do it either. So the same id can give different answers from different functions. Either make them match or say exactly which call does the fallback.

Comment thread README.md
| **IOSIssuerCallback** | This callback is invoked with a nonce, its signature, and a certificate array obtained from Apple. It is expected that you will forward these details to your server or the card issuer's API to securely encrypt the payload required for adding cards to the Apple Wallet. | `(nonce: string, nonceSignature: string, certificate: string[]) => IOSEncryptPayload` |
| **IOSIssuerCallback** | This callback is invoked with a nonce, its signature, a certificate array obtained from Apple, and the device the card is being added to. Forward these details to your server or the card issuer's API to securely encrypt the payload required for adding cards to the Apple Wallet. `deviceType` is a best-effort value; see `IOSDeviceType`. | `(nonce: string, nonceSignature: string, certificate: string[], deviceType: IOSDeviceType) => IOSEncryptPayload` |
| **IOSDeviceType** | Best-effort device the card is being provisioned to. PassKit does not report the selected device, so this is inferred from existing passes when `primaryAccountIdentifier` is set. If no pass matches that id exactly, the library uses the id of the only pass with the same last digits. Passes on a paired Apple Watch are never counted as iPhone passes. `phone` means the card is already on the paired Apple Watch only. `watch` means it is already on the iPhone only. `unknown` means the card is on neither device, or no identifier was provided, so the user may still choose either device. Passes in the `deactivated` state count as not present. | `'phone' \| 'watch' \| 'unknown'` |
| **IOSAddPaymentPassData** | Data Apple returns when the user continues the provisioning flow, including the device the card is being added to. | `status: number`,<br>`nonce: string`,<br>`nonceSignature: string`,<br>`certificates: string[]`,<br>`deviceType?: string` |

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.

Apple does not return deviceType, the library guesses it (see IOSDeviceType above). Worth saying that here so it does not read like a PassKit field.

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