Skip to content

fix(headless): validate cobadged cards via BIN cache (iOS↔Android parity)#1722

Open
OnurVar wants to merge 2 commits into
masterfrom
ov/fix/headless-cobadge-validation
Open

fix(headless): validate cobadged cards via BIN cache (iOS↔Android parity)#1722
OnurVar wants to merge 2 commits into
masterfrom
ov/fix/headless-cobadge-validation

Conversation

@OnurVar
Copy link
Copy Markdown
Contributor

@OnurVar OnurVar commented Apr 28, 2026

Description

Fixes the headless cobadge case where a card whose local IIN guess is not in the merchant's allowed list (e.g. BIN `5017…` is locally classified as Maestro, but the BIN response says CB + MC) is rejected at validation, leaving the pay button disabled forever.

Root cause: `PrimerRawCardDataTokenizationBuilder.validateRawData(_:)` ran on every keystroke with `cardNetworksMetadata: nil` and fell back to `CardNetwork(cardNumber:)`. The SDK already had the authoritative BIN response cached in `CardValidationService.metadataCacheBacking`, but the validator never consulted it.

Fix: validator now consults the BIN cache, and the allowed-list check is now expressed the same way Android does it. iOS and Android headless validators are now behaviourally aligned.

What changed

  • `PrimerRawCardDataTokenizationBuilder`
    • `validateRawData(_:)` reads `CardValidationService.cachedMetadata(forCardNumber:)` and forwards it to the metadata-aware overload.
    • The unsupported-card-type check now only fires when the cache holds BIN-derived metadata (`source != .local`) — same gate as Android `CardNumberValidator.kt:28`. Local-IIN guesses (chars 1–7, before the BIN response arrives) no longer trigger a false reject.
    • The check is now "any detected network is allowed" (`detected.contains(where: \.allowed)`) instead of "the resolved network is in the allowed list". Matches Android, keeps cobadged cards valid even when the user hasn't picked yet.
    • Drops the redundant defensive allowed-list check in `makeRequestBodyWithRawData`. `RawDataManager.submit` always runs validation first, and Android's `CardTokenizationDelegate` doesn't repeat the check either.
  • `CardValidationService`
    • All caches are now keyed by 8-char BIN via a single `binKey(for:)` helper. Previously two paths keyed by full PAN and one by BIN — typing digits 9–16 was needlessly refetching.
    • Exposes `cachedMetadata(forCardNumber:)` on the protocol so the builder can read the cache without going through validation.
  • `MerchantHeadlessCheckoutRawDataViewController` (Debug App)
    • Preserves the user's tap when `didReceiveCardMetadata` re-fires (submit-time re-validation was clobbering the tap).
    • Only treats a selectable list with >1 items as interactive — single-item lists fall through to the non-interactive single/fallback branch.

iOS ↔ Android parity

Behaviour iOS (before) iOS (now) Android
BIN cache lookup keyed by 8-char BIN mixed (PAN + BIN)
Validation consults BIN cache (no-metadata path)
Allowed-list check only for REMOTE / LOCAL_FALLBACK ❌ runs always
"Any detected network allowed" check ❌ "resolved is in allowed"
No submit-time defensive allowed-list check
`preferredNetwork = cardData.cardNetwork` pass-through at submit filters EFTPOS in headless filters EFTPOS in headless pass-through

The EFTPOS filter is a deliberate iOS-specific behaviour (covered by an existing test, out of scope here). Everything else is now in lockstep with Android `CardNumberValidator.kt`.

Manual Testing

Verified end-to-end in the Debug App against the sandbox client session with `orderedAllowedCardNetworks=[CB, VISA, MASTERCARD]`. Cases below come from the test matrix in `specs/TEST-CASES.md`:

# Scenario Card User action Expected `preferredNetwork` Result
1 CB + MC cobadge, no tap `5017 6792 1000 0700` fill all fields `nil`
2 CB + MC, tap CB `5017 6792 1000 0700` tap CB then Pay `CARTES_BANCAIRES`
3 CB + MC, tap MC `5017 6792 1000 0700` tap MC then Pay `MASTERCARD`
4 Single VISA, no tap `4242 4242 4242 4242` fill all fields `nil`
5 Single VISA, badge non-interactive `4242 4242 4242 4242` tap badge `nil` (badge not selectable)
6 EFTPOS-cobadged `4434 0200 0000 0006` fill all fields `nil` (EFTPOS filtered, no user choice)
7 Single MC, no tap `5454 5454 5454 5454` fill all fields `nil`
8 Cobadge, tap CB then MC `5017 6792 1000 0700` tap CB → tap MC → Pay `MASTERCARD` (last tap wins)

Contributor Checklist

  • All status checks have passed prior to code review
  • I have added unit tests to a reasonable level of coverage where suitable
  • I have added UI tests to new user flows, if applicable
  • I have manually tested newly added UX
  • I have open a documentation PR, if applicable

Reviewer Checklist

  • I have verified that a suitable set of automated tests has been added
  • I have verified that the title prefix aligns to the code changes + whether a release is expected after merging the PR
  • I have verified the documentation PR aligns with this PR, if applicable

Before Merging

  • If introducing a breaking change, I have communicated it internally
  • Any related documentation PRs are ready to merge

Other Stuff

  • You can find out more about our automation checks here
  • Find out more about conventional commits here

@OnurVar OnurVar self-assigned this Apr 28, 2026
@OnurVar OnurVar changed the title fix fix: cobadge validation parity with Android Apr 29, 2026
@OnurVar OnurVar marked this pull request as ready for review April 29, 2026 00:37
@OnurVar OnurVar requested review from a team as code owners April 29, 2026 00:37
@github-actions
Copy link
Copy Markdown
Contributor

@OnurVar OnurVar force-pushed the ov/fix/headless-cobadge-validation branch 3 times, most recently from 8332f03 to a5d41ff Compare April 29, 2026 15:20
@OnurVar OnurVar changed the title fix: cobadge validation parity with Android fix(headless): validate cobadged cards via BIN cache (iOS↔Android parity) Apr 29, 2026
@OnurVar OnurVar force-pushed the ov/fix/headless-cobadge-validation branch from a5d41ff to e8154d7 Compare April 29, 2026 15:33
…ity)

- `validateRawData(_:)` now reads from `CardValidationService.cachedMetadata`
  so cobadged cards (e.g. BIN 5017…, CB+MC) aren't rejected on the
  local-IIN guess (Maestro) when the merchant's allowed list contains
  the actual networks.
- Validator mirrors Android `CardNumberValidator`: the unsupported-card-type
  check only fires for BIN-derived metadata (REMOTE / LOCAL_FALLBACK), and
  uses "any detected network is allowed" rather than "the resolved network
  is in the allowed list".
- Drop the redundant allowed-list guard in `makeRequestBodyWithRawData`
  (validation runs before submit; Android doesn't repeat it either).
- BIN cache now keyed consistently by 8-char BIN via `binKey(for:)`, with
  `cachedMetadata(forCardNumber:)` exposed on the protocol.
- Debug App: preserve the user's tap when `didReceiveCardMetadata`
  re-fires (submit-time re-validation), and only treat a selectable list
  with >1 items as interactive.
- Tests cover BIN-cache lookup, `.local` source skip, REMOTE rejection,
  and cache-key consistency.
@OnurVar OnurVar force-pushed the ov/fix/headless-cobadge-validation branch from e8154d7 to 830eae3 Compare April 29, 2026 16:26
allowedCardNetworks: [CardNetwork] = [CardNetwork].allowedCardNetworks,
apiClient: PrimerAPIClientBINDataProtocol = PrimerAPIClient(),
debouncer: Debouncer = .init(delay: 0.35)) {
private func binKey(for cardNumber: String) -> String {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

below internal and functions below inits

@sonarqubecloud
Copy link
Copy Markdown

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

Development

Successfully merging this pull request may close these issues.

2 participants