feature: Checkout Components#1212
Conversation
Generated by 🚫 Danger Swift against 3505a00 |
|
Appetize link: https://appetize.io/app/lhzg34it4yhsoo7tdlggxsvv74 |
1ae303a to
744fa40
Compare
There was a problem hiding this comment.
This shouldnt be here, perhaps a rogue npm install from our friend Claude?
There was a problem hiding this comment.
Are these changes necessary?
There was a problem hiding this comment.
As below with NetworkResponseFactory - are you intending to remove these logs?
| @@ -75,6 +75,13 @@ extension PrimerTheme { | |||
| var darkHex: String? | |||
| var lightHex: String? | |||
|
|
|||
| /// Convert to UIColor based on current appearance mode | |||
| var uiColor: UIColor? { | |||
| let isDarkMode = UIScreen.isDarkModeEnabled | |||
There was a problem hiding this comment.
Open question - is this sufficient to determine Dark mode? In apps which allow users to manually set the theme, we may want to extract this to something overridable in PrimerSettings?
Doesnt need to be actioned here, but please log it if you agree
There was a problem hiding this comment.
Thanks for raising this. Looking at the usage, the uiColor property is only being used to convert server-provided theme colors (darkHex/lightHex/coloredHex) to UIColor based on the current appearance.
The current implementation uses UIScreen.main.traitCollection.userInterfaceStyle which reflects the system's appearance setting. For apps with manual theme overrides (e.g., "Always Dark" regardless of system setting, this could indeed select the wrong color variant.
Since this is a computed property on a data model that comes from the server, adding theme override support would require a more significant architectural change - we'd need to pass the app's theme preference through to this conversion point.
I'll log this as technical debt for future consideration. For now, the SDK will follow system appearance settings. Here is the Jira ticket.
| @@ -1,6 +1,6 @@ | |||
| import Foundation | |||
|
|
|||
| internal enum PrimerPaymentMethodType: String, Codable, CaseIterable, Equatable, Hashable { | |||
There was a problem hiding this comment.
Can you explain this change from internal->public. In what way should this enum be used by a merchant going forward?
There was a problem hiding this comment.
This enum was made public to support the CheckoutComponents API. Merchants can now use it to access payment method scopes with compile-time safety and auto-completion:
// Merchant usage example
if let cardFormScope: DefaultCardFormScope = checkoutScope.getPaymentMethodScope(for: .paymentCard) {
// Customize card form fields
cardFormScope.cardNumberInput = { field in
// Custom UI
}
}
Benefits for merchants:
- Type safety: .paymentCard instead of string "PAYMENT_CARD"
- Discoverability: Auto-completion shows all available payment methods
- Compile-time checks: Typos caught at build time
Alternative (if we keep it internal):
We'd need to remove the enum-based method from PrimerCheckoutScope and merchants would use:
// Type-safe but less discoverable
checkoutScope.getPaymentMethodScope(PrimerCardFormScope.self)
OR
// Sstring-based (prone to typos)
checkoutScope.getPaymentMethodScope(for: "PAYMENT_CARD")
The enum approach provides the best developer experience, though it does expose our payment method types as public API.
| @@ -322,7 +322,8 @@ extension [CardNetwork]: LogReporter { | |||
| logger.warn(message: "Expected allowed networks to be present in client session") | |||
| return [] | |||
| } | |||
| return networkStrings.compactMap { CardNetwork(rawValue: $0) } | |||
| let networks = networkStrings.compactMap { CardNetwork(rawValue: $0) }//.filter { $0 != .amex } | |||
There was a problem hiding this comment.
Should this change be here?
| } | ||
| } | ||
|
|
||
| /// Refactored validation method with reduced cyclomatic complexity. |
There was a problem hiding this comment.
This comment can be removed
There was a problem hiding this comment.
Can you confirm this method is covered by Unit tests?
|
|
||
| public var stringValue: String { | ||
| switch self { | ||
| // Existing cases |
There was a problem hiding this comment.
Existing/New comments throughout arent relevant here (useful for review, not useful in the code going forward)
There was a problem hiding this comment.
Should all the logs in this file be commented out?
|
As a first pass I have reviewed all changes to |
…rimer-io/primer-sdk-ios into bn/feature/checkout-components
There was a problem hiding this comment.
Changes related to adding the CC demos.
| case .countryCode: | ||
| break | ||
| case .firstName: | ||
| break | ||
| case .lastName: | ||
| break | ||
| case .addressLine1: | ||
| break | ||
| case .addressLine2: | ||
| break | ||
| case .city: | ||
| break | ||
| case .state: | ||
| break | ||
| case .all: | ||
| break | ||
| case .email: | ||
| break |
There was a problem hiding this comment.
I implemented the BillingAddress feature on the Headless example.
There was a problem hiding this comment.
UIKit wrapper for the SwiftUI-based CheckoutComponents.
Here's how it works:
Architecture Overview
- SwiftUI Core: The actual CheckoutComponents implementation is in SwiftUI (PrimerCheckout.swift)
- Bridge Layer: PrimerSwiftUIBridgeViewController wraps SwiftUI views in a UIHostingController
- UIKit API: CheckoutComponentsPrimer provides a familiar UIKit-style API for iOS developers
Key Components
CheckoutComponentsPrimer.swift:342-395 creates and presents the SwiftUI content:
// Creates a bridge controller that embeds SwiftUI
let bridgeController = PrimerSwiftUIBridgeViewController.createForCheckoutComponents(
clientToken: clientToken,
settings: PrimerSettings.current,
diContainer: DIContainer.shared,
navigator: CheckoutNavigator(),
presentationContext: .direct,
onCompletion: { /* ... */ }
)
// Presents it modally like any UIViewController
viewController.present(bridgeController, animated: true)
PrimerSwiftUIBridgeViewController.swift:28-30 embeds SwiftUI:
init<Content: View>(swiftUIView: Content) {
self.hostingController = UIHostingController(rootView: AnyView(swiftUIView))
super.init()
}
Why This Design?
- Backward Compatibility: Many iOS apps still use UIKit primarily
- Familiar API: Matches the existing Primer SDK patterns (delegate-based)
- Easy Migration: Developers can adopt CheckoutComponents without rewriting their app in SwiftUI
- Modal Presentation: Works seamlessly with UIKit navigation patterns (there is some issues with dynamic height calculation, there is a ticket for it)
Usage Comparison
Pure SwiftUI approach:
PrimerCheckout(clientToken: token)
.onPaymentSuccess { result in /* ... */ }
UIKit wrapper approach:
CheckoutComponentsPrimer.shared.delegate = myDelegate
CheckoutComponentsPrimer.presentCheckout(
with: clientToken,
from: viewController
)
The wrapper handles all the complexity of embedding SwiftUI content into UIKit, managing sizing, and translating SwiftUI callbacks to UIKit delegate patterns.
There was a problem hiding this comment.
The changes related to CheckoutComponentsDelegate implementation should be revisited. I built this as a PoC to make sure everything works in UIKit, and left it there for the future. Because it is not defined how this should behave, or if we even should care about it (we may want to leave it to merchants to embed SwiftUI CC into their UIKit app the way they want), consider this part and related files as SPIKE. Let's talk about it.
There was a problem hiding this comment.
This file is related to this comment. I suggest skipping the review for now until we define what we want to do regarding UIKit support and how it should look.
Three changes that together eliminate the deadlock:
1. Container.resolveSync: Task {} → Task.detached {} so the resolution
task runs on the cooperative pool instead of inheriting @mainactor
from the caller (which is blocked by the semaphore).
2. HeadlessRepositoryImpl.init: marked nonisolated since it only assigns
stored properties and needs no actor-isolated work.
3. ComposableContainer: removed MainActor.run wrapper from the
HeadlessRepository factory — no longer needed with nonisolated init,
and it was causing the detached task to hop back to main.
Remove unnecessary await on same-actor calls, fix @preconcurrency import for PassKit, replace deprecated primerHeadlessUniveraslCheckout typo call, add @sendable to Factory closure, fix implicit coercion and var-to-let, and clean up unused values.
Make PaymentMethodProtocol.createScope async, replacing resolveSync (semaphore-based) with proper async DI resolution. Pre-load all payment method scopes during checkout init so getPaymentMethodScope (public API) stays synchronous via cache reads. Remove nonisolated init from 3 repository classes and @unchecked Sendable from 13 @mainactor classes.
# Conflicts: # .github/actions/sdk-tests/action.yml
- Make paymentMethodScopeCache internal on DefaultCheckoutScope so tests can pre-populate it, fixing async race where createView() was called before the background Task populated the cache - Restore deprecated primerHeadlessUniveraslCheckoutUIDidDismissPaymentMethod call in PrimerDelegate dismiss methods for backward compatibility
Apply fixes from 3 rounds of code review (157 findings total): Critical: Handle .checkoutComponents in PrimerDelegate, fix RetentionPolicyTests shadow mock, fix Google Pay icon mapping, add KVC crash guard, retain card payment handler, add card payment timeout, fix VoiceOver on card inputs, add DesignTokens bounds check, custom Equatable for PrimerCardFormState. High: Remove public from Internal types, fix DismissalMechanism encoding, cancel vault timeout on success, log errors in catch blocks, resolve QRCode interactor from DI, fix WebRedirect try? to try, remove dead registrations, guard 500ms sleep, guard terminal dismissed state, fix analytics encoding, wrap registry reset in DEBUG. Medium/Low: Add Sendable to state structs, add enum doc comments, extract PrimerFormFieldState to own file, add scope typealiases, replace UIAccessibility with service, replace onTapGesture with Button, remove unused properties, fix doc comments, replace Color.pink with design token.
…-components # Conflicts: # Sources/PrimerSDK/Classes/Core/Analytics/AnalyticsEvent.swift
22316f2 to
6ff43c2
Compare
* feat: Implement ADYEN_KLARNA payment method in CheckoutComponents Add Klarna via Adyen integration with payment option selection, redirect flow, and polling for CheckoutComponents (iOS 15+). - Add PrimerPaymentMethodType.adyenKlarna enum case - Add AdyenKlarnaSessionInfo for tokenization - Add API endpoint for fetching Klarna payment types - Create AdyenKlarnaRepository with redirect+poll flow - Create ProcessAdyenKlarnaPaymentInteractor - Create PrimerAdyenKlarnaScope public protocol and state - Create DefaultAdyenKlarnaScope with full state machine - Create AdyenKlarnaScreen matching web SDK design - Add accessibility identifiers for all UI elements - Add localized strings in all 62 supported languages - Register in PaymentMethodRegistry and DI container - Add unit tests for interactor flow Ticket: ACC-7020 * fix: Refactor nested closures in AdyenKlarnaRepositoryImpl Replace 3-level nested closure in openDeepLink with @mainactor async/await to resolve SonarCloud nesting violation. * fix: Use @testable import in Debug App for SPM build CustomPaymentSelectionDemo references DefaultCardFormScope which is internal. Use @testable import PrimerSDK to access it in the Debug App. * test: Add comprehensive tests for AdyenKlarna CheckoutComponents Add tests for AdyenKlarnaPaymentMethod (12 tests), AdyenKlarnaRepository (7 tests), and DefaultAdyenKlarnaScope (13 tests) to improve coverage above 80% threshold for new code. * Remove missing files and folders from the project * test: Improve AdyenKlarnaRepositoryImpl test coverage Add tests for tokenize flow (nil token, no required action, full success), openWebAuthentication, and resumePayment after tokenize. Coverage now covers init, fetchPaymentOptions, tokenize, openWebAuthentication, resumePayment, and cancelPolling paths. * fix: Localize Klarna payment option names in AdyenKlarnaScreen Map raw API option names (klarna, klarna_account, klarna_paynow) to localized display strings (Pay later, Pay over time, Pay now) matching web SDK translations across all 57 supported languages. * fix: Stabilize flaky tests with race-condition-safe assertions Replace exact call count assertions (== 1) with relative assertions (greaterThan countBeforeCall) in refreshVaultedPaymentMethods tests, and increase sleep + use greaterThanOrEqual in selectCardNetwork test. These tests were flaky due to async operations firing during scope init.
* feat: Add MOLLIE_GIFTCARD payment method type Register Mollie Gift Card in PrimerPaymentMethodType enum so it is recognized by the SDK. The actual payment flow is handled automatically by the existing WebRedirect infrastructure when the backend returns this type with implementationType WEB_REDIRECT. * fix: Stabilize flaky tests with race-condition-safe assertions Replace exact call count assertions (== 1) with relative assertions (greaterThan countBeforeCall) in refreshVaultedPaymentMethods tests, and increase sleep + use greaterThanOrEqual in selectCardNetwork test. These tests were flaky due to async operations firing during scope init. * fix: Use @testable import in Debug App for SPM build CustomPaymentSelectionDemo references DefaultCardFormScope which is internal. Use @testable import PrimerSDK to access it in the Debug App.
…#1689) fix: Hide "choose other payment method" on error screen for single PM When only one payment method is available, the error screen no longer shows the "Choose other payment method" button since there are no alternatives to choose from. ACC-6849
…1694) * fix: Make PaymentMethodRegistry.reset() available in Release builds reset() was guarded by #if DEBUG, leaving stale payment method entries in production when merchants re-initialize checkout with different API configs. Now reset() runs unconditionally and is called at the start of registerPaymentMethods() to clear previous registrations. * fix: Reorder test registration to run after scope creation DefaultCheckoutScope.init calls registerPaymentMethods() which now calls reset(), clearing any test-registered payment methods. Move test .register() calls to after scope creation so they aren't wiped.
The selectCountry computed property was lazily creating and caching a DefaultSelectCountryScope on first access, mutating private state in a getter. Replace with idiomatic lazy var for identical behavior without the side effect.
* refactor: Break down DefaultCheckoutScope into focused types Extract 3 concerns from DefaultCheckoutScope (582→484 lines): - CheckoutNavigationState: standalone enum for navigation states - CheckoutAnalyticsTracker: analytics event tracking and metadata - VaultedPaymentMethodManager: vaulted payment method state management Tests migrated to dedicated files (86 tests, 0 failures). * style: Move onSelectionChanged above private(set) properties Address review feedback to place higher-access stored property ahead of private(set) ones per the member ordering convention.
CheckoutComponents test classes write into the global `DIContainer.shared` but do not reset it between tests, so singleton registrations and resolution state leak across tests. Under varying test-run orderings this manifests as `circularDependency`, stale mock call counts, and tests asserting against state left by a previous class. Four recent PRs (#1685, #1686, #1691, #1705) have been flaking on this. Changes: - Add `ContainerTestHelpers.resetSharedContainer()` and call it from `setUp` and `tearDown` of the 12 test files that use `DIContainer.shared`. - Add `ContainerTestHelpers.createSettledCheckoutScope()` — creates a `DefaultCheckoutScope` with a pre-wired test container and returns only after its async init leaves `.initializing`. Use in the three `DefaultPaymentMethodSelectionScope*Tests` classes so downstream `loadPaymentMethods()` no longer waits forever on a scope stuck in `.initializing`. - Fix `test_onDismiss_setsNavigationStateToDismissed` race: build the SUT with `isInitScreenEnabled: false` so the async init task cannot overwrite `.dismissed` with `.loading`; drop the 500 ms sleep and the `|| == .loading` hedge — both `updateState` calls on the dismiss path are synchronous. - Fix `test_deleteVaultedPaymentMethod_whenDeleteThrows_propagatesErrorWithoutRefresh`: capture a post-init baseline of `fetchVaultedPaymentMethodsCallCount` and assert the failed delete did not increment it, instead of asserting the absolute count is zero (the settled scope now legitimately refreshes once during init).
* fix: Pass correct paymentMethodType to QRCode interactor via Factory pattern The QRCode interactor was registered in the DI container with an empty string for paymentMethodType, causing all QR code payments to send "" to the backend. Introduce QRCodePaymentInteractorFactory using the existing Factory<Product, Params> protocol so the correct type is passed at scope-creation time. * style: Remove unused typealiases and @unchecked Sendable from QRCode factory
…er (#1705) * fix: Propagate critical DI registration failures in ComposableContainer configure() now throws and runs a production-level validation step that resolves every critical dependency after registration, so a broken container fails loudly at init instead of masking as silent resolve failures at payment time. - Split register helpers: criticalRegister (rethrows) for infrastructure and data repositories, guardedRegister (log-and-swallow) for optional payment method wiring - Move DIContainer.setContainer after validation so a partially configured container is never published - Add ComposableContainerTests for happy-path and publish-ordering - Update existing DI/Settings tests to try await configure() * refactor: Narrow validateCriticalDependencies to private Addresses review feedback — no callers outside the type, so fileprivate widens access beyond what is needed.
…onents (#1691) * feat: Implement ADYEN_AFFIRM billing address redirect in CheckoutComponents Add a new BillingAddressRedirect scope for payment methods that require billing address collection before redirect (Affirm via Adyen). New scope type: - PrimerBillingAddressRedirectScope (public protocol) - PrimerBillingAddressRedirectState (public state) - DefaultBillingAddressRedirectScope (implementation) - BillingAddressRedirectScreen (billing form + submit) - BillingAddressRedirectPaymentMethod (registration) Reuses existing validation rules (AddressRule, CityRule, StateRule, PostalCodeRule) and ClientSessionActionsModule for billing address submission. The billing address is sent to the backend before the redirect to Affirm's hosted page. Required fields: country, address line 1, postal code, city, state. Address line 2 is optional. * fix: Exclude ADYEN_AFFIRM from WebRedirect dynamic registration ADYEN_AFFIRM has implementationType WEB_REDIRECT in the backend but needs BillingAddressRedirect scope (with billing form). Without this filter, WebRedirect would overwrite the dedicated registration since the registry uses last-write-wins. * chore: Remove ADYEN_AFFIRM WebRedirect exclusion Platform PR primer-io/platform#7233 changes adyen_affirm.yml implementation-type from WEB_REDIRECT to NATIVE_SDK, so the webRedirect filter already excludes it — the dedicated exclusion set becomes dead code. * test: Add coverage for BillingAddressRedirect scope lifecycle Cover start (idempotency), cancel, onBack (both contexts), presentationContext, init with paymentMethod/surcharge, validation edge cases, and submit happy + failure state transitions. Raises DefaultBillingAddressRedirectScope new coverage above 80%.
…CC-7135] (#1711) * refactor: Decouple card form field views from DefaultCardFormScope [ACC-7135] Introduce internal protocol CardFormFieldScopeInternal refining PrimerCardFormScope with the FieldValidationStates-keyed validation hook. Retype the 9 card form field components to accept the internal protocol, dropping all 33 `as? DefaultCardFormScope` guards that previously silently skipped validation for any scope other than DefaultCardFormScope (including MockCardFormScope used in previews). Trim the public PrimerCardFormScope protocol of two internal-only validation methods (updateValidationState(cardNumber:cvv:expiry:cardholderName:) and updateValidationStateIfNeeded(for:isValid:)) that were leftover plumbing from an earlier pre-public-API iteration. Neither was documented in API_REFERENCE.md. Rename updateValidationState(_ field:) -> updateValidationState(keyPath:) on DefaultCardFormScope+Validation for clarity at call sites. Follow-ups (ACC-7171, ACC-7172, ACC-7173) filed separately to address the remaining concrete-type couplings uncovered during the broader audit. * fix: Interpolate keyPath in MockCardFormScope validation log [ACC-7135] SonarCloud flagged the keyPath parameter as unused. Use it in the log message for parity with the sibling updateValidationStateIfNeeded method, making the preview log actually informative about which field was updated. * refactor: Drop redundant keyPath: label on updateValidationState [ACC-7135] Per review feedback — the strongly-typed WritableKeyPath parameter self-documents at call sites, so the explicit label added nothing. Drop the external label while keeping the internal name for readability in the method body.
…-components # Conflicts: # .cz.toml # BuildTools/.swiftformat # CHANGELOG.md # Debug App/Podfile.lock # PrimerSDK.podspec # Sources/PrimerSDK/Classes/Core/Analytics/AnalyticsEvent.swift # Sources/PrimerSDK/Classes/Core/PrimerHeadlessUniversalCheckout/Managers/Payment Method Managers/RawDataManager.swift # Sources/PrimerSDK/Classes/Data Models/PrimerConfiguration.swift # Sources/PrimerSDK/Classes/PCI/Services/API/Primer/PrimerAPI.swift # Sources/PrimerSDK/Classes/User Interface/TokenizationViewModels/PaymentMethodTokenizationViewModel+Logic.swift # Sources/PrimerSDK/Classes/version.swift # Tests/Klarna/PrimerHeadlessKlarnaComponentTests.swift # Tests/Utilities/Mocks/Services/MockAPIClient.swift
|



🚀 iOS CheckoutComponents SDK Implementation
Overview
This PR introduces the new CheckoutComponents SDK for iOS - a modern, SwiftUI-native payment integration framework that provides exact API parity with our Android SDK while leveraging iOS platform
strengths.
RFC Reference
https://www.notion.so/primerio/RFC-iOS-CheckoutComponents-SDK-23bca65dc30e8163bf68dea650baf9d9
Key Features Implemented
🏗️ Core Architecture
💳 Payment Components
🎨 Customization Capabilities
📱 Debug App Integration
Technical Highlights
Performance Optimizations
Modern iOS Patterns
File Changes Summary
Testing
Production Readiness
This implementation is feature-complete but requires additional work before production release as outlined in the RFC:
Dependencies
Breaking Changes
None - This is a new module that doesn't affect existing Drop-in UI functionality.
Next Steps