diff --git a/.github/workflows/build-kits.yml b/.github/workflows/build-kits.yml index 990612569..884b96e16 100644 --- a/.github/workflows/build-kits.yml +++ b/.github/workflows/build-kits.yml @@ -37,15 +37,25 @@ jobs: - name: Pod Lib Lint run: | echo "Linting: ${{ matrix.kit.podspec }}" - INCLUDES="mParticle-Apple-SDK-Swift.podspec,mParticle-Apple-SDK-ObjC.podspec,mParticle-Apple-SDK.podspec" + INCLUDES="" + if [ "${{ matrix.kit.pod_lint_standalone }}" != "true" ]; then + INCLUDES="mParticle-Apple-SDK-Swift.podspec,mParticle-Apple-SDK-ObjC.podspec,mParticle-Apple-SDK.podspec" + fi if [ -n "${{ matrix.kit.pod_lint_include_podspecs }}" ]; then - INCLUDES="${INCLUDES},${{ matrix.kit.pod_lint_include_podspecs }}" + if [ -n "$INCLUDES" ]; then + INCLUDES="${INCLUDES},${{ matrix.kit.pod_lint_include_podspecs }}" + else + INCLUDES="${{ matrix.kit.pod_lint_include_podspecs }}" + fi + fi + INCLUDE_ARGS=() + if [ -n "$INCLUDES" ]; then + INCLUDE_ARGS=(--include-podspecs="{${INCLUDES}}") fi - INCLUDES="{${INCLUDES}}" for attempt in 1 2 3; do pod lib lint "${{ matrix.kit.podspec }}" \ --allow-warnings \ - --include-podspecs="$INCLUDES" \ + "${INCLUDE_ARGS[@]}" \ && break [ $attempt -lt 3 ] && echo "Attempt $attempt failed, retrying in 60s..." && sleep 60 || exit 1 done diff --git a/.github/workflows/release-draft.yml b/.github/workflows/release-draft.yml index b0ed21333..f02739364 100644 --- a/.github/workflows/release-draft.yml +++ b/.github/workflows/release-draft.yml @@ -94,6 +94,8 @@ jobs: Kits/rokt-sdk-plus/rokt-sdk-plus-ios/Sources/RoktSDKPlus/RoktSDKPlus.swift sed -i '' 's/^let version = "[^"]*"/let version = "'"${VERSION}"'"/' \ Kits/rokt-sdk-plus/rokt-sdk-plus-ios/Package.swift + sed -i '' 's/^let version = "[^"]*"/let version = "'"${VERSION}"'"/' \ + Kits/rokt-payment-extension/rokt-payment-extension-ios/Package.swift sed -i '' "s/kLibVersion = @\"[^\"]*\"/kLibVersion = @\"${VERSION}\"/" \ Kits/clevertap/clevertap-7/Sources/mParticle-CleverTap/MPKitCleverTap.m sed -i '' "s/registerPluginName:@\"mParticleKit\" version:@\"[^\"]*\"/registerPluginName:@\"mParticleKit\" version:@\"${VERSION}\"/" \ @@ -166,7 +168,7 @@ jobs: - Kit source version strings (Rokt \`kMPRoktKitVersion\`, RoktSDKPlus \`RoktSDKPlus.version\`, CleverTap \`kLibVersion\`, Branch \`registerPluginName\`) - \`Framework/Info.plist\` - Integration test mappings - - Kit \`Package.swift\`: \`let version\` for \`Kits/rokt-sdk-plus/rokt-sdk-plus-ios\` on every release; other kits’ \`let version\` only on **major** bumps (core SDK SPM pin) + - Kit \`Package.swift\`: \`let version\` for \`Kits/rokt-sdk-plus/rokt-sdk-plus-ios\` and \`Kits/rokt-payment-extension/rokt-payment-extension-ios\` on every release; other kits’ \`let version\` only on **major** bumps (core SDK SPM pin) --- diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index ea5779727..a045aa6ab 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -284,7 +284,11 @@ jobs: id: push run: | DEST_URL="https://x-access-token:${{ steps.generate-token.outputs.token }}@github.com/${{ env.DEST_ORG }}/${{ matrix.kit.dest_repo }}.git" - git push "$DEST_URL" "split/${{ matrix.kit.name }}:main" + if [ "${{ matrix.kit.mirror_force_push_main }}" = "true" ]; then + git push --force "$DEST_URL" "split/${{ matrix.kit.name }}:main" + else + git push "$DEST_URL" "split/${{ matrix.kit.name }}:main" + fi echo "sha=$(git rev-parse split/${{ matrix.kit.name }})" >> $GITHUB_OUTPUT - name: Create GitHub release on mirror @@ -389,6 +393,13 @@ jobs: . Scripts/pod_push.sh wait_for_pod_version_on_trunk mParticle-Rokt "$VERSION" + - name: Wait for RoktPaymentExtension on CocoaPods CDN (RoktSDKPlus) + if: matrix.kit.name == 'rokt-sdk-plus-ios' + run: | + VERSION=$(head -n 1 VERSION | tr -d '\r\n ') + . Scripts/pod_push.sh + wait_for_pod_version_on_trunk RoktPaymentExtension "$VERSION" + - name: Publish to CocoaPods if: matrix.kit.podspec != '' env: diff --git a/Kits/matrix.json b/Kits/matrix.json index 721825105..5e56a919d 100644 --- a/Kits/matrix.json +++ b/Kits/matrix.json @@ -393,17 +393,37 @@ } ] }, + { + "name": "rokt-payment-extension-ios", + "local_path": "Kits/rokt-payment-extension/rokt-payment-extension-ios", + "podspec": "Kits/rokt-payment-extension/rokt-payment-extension-ios/RoktPaymentExtension.podspec", + "dest_repo": "rokt-payment-extension-ios", + "dest_org": "ROKT", + "mirror_force_push_main": true, + "pod_lint_standalone": true, + "spm_package_only": true, + "skip_xcframework": true, + "skip_example_builds": true, + "schemes": [ + { + "scheme": "RoktPaymentExtension", + "module": "RoktPaymentExtension", + "destination": "iOS" + } + ] + }, { "name": "rokt-sdk-plus-ios", "local_path": "Kits/rokt-sdk-plus/rokt-sdk-plus-ios", "podspec": "Kits/rokt-sdk-plus/rokt-sdk-plus-ios/RoktSDKPlus.podspec", "dest_repo": "rokt-sdk-plus-ios", "dest_org": "ROKT", + "mirror_force_push_main": true, "spm_package_only": true, "skip_xcframework": true, "skip_example_builds": true, "skip_spm_tests": true, - "pod_lint_include_podspecs": "Kits/rokt/rokt/mParticle-Rokt.podspec", + "pod_lint_include_podspecs": "Kits/rokt/rokt/mParticle-Rokt.podspec,Kits/rokt-payment-extension/rokt-payment-extension-ios/RoktPaymentExtension.podspec", "schemes": [ { "scheme": "RoktSDKPlus", diff --git a/Kits/rokt-payment-extension/rokt-payment-extension-ios/.gitignore b/Kits/rokt-payment-extension/rokt-payment-extension-ios/.gitignore new file mode 100644 index 000000000..909aa1b38 --- /dev/null +++ b/Kits/rokt-payment-extension/rokt-payment-extension-ios/.gitignore @@ -0,0 +1,40 @@ +.DS_Store +/.build +Package.resolved +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc +.idea + +# Xcode +build/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata/ +*.xccheckout +profile +*.moved-aside +DerivedData +*.hmap +*.ipa + +# Bundler +.bundle + +# CocoaPods +Pods/ + +aws/ +vendor/ +repositories +checkouts +workspace-state.json diff --git a/Kits/rokt-payment-extension/rokt-payment-extension-ios/.markdownlint.yaml b/Kits/rokt-payment-extension/rokt-payment-extension-ios/.markdownlint.yaml new file mode 100644 index 000000000..d32bc6a1d --- /dev/null +++ b/Kits/rokt-payment-extension/rokt-payment-extension-ios/.markdownlint.yaml @@ -0,0 +1,4 @@ +--- +default: true +MD013: false +MD040: false diff --git a/Kits/rokt-payment-extension/rokt-payment-extension-ios/.swiftformat b/Kits/rokt-payment-extension/rokt-payment-extension-ios/.swiftformat new file mode 100644 index 000000000..ec8470016 --- /dev/null +++ b/Kits/rokt-payment-extension/rokt-payment-extension-ios/.swiftformat @@ -0,0 +1,117 @@ +--swiftversion 5.9 +--acronyms ID,URL,UUID +--allman false +--anonymousforeach convert +--assetliterals visual-width +--asynccapturing +--beforemarks +--binarygrouping none +--callsiteparen default +--categorymark "MARK: %c" +--classthreshold 0 +--closingparen balanced +--closurevoid remove +--commas inline +--complexattrs preserve +--computedvarattrs preserve +--condassignment after-property +--conflictmarkers reject +--dateformat system +--decimalgrouping none +--doccomments before-declarations +--elseposition same-line +--emptybraces no-space +--enumnamespaces always +--enumthreshold 0 +--exponentcase lowercase +--exponentgrouping disabled +--extensionacl on-extension +--extensionlength 0 +--extensionmark "MARK: - %t + %c" +--fractiongrouping disabled +--fragment false +--funcattributes preserve +--generictypes +--groupedextension "MARK: %c" +--guardelse auto +--header ignore +--hexgrouping none +--hexliteralcase uppercase +--ifdef no-indent +--importgrouping alpha +--indent 4 +--indentcase false +--indentstrings false +--initcodernil false +--lifecycle +--lineaftermarks true +--linebreaks lf +--markcategories true +--markextensions always +--marktypes always + +# Wrap lines that exceed the specified maximum width. +--rules wrap +--maxwidth 130 + +--modifierorder +--nevertrailing +--nilinit remove +--noncomplexattrs +--nospaceoperators ...,..<,/ +--nowrapoperators +--octalgrouping none +--onelineforeach ignore +--operatorfunc spaced +--organizationmode visibility +--organizetypes actor,class,enum,struct +--patternlet inline +--ranges spaced +--redundanttype infer-locals-only +--self remove +--selfrequired +--semicolons never +--shortoptionals except-properties +--smarttabs enabled +--someany true +--storedvarattrs preserve +--stripunusedargs closure-only +--structthreshold 0 +--tabwidth unspecified +--throwcapturing +--timezone system +--trailingclosures +--trimwhitespace always +--typeattributes preserve +--typeblanklines remove +--typedelimiter space-after +--typemark "MARK: - %t" +--voidtype void +--wraparguments preserve +--wrapcollections preserve +--wrapconditions preserve +--wrapeffects preserve +--wrapenumcases always +--wrapparameters default +--wrapreturntype preserve +--wrapternary default +--wraptypealiases preserve +--xcodeindentation disabled +--yodaswap always +--rules blankLinesAroundMark +--rules isEmpty +# Replace consecutive blank lines with a single blank line. +--rules consecutiveBlankLines + +# Replace consecutive spaces with a single space. +--rules consecutiveSpaces +--rules spaceAroundOperators +--operatorfunc spaced + +# Add or remove space around parentheses. +--rules spaceAroundParens +--rules spaceInsideParens + +# Mark unused function arguments with _. +--rules unusedArguments +--stripunusedargs closure-only diff --git a/Kits/rokt-payment-extension/rokt-payment-extension-ios/.swiftlint.yml b/Kits/rokt-payment-extension/rokt-payment-extension-ios/.swiftlint.yml new file mode 100644 index 000000000..73bfe2329 --- /dev/null +++ b/Kits/rokt-payment-extension/rokt-payment-extension-ios/.swiftlint.yml @@ -0,0 +1,29 @@ +--- +disabled_rules: + - identifier_name + - force_cast + - weak_delegate + - function_parameter_count + - file_length + - type_body_length + - cyclomatic_complexity + - function_body_length + +nesting: + type_level: + warning: 3 + error: 6 + +line_length: + warning: 130 + error: 160 + ignores_comments: true + ignores_urls: true + +type_name: + min_length: 1 + max_length: 40 + +excluded: + - Tests + - .build diff --git a/Kits/rokt-payment-extension/rokt-payment-extension-ios/.yamllint.yaml b/Kits/rokt-payment-extension/rokt-payment-extension-ios/.yamllint.yaml new file mode 100644 index 000000000..12e4fe595 --- /dev/null +++ b/Kits/rokt-payment-extension/rokt-payment-extension-ios/.yamllint.yaml @@ -0,0 +1,11 @@ +--- +extends: default + +rules: + truthy: + check-keys: false + document-start: disable + line-length: + max: 200 + comments: + min-spaces-from-content: 1 diff --git a/Kits/rokt-payment-extension/rokt-payment-extension-ios/LICENSE.md b/Kits/rokt-payment-extension/rokt-payment-extension-ios/LICENSE.md new file mode 100644 index 000000000..eba0cbcc0 --- /dev/null +++ b/Kits/rokt-payment-extension/rokt-payment-extension-ios/LICENSE.md @@ -0,0 +1,10 @@ +# License + +Copyright 2024 Rokt Pte Ltd + +Licensed under the Rokt Software Development Kit (SDK) Terms of Use +Version 2.0 (the "License") + +You may not use this file except in compliance with the License. + +You may obtain a copy of the License at diff --git a/Kits/rokt-payment-extension/rokt-payment-extension-ios/MIGRATING.md b/Kits/rokt-payment-extension/rokt-payment-extension-ios/MIGRATING.md new file mode 100644 index 000000000..a8553da0c --- /dev/null +++ b/Kits/rokt-payment-extension/rokt-payment-extension-ios/MIGRATING.md @@ -0,0 +1,76 @@ +# Migration Guide + +This document provides guidance on migrating to newer versions of the Rokt Payment Extension for iOS. + +## Ecosystem versioning (mParticle monorepo) + +When consumed from the [mParticle Apple SDK monorepo](https://github.com/mParticle/mparticle-apple-sdk) or its mirror **[ROKT/rokt-payment-extension-ios](https://github.com/ROKT/rokt-payment-extension-ios)**, **RoktPaymentExtension** uses the **same semver as mParticle-Apple-SDK** (for example `9.2.1`), not the legacy **2.x** release line. Update SPM pins and CocoaPods to that ecosystem version; tags are **`v`** (for example `v9.2.1`). + +## Migrating from 1.x to 2.0.0 + +Version 2.0 replaces the `returnURL:` init parameter with `urlScheme:`. Partners now pass only the bare URL scheme — the SDK builds the full redirect URL (`://rokt-payment-return`) internally and verifies the scheme is registered under `CFBundleURLSchemes` in the host app's `Info.plist` at init time. + +### Init parameter rename + +**Before (1.x):** + +```swift +guard let paymentExtension = RoktPaymentExtension( + applePayMerchantId: "merchant.com.example", + returnURL: "myapp://stripe-redirect" +) else { return } +``` + +**After (2.0):** + +```swift +guard let paymentExtension = RoktPaymentExtension( + applePayMerchantId: "merchant.com.example", + urlScheme: "myapp" +) else { return } +``` + +Your `Info.plist` `CFBundleURLSchemes` entry does not need to change — keep the same scheme you already declared. + +### New init-time validation + +`init?` now returns `nil` when the supplied `urlScheme` is not registered under `CFBundleURLSchemes` in the host app's `Info.plist`. + +- **DEBUG builds** also fire an `assertionFailure` with a copy-paste `Info.plist` snippet. +- **Release builds** log the same message via `os_log` at `.error` level. + +If your `guard let` starts returning `nil`, double-check that the scheme you pass to `urlScheme:` exactly matches an entry in your `Info.plist`: + +```xml +CFBundleURLTypes + + + CFBundleURLSchemes + myapp + + +``` + +### `handleURLCallback(with:)` now filters URLs + +`handleURLCallback(with:)` now filters incoming URLs to the configured `://rokt-payment-return` pattern and returns `false` for any URL that doesn't match. If your app forwards every redirect URL to every registered payment extension, URLs owned by your own code are no longer accidentally consumed by the Rokt extension. + +### Afterpay-not-configured error message + +The error message raised when Afterpay is triggered without an init-time `urlScheme` now reads: + +> `Afterpay not configured. Provide a urlScheme at init.` + +(Previously: `... Provide a returnURL at init.`) + +--- + +## Migrating from `RoktStripePaymentExtension` (0.x) to 1.0.0 + +Version 1.0 renames the package and class. To migrate: + +1. Replace `import RoktStripePaymentExtension` with `import RoktPaymentExtension`. +2. Replace the class name `RoktStripePaymentExtension` with `RoktPaymentExtension`. +3. Update your `Package.swift` URL to `https://github.com/ROKT/rokt-payment-extension-ios.git` (old URL auto-redirects via GitHub). +4. Update your `Podfile`: `pod 'RoktPaymentExtension'`. +5. Optional: the initializer now accepts an optional `applePayMerchantId`. To support only Afterpay, drop it and pass `urlScheme` instead (see [Migrating from 1.x to 2.0.0](#migrating-from-1x-to-200) above for the current Afterpay init parameter). diff --git a/Kits/rokt-payment-extension/rokt-payment-extension-ios/Package.swift b/Kits/rokt-payment-extension/rokt-payment-extension-ios/Package.swift new file mode 100644 index 000000000..3dc454877 --- /dev/null +++ b/Kits/rokt-payment-extension/rokt-payment-extension-ios/Package.swift @@ -0,0 +1,30 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let version = "9.2.1" + +let package = Package( + name: "RoktPaymentExtension", + platforms: [.iOS(.v15)], + products: [ + .library(name: "RoktPaymentExtension", targets: ["RoktPaymentExtension"]) + ], + dependencies: [ + .package(url: "https://github.com/ROKT/rokt-contracts-apple.git", from: "2.0.2"), + .package(url: "https://github.com/stripe/stripe-ios.git", from: "25.0.0") + ], + targets: [ + .target( + name: "RoktPaymentExtension", + dependencies: [ + .product(name: "RoktContracts", package: "rokt-contracts-apple"), + .product(name: "StripeApplePay", package: "stripe-ios"), + .product(name: "StripePayments", package: "stripe-ios") + ] + ), + .testTarget( + name: "RoktPaymentExtensionTests", + dependencies: ["RoktPaymentExtension"] + ) + ] +) diff --git a/Kits/rokt-payment-extension/rokt-payment-extension-ios/README.md b/Kits/rokt-payment-extension/rokt-payment-extension-ios/README.md new file mode 100644 index 000000000..71af337e1 --- /dev/null +++ b/Kits/rokt-payment-extension/rokt-payment-extension-ios/README.md @@ -0,0 +1,205 @@ +# Rokt Payment Extension (iOS) + +Developed in the [mParticle Apple SDK monorepo](https://github.com/mParticle/mparticle-apple-sdk) under `Kits/rokt-payment-extension/rokt-payment-extension-ios` and mirrored to **[ROKT/rokt-payment-extension-ios](https://github.com/ROKT/rokt-payment-extension-ios)**. **RoktPaymentExtension** is versioned with the mParticle Apple SDK ecosystem (`VERSION` in the monorepo); SPM/CocoaPods tags use that semver (not the legacy 2.x line). + +Optional payment integration for the Rokt iOS SDK ecosystem. Currently provides +Apple Pay, card, and Afterpay/Clearpay support via Stripe for +[Shoppable Ads](https://docs.rokt.com) placements. Designed to host additional +providers (e.g. PayPal, Klarna) over time. + +This package depends only on [RoktContracts](https://github.com/ROKT/rokt-contracts-apple) — not the full Rokt SDK — keeping payment-provider SDKs isolated and the integration lightweight. + +## Requirements + +- iOS 15.6+ +- Swift 5.9+ +- Xcode 15.0+ +- Stripe account with Apple Pay enabled (for Apple Pay / card) +- For Afterpay / Clearpay: a Stripe account with the method enabled and a custom + URL scheme registered in the host app's `Info.plist` under `CFBundleURLSchemes` + (you pass the same scheme to the extension via `urlScheme:`) + +## Installation + +### Swift Package Manager + +In Xcode: **File > Add Packages**, enter: + +```text +https://github.com/ROKT/rokt-payment-extension-ios.git +``` + +Or add to your `Package.swift`: + +```swift +dependencies: [ + .package(url: "https://github.com/ROKT/rokt-payment-extension-ios.git", from: "9.2.1") +] +``` + +### CocoaPods + +```ruby +pod 'RoktPaymentExtension' +``` + +## Usage + +The extension accepts optional init params — you enable only the methods you +want to support. At least one of `applePayMerchantId` or `urlScheme` must be +provided; otherwise the initializer returns `nil`. + +| Init parameters | Enables | +| ------------------------- | ------------------------- | +| `applePayMerchantId` only | Apple Pay, card | +| `urlScheme` only | Afterpay / Clearpay | +| Both | Apple Pay, card, Afterpay | + +### Direct Rokt SDK Integration + +When using the Rokt SDK directly, the partner provides the Stripe publishable key +explicitly at registration time: + +```swift +import Rokt_Widget +import RoktPaymentExtension + +// 1. Initialize Rokt +Rokt.initWith(roktTagId: "your-tag-id") + +// 2. Create the payment extension. +// Supply `applePayMerchantId` for Apple Pay, `urlScheme` for Afterpay, or both. +guard let paymentExtension = RoktPaymentExtension( + applePayMerchantId: "merchant.com.example", + urlScheme: "myapp" // bare scheme — omit to keep the extension Apple-Pay-only +) else { return } + +// 3. Register with the Rokt SDK — pass your Stripe publishable key +Rokt.registerPaymentExtension(paymentExtension, config: [ + "stripeKey": "pk_live_abc123" +]) + +// 4. Show Shoppable Ads (always overlay) +Rokt.selectShoppableAds( + identifier: "ConfirmationPage", + attributes: [ + "email": "user@example.com", + "firstname": "John", + "lastname": "Doe", + "confirmationref": "ORDER-12345" + ], + onEvent: { event in + switch event { + case let e as RoktEvent.CartItemInstantPurchase: + print("Purchase: \(e.catalogItemId)") + case let e as RoktEvent.CartItemInstantPurchaseFailure: + print("Failed: \(e.error ?? "unknown")") + default: + break + } + } +) +``` + +### SDK+ Integration + +When using the mParticle SDK, the Stripe publishable key is **automatically provided +from the mParticle dashboard configuration**. The partner only needs to create the +extension and register it — the Kit injects the `stripeKey` before forwarding to the +Rokt SDK: + +```swift +import mParticle_Apple_SDK +import RoktPaymentExtension + +// 1. mParticle init handles Rokt.initialize via Kit (tagId from dashboard) + +// 2. Create and register the payment extension — no stripeKey needed. +guard let paymentExtension = RoktPaymentExtension( + applePayMerchantId: "merchant.com.example", + urlScheme: "myapp" // bare scheme — omit to keep the extension Apple-Pay-only +) else { return } +MParticle.sharedInstance().rokt.registerPaymentExtension(paymentExtension) +// Kit automatically injects stripeKey from dashboard config + +// 3. Show Shoppable Ads +MParticle.sharedInstance().rokt.shoppableAds( + "ConfirmationPage", + attributes: [ + "email": "user@example.com", + "firstname": "John", + "lastname": "Doe" + ] +) +``` + +### Enabling Afterpay / Clearpay + +Afterpay/Clearpay is a redirect-based payment method: Stripe opens a web page for +authentication and redirects back to your app via a custom URL scheme. + +1. **Declare the URL scheme** in your host app's `Info.plist` under + `CFBundleURLTypes` (e.g. `myapp`). +2. **Pass the matching `urlScheme`** when creating the extension + (e.g. `"myapp"`). The SDK builds the full return URL internally — you + never need to type the path. The initializer returns `nil` if the scheme + isn't registered in `Info.plist` (and raises an `assertionFailure` in + DEBUG builds). Omit `urlScheme` entirely and the extension stays + Apple-Pay-only. +3. **Forward redirect URLs** to the Rokt SDK from your `SceneDelegate` / + `AppDelegate`. The SDK dispatches the URL to every registered + `PaymentExtension` via the optional `handleURLCallback(with:)` hook, which + this extension implements by calling `StripeAPI.handleURLCallback(with:)`. + + ```swift + // SceneDelegate + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + for ctx in URLContexts { + Rokt.handleURLCallback(with: ctx.url) + } + } + ``` + +### What Partners Need for Each Scenario + +| Scenario | Packages | Stripe Key Source | Code | +| ---------------------------- | ---------------------------------- | ----------------------------- | -------------------------------------------------------------------- | +| Standard placements (SDK+) | mParticle SDK + Rokt Kit | — | `rokt.selectPlacements(...)` | +| Shoppable Ads (SDK+) | Above + RoktPaymentExtension | Dashboard config (automatic) | `registerPaymentExtension(ext)` + `shoppableAds(...)` | +| Standard placements (Direct) | Rokt-Widget | — | `Rokt.selectPlacements(...)` | +| Shoppable Ads (Direct) | Rokt-Widget + RoktPaymentExtension | Partner passes in config dict | `registerPaymentExtension(ext, config:)` + `selectShoppableAds(...)` | + +For Apple Pay, the extension now uses the backend preparation response to show +shipping, tax, and final total line items in the PassKit sheet whenever those +amounts are supplied. + +## Architecture + +```text +RoktPaymentExtension (public facade) + ├── StripeApplePayManager (Apple Pay / card) ← built if applePayMerchantId provided + │ ├── STPApplePayContext (Stripe SDK) + │ └── ContactAddressMapping (PKContact → ContactAddress) + ├── StripeAfterpayManager (Afterpay / Clearpay) ← built if urlScheme provided + │ ├── STPPaymentHandler (Stripe SDK) + │ └── BillingDetailsMapping (ContactAddress → Stripe billing/shipping) + └── handleURLCallback(with:) → StripeAPI.handleURLCallback +``` + +- **RoktPaymentExtension**: Implements `PaymentExtension` protocol from RoktContracts; routes each `PaymentMethodType` to the matching internal manager. `supportedMethods` is computed from the configured managers. +- **StripeApplePayManager**: Manages Apple Pay / card flows via Stripe's `STPApplePayContext`, including line-item totals from the backend payment preparation response. +- **StripeAfterpayManager**: Manages redirect-based Afterpay / Clearpay flows via `STPPaymentHandler`; validates `PaymentContext.billingAddress` and confirms the PaymentIntent with a Rokt-owned return URL built from the partner's `urlScheme`. +- **ContactAddressMapping**: Converts Apple Pay `PKContact` to `ContactAddress`. +- **BillingDetailsMapping**: Converts `ContactAddress` to `STPPaymentMethodBillingDetails` and `STPPaymentIntentShippingDetailsParams`. + +## Migration + +See [MIGRATING.md](MIGRATING.md) for migration guidance between major versions. + +## License + +Copyright 2024 Rokt Pte Ltd. Licensed under the [Rokt SDK Terms of Use 2.0](https://rokt.com/sdk-license-2-0/). + +## Security + +Please report vulnerabilities via our [disclosure form](https://www.rokt.com/vulnerability-disclosure/). Do not use GitHub issues. diff --git a/Kits/rokt-payment-extension/rokt-payment-extension-ios/RoktPaymentExtension.podspec b/Kits/rokt-payment-extension/rokt-payment-extension-ios/RoktPaymentExtension.podspec new file mode 100644 index 000000000..79a38b6dc --- /dev/null +++ b/Kits/rokt-payment-extension/rokt-payment-extension-ios/RoktPaymentExtension.podspec @@ -0,0 +1,21 @@ +Pod::Spec.new do |s| + s.name = 'RoktPaymentExtension' + s.version = '9.2.1' + s.summary = 'Payment extension for the Rokt SDK ecosystem (Apple Pay + Afterpay via Stripe).' + s.swift_version = '5.9' + s.description = <<-DESC + Payment integration for Rokt Shoppable Ads. Implements the PaymentExtension + protocol from RoktContracts. Currently supports Apple Pay, card, and + Afterpay/Clearpay via Stripe; designed to host additional providers over time. + DESC + s.homepage = 'https://github.com/ROKT/rokt-payment-extension-ios' + s.license = { :type => 'Rokt SDK Terms of Use 2.0', :file => 'LICENSE.md' } + s.author = { 'ROKT DEV' => 'nativeappsdev@rokt.com' } + s.source = { :git => 'https://github.com/ROKT/rokt-payment-extension-ios.git', :tag => 'v' + s.version.to_s } + s.ios.deployment_target = '15.6' + s.source_files = 'Sources/RoktPaymentExtension/**/*.swift' + s.frameworks = 'Foundation', 'PassKit' + s.dependency 'RoktContracts', '~> 2.0.2' + s.dependency 'StripeApplePay', '~> 25.0' + s.dependency 'StripePayments', '~> 25.0' +end diff --git a/Kits/rokt-payment-extension/rokt-payment-extension-ios/SECURITY.md b/Kits/rokt-payment-extension/rokt-payment-extension-ios/SECURITY.md new file mode 100644 index 000000000..4f3346ae1 --- /dev/null +++ b/Kits/rokt-payment-extension/rokt-payment-extension-ios/SECURITY.md @@ -0,0 +1,9 @@ +# Security Policy + +## Reporting a vulnerability + +To avoid abuse by malicious actors please do not open GitHub issues or pull requests for any security related issue you may have spotted. + +The safest way to report any vulnerability or concern you may have is via our [dedicated submission form](https://www.rokt.com/vulnerability-disclosure/). + +For further information please refer to the [Rokt Vulnerability Disclosure Policy](https://www.rokt.com/vulnerability-disclosure/). diff --git a/Kits/rokt-payment-extension/rokt-payment-extension-ios/Sources/RoktPaymentExtension/BillingDetailsMapping.swift b/Kits/rokt-payment-extension/rokt-payment-extension-ios/Sources/RoktPaymentExtension/BillingDetailsMapping.swift new file mode 100644 index 000000000..45c62c0e5 --- /dev/null +++ b/Kits/rokt-payment-extension/rokt-payment-extension-ios/Sources/RoktPaymentExtension/BillingDetailsMapping.swift @@ -0,0 +1,54 @@ +import RoktContracts +import StripePayments + +enum BillingDetailsMapping { + /// Maps a ``ContactAddress`` to Stripe billing details for Afterpay payment method params. + static func map(from address: ContactAddress, fallbackName: String? = nil) -> STPPaymentMethodBillingDetails { + let billing = STPPaymentMethodBillingDetails() + billing.name = resolvedName(address.name, fallback: fallbackName) + billing.email = address.email + + let stripeAddress = STPPaymentMethodAddress() + stripeAddress.line1 = address.addressLine1 + stripeAddress.line2 = address.addressLine2 + stripeAddress.city = address.city + stripeAddress.state = address.state + stripeAddress.postalCode = address.postalCode + stripeAddress.country = address.country + billing.address = stripeAddress + + return billing + } + + /// Maps a ``ContactAddress`` to Stripe shipping details for the PaymentIntent. + static func mapShipping( + from address: ContactAddress, + fallbackName: String? = nil + ) -> STPPaymentIntentShippingDetailsParams { + let shippingAddress = STPPaymentIntentShippingDetailsAddressParams(line1: address.addressLine1 ?? "") + shippingAddress.line2 = address.addressLine2 + shippingAddress.city = address.city + shippingAddress.state = address.state + shippingAddress.postalCode = address.postalCode + shippingAddress.country = address.country + + return STPPaymentIntentShippingDetailsParams( + address: shippingAddress, + name: resolvedName(address.name, fallback: fallbackName) ?? "" + ) + } + + static func resolvedName(_ name: String?, fallback: String? = nil) -> String? { + let trimmed = name?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmed, !trimmed.isEmpty { + return trimmed + } + + let fallback = fallback?.trimmingCharacters(in: .whitespacesAndNewlines) + if let fallback, !fallback.isEmpty { + return fallback + } + + return nil + } +} diff --git a/Kits/rokt-payment-extension/rokt-payment-extension-ios/Sources/RoktPaymentExtension/ContactAddressMapping.swift b/Kits/rokt-payment-extension/rokt-payment-extension-ios/Sources/RoktPaymentExtension/ContactAddressMapping.swift new file mode 100644 index 000000000..e95975eab --- /dev/null +++ b/Kits/rokt-payment-extension/rokt-payment-extension-ios/Sources/RoktPaymentExtension/ContactAddressMapping.swift @@ -0,0 +1,20 @@ +import Contacts +import PassKit +import RoktContracts + +enum ContactAddressMapping { + static func map(from contact: PKContact) -> ContactAddress { + let name = [contact.name?.givenName, contact.name?.familyName] + .compactMap { $0 } + .joined(separator: " ") + return ContactAddress( + name: name, + email: contact.emailAddress.flatMap { $0 as String? } ?? "", + addressLine1: contact.postalAddress?.street, + city: contact.postalAddress?.city, + state: contact.postalAddress?.state, + postalCode: contact.postalAddress?.postalCode, + country: contact.postalAddress?.isoCountryCode + ) + } +} diff --git a/Kits/rokt-payment-extension/rokt-payment-extension-ios/Sources/RoktPaymentExtension/RoktPaymentExtension.swift b/Kits/rokt-payment-extension/rokt-payment-extension-ios/Sources/RoktPaymentExtension/RoktPaymentExtension.swift new file mode 100644 index 000000000..df258bf09 --- /dev/null +++ b/Kits/rokt-payment-extension/rokt-payment-extension-ios/Sources/RoktPaymentExtension/RoktPaymentExtension.swift @@ -0,0 +1,255 @@ +import Foundation +import os.log +import PassKit +import RoktContracts +import StripeApplePay +import UIKit + +/// Rokt payment extension backed by Stripe. +/// +/// Currently supports Apple Pay and Afterpay/Clearpay via Stripe SDKs. +/// Partners provide what they want to support at init time: +/// - `applePayMerchantId` only → Apple Pay (and card via Apple Pay sheet) +/// - `urlScheme` only → Afterpay +/// - Both → all three methods +/// +/// Returns `nil` if neither `applePayMerchantId` nor `urlScheme` is provided, +/// or if the supplied `urlScheme` is not registered under `CFBundleURLSchemes` +/// in the host app's `Info.plist`. +public class RoktPaymentExtension: PaymentExtension { + + // MARK: - PaymentExtension Protocol Properties + + public let id: String = "rokt-payment-extension" + public let extensionDescription: String = "Rokt Payment Extension" + + /// Payment methods this extension supports, determined by which parameters + /// were provided at initialization. Apple Pay / card require + /// `applePayMerchantId`; Afterpay requires `urlScheme`. + public var supportedMethods: [String] { + var methods: [String] = [] + if let merchantId, !merchantId.isEmpty { + methods.append(PaymentMethodType.applePay.wireValue) + methods.append(PaymentMethodType.card.wireValue) + } + if let urlScheme, !urlScheme.isEmpty { + methods.append(PaymentMethodType.afterpay.wireValue) + } + return methods + } + + // MARK: - Private Properties + + private let merchantId: String? + private let countryCode: String + private let urlScheme: String? + + private var stripeApplePayManager: StripeApplePayManager? + private var stripeAfterpayManager: StripeAfterpayManager? + + static let returnHost = "rokt-payment-return" + + // MARK: - Initialization + + /// Initialize the Rokt payment extension. + /// + /// Supply `applePayMerchantId` to enable Apple Pay / card support. + /// Supply `urlScheme` to enable Afterpay (redirect-based). At least one of + /// the two must be provided — otherwise the initializer returns `nil`. + /// + /// When `urlScheme` is provided, the SDK builds the full redirect URL + /// (`://rokt-payment-return`) internally and verifies the scheme + /// is registered under `CFBundleURLSchemes` in `Info.plist`. + /// + /// - Parameters: + /// - applePayMerchantId: Apple Pay merchant identifier. Omit to disable Apple Pay. + /// - countryCode: ISO 3166-1 alpha-2 country code for the payment (default: "US"). + /// Applies only to Apple Pay. + /// - urlScheme: Bare custom URL scheme (e.g. `"com.partner.app"`) for redirect-based + /// payment methods like Afterpay. The scheme must also be registered under + /// `CFBundleURLSchemes` in the host app's `Info.plist`. Omit to disable Afterpay. + /// - Returns: `nil` if both `applePayMerchantId` and `urlScheme` are omitted or empty, + /// or if `urlScheme` is provided but not registered in `Info.plist`. + public convenience init?( + applePayMerchantId: String? = nil, + countryCode: String = "US", + urlScheme: String? = nil + ) { + self.init( + applePayMerchantId: applePayMerchantId, + countryCode: countryCode, + urlScheme: urlScheme, + bundle: .main + ) + } + + /// Internal init used by tests to inject a `Bundle` whose `Info.plist` + /// contains a controlled `CFBundleURLTypes` entry. + internal init?( + applePayMerchantId: String? = nil, + countryCode: String = "US", + urlScheme: String? = nil, + bundle: Bundle + ) { + let hasApplePay = !(applePayMerchantId?.isEmpty ?? true) + let hasAfterpay = !(urlScheme?.isEmpty ?? true) + guard hasApplePay || hasAfterpay else { return nil } + + if hasAfterpay, let scheme = urlScheme { + guard Self.isValidBareScheme(scheme), + Self.isSchemeRegistered(scheme, in: bundle) else { + Self.reportInvalidScheme(scheme) + return nil + } + } + + self.merchantId = applePayMerchantId + self.countryCode = countryCode + self.urlScheme = hasAfterpay ? urlScheme : nil + } + + // MARK: - PaymentExtension Protocol Implementation + + @discardableResult + public func onRegister(parameters: [String: String]) -> Bool { + guard let stripeKey = parameters["stripeKey"], !stripeKey.isEmpty else { + return false + } + + let apiClient = STPAPIClient(publishableKey: stripeKey) + + if let merchantId, !merchantId.isEmpty { + stripeApplePayManager = StripeApplePayManager( + apiClient: apiClient, + merchantId: merchantId, + countryCode: countryCode + ) + } + + if let urlScheme, !urlScheme.isEmpty { + let returnURL = "\(urlScheme)://\(Self.returnHost)" + stripeAfterpayManager = StripeAfterpayManager( + apiClient: apiClient, + returnURL: returnURL + ) + } + + return true + } + + public func onUnregister() { + stripeApplePayManager = nil + stripeAfterpayManager = nil + } + + public func presentPaymentSheet( + item: PaymentItem, + method: PaymentMethodType, + context: PaymentContext, + from viewController: UIViewController, + preparePayment: @escaping ( + _ address: ContactAddress, + _ completion: @escaping (PaymentPreparation?, Error?) -> Void + ) -> Void, + completion: @escaping (PaymentSheetResult) -> Void + ) { + switch method { + case .applePay, .card: + guard let stripeApplePayManager else { + completion(.failed(error: "Apple Pay not configured. Provide applePayMerchantId at init.")) + return + } + stripeApplePayManager.presentPayment( + item: item, + from: viewController, + preparePayment: preparePayment, + completion: completion + ) + + case .afterpay: + guard let stripeAfterpayManager else { + completion(.failed(error: "Afterpay not configured. Provide a urlScheme at init.")) + return + } + stripeAfterpayManager.presentPayment( + item: item, + context: context, + from: viewController, + preparePayment: preparePayment, + completion: completion + ) + + case .paypal: + // PayPal is defined in RoktContracts 2.x but not yet implemented here. + // Handled explicitly (rather than falling through `@unknown default`) so the + // compiler flags any future enum additions instead of silently accepting them. + completion(.failed(error: "Unsupported payment method: \(method.wireValue)")) + + @unknown default: + completion(.failed(error: "Unsupported payment method: \(method.wireValue)")) + } + } + + /// Forwards a redirect URL to Stripe so it can complete in-flight redirect-based + /// flows (e.g. Afterpay). Only URLs whose scheme matches the configured + /// `urlScheme` and whose host equals `rokt-payment-return` are forwarded — + /// anything else returns `false`, leaving partner-owned URLs untouched. + public func handleURLCallback(with url: URL) -> Bool { + guard let urlScheme, + url.scheme?.lowercased() == urlScheme.lowercased(), + url.host == Self.returnHost else { + return false + } + return StripeAPI.handleURLCallback(with: url) + } + + // MARK: - Scheme Validation Helpers + + /// Returns `true` when the scheme is non-empty and contains no path separator + /// characters — guarding against partners accidentally passing a full URL + /// (e.g. `"myapp://stripe-redirect"`) or a path fragment. + static func isValidBareScheme(_ scheme: String) -> Bool { + !scheme.isEmpty && !scheme.contains("://") && !scheme.contains("/") + } + + /// Returns `true` when `scheme` appears (case-insensitively) under any + /// `CFBundleURLSchemes` array inside `CFBundleURLTypes` in the bundle's + /// `Info.plist`. + static func isSchemeRegistered(_ scheme: String, in bundle: Bundle) -> Bool { + let target = scheme.lowercased() + guard let urlTypes = bundle.infoDictionary?["CFBundleURLTypes"] as? [[String: Any]] else { + return false + } + for entry in urlTypes { + if let schemes = entry["CFBundleURLSchemes"] as? [String], + schemes.map({ $0.lowercased() }).contains(target) { + return true + } + } + return false + } + + /// Reports an invalid / unregistered scheme. + /// In DEBUG builds the failure is surfaced via `assertionFailure` so the + /// integrating engineer sees it immediately. In release builds the message + /// is logged via `os_log` at `.error` and the initializer returns `nil`, + /// making the failure visible through the partner's `guard let ext = ...`. + private static func reportInvalidScheme(_ scheme: String) { + let message = """ + Rokt: URL scheme '\(scheme)' is not registered under CFBundleURLSchemes in Info.plist, \ + or contains invalid characters. Register it like this: + CFBundleURLTypes + + + CFBundleURLSchemes + \(scheme) + + + """ + #if DEBUG + assertionFailure(message) + #else + os_log("%{public}s", log: .default, type: .error, message) + #endif + } +} diff --git a/Kits/rokt-payment-extension/rokt-payment-extension-ios/Sources/RoktPaymentExtension/StripeAfterpayManager.swift b/Kits/rokt-payment-extension/rokt-payment-extension-ios/Sources/RoktPaymentExtension/StripeAfterpayManager.swift new file mode 100644 index 000000000..eac16d54f --- /dev/null +++ b/Kits/rokt-payment-extension/rokt-payment-extension-ios/Sources/RoktPaymentExtension/StripeAfterpayManager.swift @@ -0,0 +1,140 @@ +import Foundation +import RoktContracts +import StripePayments +import UIKit + +internal class StripeAfterpayManager { + + private let apiClient: STPAPIClient + private let returnURL: String + + internal init(apiClient: STPAPIClient, returnURL: String) { + self.apiClient = apiClient + self.returnURL = returnURL + } + + internal func presentPayment( + item: PaymentItem, + context: PaymentContext, + from viewController: UIViewController, + preparePayment: @escaping ( + _ address: ContactAddress, + _ completion: @escaping (PaymentPreparation?, Error?) -> Void + ) -> Void, + completion: @escaping (PaymentSheetResult) -> Void + ) { + guard !item.name.isEmpty else { + completion(.failed(error: "Payment item name cannot be empty")) + return + } + + guard !item.id.isEmpty else { + completion(.failed(error: "Payment item id cannot be empty")) + return + } + + guard item.amount.compare(NSDecimalNumber.zero) == .orderedDescending else { + completion(.failed(error: "Payment item amount must be greater than zero")) + return + } + + guard !item.currency.isEmpty else { + completion(.failed(error: "Payment item currency cannot be empty")) + return + } + + // Afterpay requires billing details. If the partner only supplies a + // shipping address, fall back to that so the payment can still be + // confirmed. + guard let billingAddress = context.billingAddress ?? context.shippingAddress else { + completion(.failed( + error: "Afterpay requires a billing or shipping address. Provide at least one in PaymentContext." + )) + return + } + + guard let billingName = BillingDetailsMapping.resolvedName( + billingAddress.name, + fallback: context.shippingAddress?.name + ) else { + completion(.failed( + error: "Afterpay requires a billing or shipping name. Provide a non-empty name in PaymentContext." + )) + return + } + + // Call preparePayment with the pre-collected address before showing any UI + preparePayment(billingAddress) { [weak self] preparation, error in + guard let self else { return } + + if let error, preparation == nil { + completion(.failed(error: error.localizedDescription)) + return + } + + guard let preparation else { + completion(.failed(error: "Payment preparation returned nil")) + return + } + + // STPPaymentHandler.shared() uses STPAPIClient.shared internally, + // so we must configure the shared client with the same publishable key + // and connected account. (Unlike STPApplePayContext which accepts a + // custom apiClient directly.) + STPAPIClient.shared.publishableKey = self.apiClient.publishableKey + STPAPIClient.shared.stripeAccount = preparation.merchantId + self.apiClient.stripeAccount = preparation.merchantId + + let params = STPPaymentIntentParams(clientSecret: preparation.clientSecret) + params.paymentMethodParams = STPPaymentMethodParams( + afterpayClearpay: STPPaymentMethodAfterpayClearpayParams(), + billingDetails: BillingDetailsMapping.map(from: billingAddress, fallbackName: billingName), + metadata: nil + ) + params.returnURL = self.returnURL + + if let shippingAddress = context.shippingAddress { + params.shipping = BillingDetailsMapping.mapShipping(from: shippingAddress, fallbackName: billingName) + } + + let authContext = SimpleAuthenticationContext(presentingController: viewController) + + DispatchQueue.main.async { + STPPaymentHandler.shared() + .confirmPaymentIntent(params: params, authenticationContext: authContext) { status, intent, error in + switch status { + case .succeeded: + completion(.succeeded(transactionId: StripePaymentDiagnostics.transactionId( + from: intent, + clientSecret: preparation.clientSecret + ))) + case .canceled: + completion(.canceled) + case .failed: + completion(.failed(error: StripePaymentDiagnostics.failureMessage( + baseMessage: error?.localizedDescription ?? "Afterpay payment failed", + paymentIntent: intent, + error: error + ))) + @unknown default: + completion(.failed(error: "Unknown payment status")) + } + } + } + } + } +} + +// MARK: - STPAuthenticationContext wrapper + +private class SimpleAuthenticationContext: NSObject, STPAuthenticationContext { + private let controller: UIViewController + + init(presentingController: UIViewController) { + self.controller = presentingController + } + + func authenticationPresentingViewController() -> UIViewController { + controller + } +} diff --git a/Kits/rokt-payment-extension/rokt-payment-extension-ios/Sources/RoktPaymentExtension/StripeApplePayManager.swift b/Kits/rokt-payment-extension/rokt-payment-extension-ios/Sources/RoktPaymentExtension/StripeApplePayManager.swift new file mode 100644 index 000000000..8585e7a45 --- /dev/null +++ b/Kits/rokt-payment-extension/rokt-payment-extension-ios/Sources/RoktPaymentExtension/StripeApplePayManager.swift @@ -0,0 +1,243 @@ +import Foundation +import PassKit +import RoktContracts +import StripeApplePay + +internal class StripeApplePayManager: NSObject { + + private let apiClient: STPAPIClient + private let merchantId: String + private let countryCode: String + + internal init( + apiClient: STPAPIClient, + merchantId: String, + countryCode: String = "US" + ) { + self.apiClient = apiClient + self.merchantId = merchantId + self.countryCode = countryCode + } + + internal func presentPayment( + item: PaymentItem, + from viewController: UIViewController, + preparePayment: @escaping ( + _ address: ContactAddress, + _ completion: @escaping (PaymentPreparation?, Error?) -> Void + ) -> Void, + completion: @escaping (PaymentSheetResult) -> Void + ) { + guard !item.name.isEmpty else { + completion(.failed(error: "Payment item name cannot be empty")) + return + } + + guard !item.id.isEmpty else { + completion(.failed(error: "Payment item id cannot be empty")) + return + } + + guard item.amount.compare(NSDecimalNumber.zero) == .orderedDescending else { + completion(.failed(error: "Payment item amount must be greater than zero")) + return + } + + guard !item.currency.isEmpty else { + completion(.failed(error: "Payment item currency cannot be empty")) + return + } + + guard PKPaymentAuthorizationController.canMakePayments() else { + completion(.failed(error: "Apple Pay is not available on this device")) + return + } + + let paymentRequest = makePaymentRequest(item: item) + + let delegate = StripeApplePayDelegate( + apiClient: apiClient, + item: item, + preparePayment: preparePayment, + completion: completion + ) + + guard let applePayContext = STPApplePayContext( + paymentRequest: paymentRequest, + delegate: delegate + ) else { + completion(.failed(error: "Failed to create Apple Pay context")) + return + } + + applePayContext.apiClient = apiClient + + // Retain delegate for the duration of the Apple Pay flow + objc_setAssociatedObject(applePayContext, "delegate", delegate, .OBJC_ASSOCIATION_RETAIN) + + applePayContext.presentApplePay(completion: nil) + } + + private func makePaymentRequest(item: PaymentItem) -> PKPaymentRequest { + let request = PKPaymentRequest() + request.merchantIdentifier = merchantId + request.countryCode = countryCode + request.currencyCode = item.currency.uppercased() + request.supportedNetworks = [.visa, .masterCard, .amex, .discover] + request.merchantCapabilities = [.capability3DS, .capabilityCredit, .capabilityDebit] + request.requiredShippingContactFields = [.postalAddress, .name, .phoneNumber, .emailAddress] + request.requiredBillingContactFields = [.postalAddress, .name] + request.paymentSummaryItems = [ + PKPaymentSummaryItem( + label: item.name, + amount: item.amount + ) + ] + return request + } + + /// Builds the line-itemized PassKit summary shown in the Apple Pay sheet. + /// + /// `total` must be taken from the server's `PaymentPreparation.totalAmount` + /// rather than computed as `subtotal + shipping + tax` on the client — the + /// server is the source of truth for the amount that will be charged against + /// the PaymentIntent, and any client-side arithmetic risks drifting from it + /// (rounding, promo codes, tax recalculation, etc.). Shipping and tax rows + /// are only appended when positive to keep the sheet clean for orders that + /// have neither. + static func makeSummaryItems( + itemName: String, + subtotal: NSDecimalNumber, + shippingCost: NSDecimalNumber, + tax: NSDecimalNumber, + total: NSDecimalNumber + ) -> [PKPaymentSummaryItem] { + var items: [PKPaymentSummaryItem] = [ + PKPaymentSummaryItem(label: itemName, amount: subtotal) + ] + if shippingCost.compare(NSDecimalNumber.zero) == .orderedDescending { + items.append(PKPaymentSummaryItem(label: "Shipping", amount: shippingCost)) + } + if tax.compare(NSDecimalNumber.zero) == .orderedDescending { + items.append(PKPaymentSummaryItem(label: "Tax", amount: tax)) + } + items.append(PKPaymentSummaryItem(label: "Total", amount: total)) + return items + } +} + +// MARK: - Private delegate + +private class StripeApplePayDelegate: NSObject, ApplePayContextDelegate { + + let apiClient: STPAPIClient + let item: PaymentItem + let preparePayment: ( + _ address: ContactAddress, + _ completion: @escaping (PaymentPreparation?, Error?) -> Void + ) -> Void + let completion: (PaymentSheetResult) -> Void + + private var clientSecret: String? + private var isPaymentPrepared = false + + init( + apiClient: STPAPIClient, + item: PaymentItem, + preparePayment: @escaping ( + _ address: ContactAddress, + _ completion: @escaping (PaymentPreparation?, Error?) -> Void + ) -> Void, + completion: @escaping (PaymentSheetResult) -> Void + ) { + self.apiClient = apiClient + self.item = item + self.preparePayment = preparePayment + self.completion = completion + } + + func applePayContext( + _ context: STPApplePayContext, + didSelectShippingContact contact: PKContact, + handler: @escaping (PKPaymentRequestShippingContactUpdate) -> Void + ) { + let address = ContactAddressMapping.map(from: contact) + + preparePayment(address) { [weak self] preparation, _ in + guard let self else { return } + + if let preparation { + self.isPaymentPrepared = true + self.clientSecret = preparation.clientSecret + self.apiClient.stripeAccount = preparation.merchantId + + // Total must come from the server preparation, not from `item.amount` — + // `item.amount` is the subtotal and does not include shipping or tax, so + // using it here makes the sheet's Total disagree with the Stripe charge. + let updatedItems = StripeApplePayManager.makeSummaryItems( + itemName: self.item.name, + subtotal: self.item.amount, + shippingCost: preparation.shippingCost, + tax: preparation.tax, + total: preparation.totalAmount + ) + + handler(PKPaymentRequestShippingContactUpdate( + errors: nil, + paymentSummaryItems: updatedItems, + shippingMethods: [] + )) + } else { + self.isPaymentPrepared = false + self.clientSecret = nil + + let applePayError = PKPaymentRequest.paymentShippingAddressUnserviceableError( + withLocalizedDescription: "Something went wrong. Please try again." + ) + handler(PKPaymentRequestShippingContactUpdate( + errors: [applePayError], + paymentSummaryItems: [], + shippingMethods: [] + )) + } + } + } + + func applePayContext( + _ context: STPApplePayContext, + didCreatePaymentMethod paymentMethod: StripeAPI.PaymentMethod, + paymentInformation: PKPayment + ) async throws -> String { + guard isPaymentPrepared, let secret = clientSecret else { + throw NSError( + domain: "RoktPaymentExtension", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Payment must be prepared before completion"] + ) + } + return secret + } + + func applePayContext( + _ context: STPApplePayContext, + didCompleteWith status: STPApplePayContext.PaymentStatus, + error: Error? + ) { + switch status { + case .success: + completion(.succeeded(transactionId: StripePaymentDiagnostics.paymentIntentId( + fromClientSecret: clientSecret + ) ?? "unknown")) + case .error: + completion(.failed(error: StripePaymentDiagnostics.failureMessage( + baseMessage: error?.localizedDescription ?? "Unknown error", + paymentIntentId: StripePaymentDiagnostics.paymentIntentId(fromClientSecret: clientSecret), + error: error + ))) + case .userCancellation: + completion(.canceled) + @unknown default: + completion(.failed(error: "Unknown payment status")) + } + } +} diff --git a/Kits/rokt-payment-extension/rokt-payment-extension-ios/Sources/RoktPaymentExtension/StripePaymentDiagnostics.swift b/Kits/rokt-payment-extension/rokt-payment-extension-ios/Sources/RoktPaymentExtension/StripePaymentDiagnostics.swift new file mode 100644 index 000000000..74f799f15 --- /dev/null +++ b/Kits/rokt-payment-extension/rokt-payment-extension-ios/Sources/RoktPaymentExtension/StripePaymentDiagnostics.swift @@ -0,0 +1,49 @@ +import Foundation +import StripePayments + +enum StripePaymentDiagnostics { + private static let stripeRequestIDKey = "com.stripe.lib:StripeRequestIDKey" + + static func paymentIntentId(fromClientSecret clientSecret: String?) -> String? { + guard let clientSecret else { return nil } + return STPPaymentIntentParams(clientSecret: clientSecret).stripeId + } + + static func failureMessage( + baseMessage: String, + paymentIntent: STPPaymentIntent?, + error: Error? + ) -> String { + return failureMessage( + baseMessage: baseMessage, + paymentIntentId: paymentIntent?.stripeId, + error: error + ) + } + + static func failureMessage( + baseMessage: String, + paymentIntentId: String?, + error: Error? = nil + ) -> String { + if let paymentIntentId, !paymentIntentId.isEmpty { + return "\(baseMessage) (Stripe paymentIntentId: \(paymentIntentId))" + } + if let requestId = requestId(from: error) { + return "\(baseMessage) (Stripe requestId: \(requestId))" + } + + return baseMessage + } + + static func transactionId(from paymentIntent: STPPaymentIntent?, clientSecret: String) -> String { + paymentIntent?.stripeId + ?? paymentIntentId(fromClientSecret: clientSecret) + ?? "unknown" + } + + private static func requestId(from error: Error?) -> String? { + let requestId = (error as NSError?)?.userInfo[stripeRequestIDKey] as? String + return requestId?.isEmpty == false ? requestId : nil + } +} diff --git a/Kits/rokt-payment-extension/rokt-payment-extension-ios/Tests/RoktPaymentExtensionTests/ContactAddressMappingTests.swift b/Kits/rokt-payment-extension/rokt-payment-extension-ios/Tests/RoktPaymentExtensionTests/ContactAddressMappingTests.swift new file mode 100644 index 000000000..177303c71 --- /dev/null +++ b/Kits/rokt-payment-extension/rokt-payment-extension-ios/Tests/RoktPaymentExtensionTests/ContactAddressMappingTests.swift @@ -0,0 +1,93 @@ +import Contacts +import PassKit +import XCTest +@testable import RoktPaymentExtension + +final class ContactAddressMappingTests: XCTestCase { + + func testMapWithFullContact() { + let contact = PKContact() + let nameComponents = PersonNameComponents() + var name = nameComponents + name.givenName = "Jane" + name.familyName = "Smith" + contact.name = name + contact.emailAddress = "jane@example.com" + + let postal = CNMutablePostalAddress() + postal.street = "42 Main St" + postal.city = "New York" + postal.state = "NY" + postal.postalCode = "10001" + postal.isoCountryCode = "US" + contact.postalAddress = postal + + let address = ContactAddressMapping.map(from: contact) + + XCTAssertEqual(address.name, "Jane Smith") + XCTAssertEqual(address.email, "jane@example.com") + XCTAssertEqual(address.addressLine1, "42 Main St") + XCTAssertEqual(address.city, "New York") + XCTAssertEqual(address.state, "NY") + XCTAssertEqual(address.postalCode, "10001") + XCTAssertEqual(address.country, "US") + } + + func testMapWithNilPostalAddress() { + let contact = PKContact() + var name = PersonNameComponents() + name.givenName = "John" + name.familyName = "Doe" + contact.name = name + contact.emailAddress = "john@example.com" + // postalAddress is nil by default + + let address = ContactAddressMapping.map(from: contact) + + XCTAssertEqual(address.name, "John Doe") + XCTAssertEqual(address.email, "john@example.com") + XCTAssertNil(address.addressLine1) + XCTAssertNil(address.city) + XCTAssertNil(address.state) + XCTAssertNil(address.postalCode) + XCTAssertNil(address.country) + } + + func testMapWithNilName() { + let contact = PKContact() + // name is nil by default + contact.emailAddress = "anon@example.com" + + let postal = CNMutablePostalAddress() + postal.city = "Sydney" + postal.isoCountryCode = "AU" + contact.postalAddress = postal + + let address = ContactAddressMapping.map(from: contact) + + XCTAssertEqual(address.name, "") + XCTAssertEqual(address.email, "anon@example.com") + XCTAssertEqual(address.city, "Sydney") + XCTAssertEqual(address.country, "AU") + } + + func testMapWithOnlyGivenName() { + let contact = PKContact() + var name = PersonNameComponents() + name.givenName = "Madonna" + contact.name = name + + let address = ContactAddressMapping.map(from: contact) + + XCTAssertEqual(address.name, "Madonna") + } + + func testMapWithNilEmail() { + let contact = PKContact() + // emailAddress is nil by default + + let address = ContactAddressMapping.map(from: contact) + + XCTAssertEqual(address.email, "") + } +} diff --git a/Kits/rokt-payment-extension/rokt-payment-extension-ios/Tests/RoktPaymentExtensionTests/RoktPaymentExtensionTests.swift b/Kits/rokt-payment-extension/rokt-payment-extension-ios/Tests/RoktPaymentExtensionTests/RoktPaymentExtensionTests.swift new file mode 100644 index 000000000..248c746e4 --- /dev/null +++ b/Kits/rokt-payment-extension/rokt-payment-extension-ios/Tests/RoktPaymentExtensionTests/RoktPaymentExtensionTests.swift @@ -0,0 +1,240 @@ +import XCTest +@testable import RoktPaymentExtension +import RoktContracts + +final class RoktPaymentExtensionTests: XCTestCase { + + // MARK: - init: at-least-one-configured guard + + func testInitWithNoParamsReturnsNil() { + XCTAssertNil(RoktPaymentExtension()) + } + + func testInitWithEmptyMerchantIdOnlyReturnsNil() { + XCTAssertNil(RoktPaymentExtension(applePayMerchantId: "")) + } + + func testInitWithEmptyUrlSchemeOnlyReturnsNil() { + XCTAssertNil(RoktPaymentExtension(urlScheme: "")) + } + + func testInitWithBothEmptyReturnsNil() { + XCTAssertNil(RoktPaymentExtension(applePayMerchantId: "", urlScheme: "")) + } + + // MARK: - Apple Pay only + + func testInitWithApplePayOnly() { + let ext = RoktPaymentExtension(applePayMerchantId: "merchant.test") + XCTAssertNotNil(ext) + XCTAssertEqual(ext?.supportedMethods, ["apple_pay", "card"]) + } + + func testInitWithApplePayAndCustomCountryCode() { + let ext = RoktPaymentExtension(applePayMerchantId: "merchant.test", countryCode: "AU") + XCTAssertNotNil(ext) + } + + // MARK: - Afterpay only + + func testInitWithAfterpayOnly() { + let ext = RoktPaymentExtension( + urlScheme: "myapp", + bundle: makeBundle(withSchemes: ["myapp"]) + ) + XCTAssertNotNil(ext) + XCTAssertEqual(ext?.supportedMethods, ["afterpay_clearpay"]) + } + + // MARK: - Both + + func testInitWithBothMethods() { + let ext = RoktPaymentExtension( + applePayMerchantId: "merchant.test", + urlScheme: "myapp", + bundle: makeBundle(withSchemes: ["myapp"]) + ) + XCTAssertNotNil(ext) + XCTAssertEqual(ext?.supportedMethods, ["apple_pay", "card", "afterpay_clearpay"]) + } + + // MARK: - Protocol properties + + func testProtocolProperties() { + let ext = RoktPaymentExtension( + applePayMerchantId: "merchant.test", + urlScheme: "myapp", + bundle: makeBundle(withSchemes: ["myapp"]) + )! + XCTAssertEqual(ext.id, "rokt-payment-extension") + XCTAssertEqual(ext.extensionDescription, "Rokt Payment Extension") + } + + // MARK: - onRegister / onUnregister + + func testOnRegisterWithoutStripeKeyReturnsFalse() { + let ext = RoktPaymentExtension(applePayMerchantId: "merchant.test")! + XCTAssertFalse(ext.onRegister(parameters: [:])) + } + + func testOnRegisterWithEmptyStripeKeyReturnsFalse() { + let ext = RoktPaymentExtension(applePayMerchantId: "merchant.test")! + XCTAssertFalse(ext.onRegister(parameters: ["stripeKey": ""])) + } + + func testOnRegisterWithValidKeyReturnsTrue() { + let ext = RoktPaymentExtension(applePayMerchantId: "merchant.test")! + XCTAssertTrue(ext.onRegister(parameters: ["stripeKey": "pk_test_123"])) + } + + func testOnUnregisterNilsManager() { + let ext = RoktPaymentExtension(applePayMerchantId: "merchant.test")! + XCTAssertTrue(ext.onRegister(parameters: ["stripeKey": "pk_test_123"])) + ext.onUnregister() + XCTAssertTrue(ext.onRegister(parameters: ["stripeKey": "pk_test_456"])) + } + + // MARK: - presentPaymentSheet error paths + + func testApplePayNotConfiguredRejectsTap() { + let ext = RoktPaymentExtension( + urlScheme: "myapp", + bundle: makeBundle(withSchemes: ["myapp"]) + )! + ext.onRegister(parameters: ["stripeKey": "pk_test_123"]) + + let item = PaymentItem(id: "item-1", name: "Widget", amount: 10.00, currency: "USD") + let expect = expectation(description: "completion") + + ext.presentPaymentSheet( + item: item, + method: .applePay, + context: PaymentContext(), + from: UIViewController(), + preparePayment: { _, done in + XCTFail("preparePayment should not be called when Apple Pay is not configured") + done(nil, nil) + }, + completion: { result in + XCTAssertEqual(result.outcome, .failed) + XCTAssertTrue(result.errorMessage?.contains("Apple Pay not configured") ?? false) + expect.fulfill() + } + ) + + waitForExpectations(timeout: 1) + } + + func testAfterpayNotConfiguredRejectsTap() { + let ext = RoktPaymentExtension(applePayMerchantId: "merchant.test")! + ext.onRegister(parameters: ["stripeKey": "pk_test_123"]) + + let item = PaymentItem(id: "item-1", name: "Widget", amount: 10.00, currency: "USD") + let context = PaymentContext( + billingAddress: ContactAddress(name: "Test", email: "test@example.com") + ) + let expect = expectation(description: "completion") + + ext.presentPaymentSheet( + item: item, + method: .afterpay, + context: context, + from: UIViewController(), + preparePayment: { _, done in + XCTFail("preparePayment should not be called when Afterpay is not configured") + done(nil, nil) + }, + completion: { result in + XCTAssertEqual(result.outcome, .failed) + XCTAssertTrue(result.errorMessage?.contains("Provide a urlScheme") ?? false) + expect.fulfill() + } + ) + + waitForExpectations(timeout: 1) + } + + func testPaypalIsRejectedAsUnsupported() { + let ext = RoktPaymentExtension(applePayMerchantId: "merchant.test")! + let item = PaymentItem(id: "item-1", name: "Widget", amount: 10.00, currency: "USD") + let expect = expectation(description: "completion") + + ext.presentPaymentSheet( + item: item, + method: .paypal, + context: PaymentContext(), + from: UIViewController(), + preparePayment: { _, done in + XCTFail("preparePayment should not be called for unsupported methods") + done(nil, nil) + }, + completion: { result in + XCTAssertEqual(result.outcome, .failed) + XCTAssertEqual(result.errorMessage, "Unsupported payment method: paypal") + expect.fulfill() + } + ) + + waitForExpectations(timeout: 1) + } + + // MARK: - Scheme validation helpers + + func testIsValidBareSchemeAcceptsBareScheme() { + XCTAssertTrue(RoktPaymentExtension.isValidBareScheme("myapp")) + XCTAssertTrue(RoktPaymentExtension.isValidBareScheme("com.partner.app")) + } + + func testIsValidBareSchemeRejectsEmbeddedURL() { + XCTAssertFalse(RoktPaymentExtension.isValidBareScheme("")) + XCTAssertFalse(RoktPaymentExtension.isValidBareScheme("myapp://stripe-redirect")) + XCTAssertFalse(RoktPaymentExtension.isValidBareScheme("myapp/something")) + } + + func testIsSchemeRegisteredMatchesCaseInsensitive() { + let b = makeBundle(withSchemes: ["MyApp"]) + XCTAssertTrue(RoktPaymentExtension.isSchemeRegistered("myapp", in: b)) + XCTAssertTrue(RoktPaymentExtension.isSchemeRegistered("MYAPP", in: b)) + } + + func testIsSchemeRegisteredReturnsFalseWhenMissing() { + XCTAssertFalse( + RoktPaymentExtension.isSchemeRegistered("myapp", in: makeBundle(withSchemes: ["other"])) + ) + XCTAssertFalse( + RoktPaymentExtension.isSchemeRegistered("myapp", in: makeBundleWithoutSchemes()) + ) + } + + // MARK: - handleURLCallback + + func testHandleURLCallbackApplePayOnlyAlwaysReturnsFalse() { + let ext = RoktPaymentExtension(applePayMerchantId: "merchant.test")! + XCTAssertFalse(ext.handleURLCallback(with: URL(string: "myapp://rokt-payment-return")!)) + XCTAssertFalse(ext.handleURLCallback(with: URL(string: "anything://anything")!)) + } + + func testHandleURLCallbackRejectsWrongScheme() { + let ext = RoktPaymentExtension( + urlScheme: "myapp", + bundle: makeBundle(withSchemes: ["myapp"]) + )! + let url = URL(string: "other://rokt-payment-return")! + XCTAssertFalse(ext.handleURLCallback(with: url)) + } + + func testHandleURLCallbackRejectsWrongHost() { + let ext = RoktPaymentExtension( + urlScheme: "myapp", + bundle: makeBundle(withSchemes: ["myapp"]) + )! + let url = URL(string: "myapp://stripe-redirect")! + XCTAssertFalse(ext.handleURLCallback(with: url)) + } + + func testHandleURLCallbackReturnsFalseForUnrelatedURL() { + let ext = RoktPaymentExtension(applePayMerchantId: "merchant.test")! + let url = URL(string: "myapp://unrelated-callback")! + XCTAssertFalse(ext.handleURLCallback(with: url)) + } +} diff --git a/Kits/rokt-payment-extension/rokt-payment-extension-ios/Tests/RoktPaymentExtensionTests/StripeAfterpayManagerTests.swift b/Kits/rokt-payment-extension/rokt-payment-extension-ios/Tests/RoktPaymentExtensionTests/StripeAfterpayManagerTests.swift new file mode 100644 index 000000000..f9bde4bbd --- /dev/null +++ b/Kits/rokt-payment-extension/rokt-payment-extension-ios/Tests/RoktPaymentExtensionTests/StripeAfterpayManagerTests.swift @@ -0,0 +1,271 @@ +import RoktContracts +import XCTest +@testable import RoktPaymentExtension + +/// Tests that exercise validation paths inside StripeAfterpayManager through the public facade. +final class StripeAfterpayManagerTests: XCTestCase { + + private var ext: RoktPaymentExtension! + + override func setUp() { + super.setUp() + ext = RoktPaymentExtension( + applePayMerchantId: "merchant.test", + urlScheme: "testapp", + bundle: makeBundle(withSchemes: ["testapp"]) + )! + ext.onRegister(parameters: ["stripeKey": "pk_test_dummy"]) + } + + private func makeContext( + billingAddress: ContactAddress? = nil, + shippingAddress: ContactAddress? = nil + ) -> PaymentContext { + PaymentContext( + billingAddress: billingAddress, + shippingAddress: shippingAddress, + returnURL: "testapp://stripe-redirect" + ) + } + + private func makeBillingAddress() -> ContactAddress { + makeAddress(name: "Jane Smith") + } + + private func makeAddress(name: String) -> ContactAddress { + ContactAddress( + name: name, + email: "jane@example.com", + addressLine1: "123 Main St", + addressLine2: "Apt 4B", + city: "New York", + state: "NY", + postalCode: "10001", + country: "US" + ) + } + + // MARK: - Validation: missing both addresses + + func testAfterpayWithoutAnyAddressFails() { + let item = PaymentItem(id: "item-1", name: "Widget", amount: 10.00, currency: "USD") + let context = makeContext() + let expect = expectation(description: "completion") + + ext.presentPaymentSheet( + item: item, + method: .afterpay, + context: context, + from: UIViewController(), + preparePayment: { _, _ in XCTFail("should not be called") }, + completion: { result in + XCTAssertEqual(result.outcome, .failed) + XCTAssertTrue(result.errorMessage?.contains("address") ?? false) + expect.fulfill() + } + ) + + waitForExpectations(timeout: 1) + } + + // MARK: - Billing address falls back to shipping when omitted + + func testAfterpayFallsBackToShippingAddressWhenBillingMissing() { + let item = PaymentItem(id: "item-1", name: "Widget", amount: 10.00, currency: "USD") + let shipping = makeBillingAddress() // reuse shape; name "Jane Smith" + let context = makeContext(billingAddress: nil, shippingAddress: shipping) + let expect = expectation(description: "preparePayment invoked with shipping") + + ext.presentPaymentSheet( + item: item, + method: .afterpay, + context: context, + from: UIViewController(), + preparePayment: { address, _ in + XCTAssertEqual(address.name, "Jane Smith", + "Should have received the shipping address as the billing fallback") + expect.fulfill() + // Don't invoke `done` — we just care that prepare fires with the right address. + }, + completion: { _ in } + ) + + waitForExpectations(timeout: 1) + } + + func testAfterpayWithEmptyAddressNameFailsBeforePreparation() { + let item = PaymentItem(id: "item-1", name: "Widget", amount: 10.00, currency: "USD") + let context = makeContext(billingAddress: makeAddress(name: " ")) + let expect = expectation(description: "completion") + + ext.presentPaymentSheet( + item: item, + method: .afterpay, + context: context, + from: UIViewController(), + preparePayment: { _, _ in XCTFail("should not be called") }, + completion: { result in + XCTAssertEqual(result.outcome, .failed) + XCTAssertTrue(result.errorMessage?.contains("name") ?? false) + expect.fulfill() + } + ) + + waitForExpectations(timeout: 1) + } + + func testShippingMappingFallsBackToBillingNameWhenShippingNameEmpty() { + let shipping = makeAddress(name: " ") + + let params = BillingDetailsMapping.mapShipping(from: shipping, fallbackName: "Jane Smith") + + XCTAssertEqual(params.name, "Jane Smith") + } + + func testShippingMappingPrefersTrimmedShippingName() { + let shipping = makeAddress(name: " Sam Buyer ") + + let params = BillingDetailsMapping.mapShipping(from: shipping, fallbackName: "Jane Smith") + + XCTAssertEqual(params.name, "Sam Buyer") + } + + func testBillingMappingIncludesAddressLine2() { + let billing = makeBillingAddress() + + let details = BillingDetailsMapping.map(from: billing) + + XCTAssertEqual(details.address?.line2, "Apt 4B") + } + + func testShippingMappingIncludesAddressLine2() { + let shipping = makeAddress(name: "Jane Smith") + + let params = BillingDetailsMapping.mapShipping(from: shipping) + + XCTAssertEqual(params.address.line2, "Apt 4B") + } + + // MARK: - Validation: empty name + + func testAfterpayWithEmptyNameFails() { + let item = PaymentItem(id: "item-1", name: "", amount: 10.00, currency: "USD") + let context = makeContext(billingAddress: makeBillingAddress()) + let expect = expectation(description: "completion") + + ext.presentPaymentSheet( + item: item, + method: .afterpay, + context: context, + from: UIViewController(), + preparePayment: { _, _ in XCTFail("should not be called") }, + completion: { result in + XCTAssertEqual(result.outcome, .failed) + XCTAssertNotNil(result.errorMessage) + expect.fulfill() + } + ) + + waitForExpectations(timeout: 1) + } + + // MARK: - Validation: empty id + + func testAfterpayWithEmptyIdFails() { + let item = PaymentItem(id: "", name: "Widget", amount: 10.00, currency: "USD") + let context = makeContext(billingAddress: makeBillingAddress()) + let expect = expectation(description: "completion") + + ext.presentPaymentSheet( + item: item, + method: .afterpay, + context: context, + from: UIViewController(), + preparePayment: { _, _ in XCTFail("should not be called") }, + completion: { result in + XCTAssertEqual(result.outcome, .failed) + XCTAssertNotNil(result.errorMessage) + expect.fulfill() + } + ) + + waitForExpectations(timeout: 1) + } + + // MARK: - Validation: zero amount + + func testAfterpayWithZeroAmountFails() { + let item = PaymentItem(id: "item-1", name: "Widget", amount: 0, currency: "USD") + let context = makeContext(billingAddress: makeBillingAddress()) + let expect = expectation(description: "completion") + + ext.presentPaymentSheet( + item: item, + method: .afterpay, + context: context, + from: UIViewController(), + preparePayment: { _, _ in XCTFail("should not be called") }, + completion: { result in + XCTAssertEqual(result.outcome, .failed) + XCTAssertNotNil(result.errorMessage) + expect.fulfill() + } + ) + + waitForExpectations(timeout: 1) + } + + // MARK: - Validation: empty currency + + func testAfterpayWithEmptyCurrencyFails() { + let item = PaymentItem(id: "item-1", name: "Widget", amount: 10.00, currency: "") + let context = makeContext(billingAddress: makeBillingAddress()) + let expect = expectation(description: "completion") + + ext.presentPaymentSheet( + item: item, + method: .afterpay, + context: context, + from: UIViewController(), + preparePayment: { _, _ in XCTFail("should not be called") }, + completion: { result in + XCTAssertEqual(result.outcome, .failed) + XCTAssertNotNil(result.errorMessage) + expect.fulfill() + } + ) + + waitForExpectations(timeout: 1) + } + + // MARK: - preparePayment failure + + func testAfterpayPreparePaymentFailureReturnsError() { + let item = PaymentItem(id: "item-1", name: "Widget", amount: 10.00, currency: "USD") + let context = makeContext(billingAddress: makeBillingAddress()) + let expect = expectation(description: "completion") + + struct PrepError: LocalizedError { + var errorDescription: String? { "Backend error" } + } + + ext.presentPaymentSheet( + item: item, + method: .afterpay, + context: context, + from: UIViewController(), + preparePayment: { address, done in + XCTAssertEqual(address.name, "Jane Smith") + XCTAssertEqual(address.email, "jane@example.com") + done(nil, PrepError()) + }, + completion: { result in + XCTAssertEqual(result.outcome, .failed) + XCTAssertEqual(result.errorMessage, "Backend error") + expect.fulfill() + } + ) + + waitForExpectations(timeout: 1) + } +} diff --git a/Kits/rokt-payment-extension/rokt-payment-extension-ios/Tests/RoktPaymentExtensionTests/StripeApplePayManagerTests.swift b/Kits/rokt-payment-extension/rokt-payment-extension-ios/Tests/RoktPaymentExtensionTests/StripeApplePayManagerTests.swift new file mode 100644 index 000000000..a337f2836 --- /dev/null +++ b/Kits/rokt-payment-extension/rokt-payment-extension-ios/Tests/RoktPaymentExtensionTests/StripeApplePayManagerTests.swift @@ -0,0 +1,181 @@ +import PassKit +import RoktContracts +import XCTest +@testable import RoktPaymentExtension + +/// Tests that exercise validation paths inside StripeApplePayManager through the public facade. +/// Direct unit tests of StripeApplePayManager internal validation use presentPaymentSheet, +/// which surfaces failures synchronously through the completion handler when item is invalid. +final class StripeApplePayManagerTests: XCTestCase { + + private var ext: RoktPaymentExtension! + + override func setUp() { + super.setUp() + ext = RoktPaymentExtension(applePayMerchantId: "merchant.test")! + ext.onRegister(parameters: ["stripeKey": "pk_test_dummy"]) + } + + // MARK: - Validation: empty name + + func testPresentPaymentSheetWithEmptyNameFails() { + let item = PaymentItem(id: "item-1", name: "", amount: 9.99, currency: "USD") + let expectation = expectation(description: "completion called") + + ext.presentPaymentSheet( + item: item, + method: .applePay, + context: PaymentContext(), + from: UIViewController(), + preparePayment: { _, _ in fatalError("should not be called") }, + completion: { result in + XCTAssertEqual(result.outcome, .failed) + XCTAssertNotNil(result.errorMessage) + expectation.fulfill() + } + ) + + waitForExpectations(timeout: 1) + } + + // MARK: - Validation: empty id + + func testPresentPaymentSheetWithEmptyIdFails() { + let item = PaymentItem(id: "", name: "Widget", amount: 9.99, currency: "USD") + let expectation = expectation(description: "completion called") + + ext.presentPaymentSheet( + item: item, + method: .applePay, + context: PaymentContext(), + from: UIViewController(), + preparePayment: { _, _ in fatalError("should not be called") }, + completion: { result in + XCTAssertEqual(result.outcome, .failed) + XCTAssertNotNil(result.errorMessage) + expectation.fulfill() + } + ) + + waitForExpectations(timeout: 1) + } + + // MARK: - Validation: zero amount + + func testPresentPaymentSheetWithZeroAmountFails() { + let item = PaymentItem(id: "item-1", name: "Widget", amount: 0, currency: "USD") + let expectation = expectation(description: "completion called") + + ext.presentPaymentSheet( + item: item, + method: .applePay, + context: PaymentContext(), + from: UIViewController(), + preparePayment: { _, _ in fatalError("should not be called") }, + completion: { result in + XCTAssertEqual(result.outcome, .failed) + XCTAssertNotNil(result.errorMessage) + expectation.fulfill() + } + ) + + waitForExpectations(timeout: 1) + } + + // MARK: - Validation: empty currency + + func testPresentPaymentSheetWithEmptyCurrencyFails() { + let item = PaymentItem(id: "item-1", name: "Widget", amount: 9.99, currency: "") + let expectation = expectation(description: "completion called") + + ext.presentPaymentSheet( + item: item, + method: .applePay, + context: PaymentContext(), + from: UIViewController(), + preparePayment: { _, _ in fatalError("should not be called") }, + completion: { result in + XCTAssertEqual(result.outcome, .failed) + XCTAssertNotNil(result.errorMessage) + expectation.fulfill() + } + ) + + waitForExpectations(timeout: 1) + } + + // MARK: - Summary items + + func testMakeSummaryItemsIncludesShippingAndTaxAndUsesTotal() { + let items = StripeApplePayManager.makeSummaryItems( + itemName: "Widget", + subtotal: NSDecimalNumber(string: "80.00"), + shippingCost: NSDecimalNumber(string: "5.00"), + tax: NSDecimalNumber(string: "3.53"), + total: NSDecimalNumber(string: "88.53") + ) + + XCTAssertEqual(items.count, 4) + XCTAssertEqual(items[0].label, "Widget") + XCTAssertEqual(items[0].amount, NSDecimalNumber(string: "80.00")) + XCTAssertEqual(items[1].label, "Shipping") + XCTAssertEqual(items[1].amount, NSDecimalNumber(string: "5.00")) + XCTAssertEqual(items[2].label, "Tax") + XCTAssertEqual(items[2].amount, NSDecimalNumber(string: "3.53")) + XCTAssertEqual(items[3].label, "Total") + XCTAssertEqual(items[3].amount, NSDecimalNumber(string: "88.53")) + } + + func testMakeSummaryItemsSkipsZeroShippingAndZeroTax() { + let items = StripeApplePayManager.makeSummaryItems( + itemName: "Widget", + subtotal: NSDecimalNumber(string: "80.00"), + shippingCost: .zero, + tax: .zero, + total: NSDecimalNumber(string: "80.00") + ) + + XCTAssertEqual(items.count, 2) + XCTAssertEqual(items[0].label, "Widget") + XCTAssertEqual(items[1].label, "Total") + XCTAssertEqual(items[1].amount, NSDecimalNumber(string: "80.00")) + } + + func testMakeSummaryItemsPreservesDecimalPrecisionForTotal() { + // Guards against ever switching to Double-based construction (83.53 → 83.5299999...). + let items = StripeApplePayManager.makeSummaryItems( + itemName: "Widget", + subtotal: NSDecimalNumber(string: "80"), + shippingCost: .zero, + tax: NSDecimalNumber(string: "3.53"), + total: NSDecimalNumber(string: "83.53") + ) + + let total = items.last! + XCTAssertEqual(total.label, "Total") + XCTAssertEqual(total.amount, NSDecimalNumber(string: "83.53")) + } + + // MARK: - Validation: no manager (not registered) + + func testPresentPaymentSheetWithoutRegistrationFails() { + let unregisteredExt = RoktPaymentExtension(applePayMerchantId: "merchant.test")! + let item = PaymentItem(id: "item-1", name: "Widget", amount: 9.99, currency: "USD") + let expectation = expectation(description: "completion called") + + unregisteredExt.presentPaymentSheet( + item: item, + method: .applePay, + context: PaymentContext(), + from: UIViewController(), + preparePayment: { _, _ in fatalError("should not be called") }, + completion: { result in + XCTAssertEqual(result.outcome, .failed) + XCTAssertNotNil(result.errorMessage) + expectation.fulfill() + } + ) + + waitForExpectations(timeout: 1) + } +} diff --git a/Kits/rokt-payment-extension/rokt-payment-extension-ios/Tests/RoktPaymentExtensionTests/StripePaymentDiagnosticsTests.swift b/Kits/rokt-payment-extension/rokt-payment-extension-ios/Tests/RoktPaymentExtensionTests/StripePaymentDiagnosticsTests.swift new file mode 100644 index 000000000..56ffc03b9 --- /dev/null +++ b/Kits/rokt-payment-extension/rokt-payment-extension-ios/Tests/RoktPaymentExtensionTests/StripePaymentDiagnosticsTests.swift @@ -0,0 +1,65 @@ +import XCTest +@testable import RoktPaymentExtension + +final class StripePaymentDiagnosticsTests: XCTestCase { + + func testPaymentIntentIdFromClientSecretUsesStripeParser() { + let paymentIntentId = StripePaymentDiagnostics.paymentIntentId( + fromClientSecret: "pi_test123_secret_sensitive" + ) + + XCTAssertEqual(paymentIntentId, "pi_test123") + } + + func testFailureMessageIncludesPaymentIntentId() { + let message = StripePaymentDiagnostics.failureMessage( + baseMessage: "Payment failed", + paymentIntentId: "pi_test123" + ) + + XCTAssertEqual( + message, + "Payment failed (Stripe paymentIntentId: pi_test123)" + ) + } + + func testFailureMessageFallsBackToRequestId() { + let error = NSError( + domain: "com.stripe.lib", + code: 50, + userInfo: ["com.stripe.lib:StripeRequestIDKey": "req_test123"] + ) + + let message = StripePaymentDiagnostics.failureMessage( + baseMessage: "Payment failed", + paymentIntentId: nil, + error: error + ) + + XCTAssertEqual( + message, + "Payment failed (Stripe requestId: req_test123)" + ) + } + + func testFailureMessageDoesNotIncludeClientSecret() { + let clientSecret = "pi_test123_secret_sensitive" + let message = StripePaymentDiagnostics.failureMessage( + baseMessage: "Payment failed", + paymentIntentId: StripePaymentDiagnostics.paymentIntentId(fromClientSecret: clientSecret) + ) + + XCTAssertTrue(message.contains("pi_test123")) + XCTAssertFalse(message.contains("_secret_")) + XCTAssertFalse(message.contains("sensitive")) + } + + func testFailureMessageReturnsBaseMessageWhenPaymentIntentUnavailable() { + let message = StripePaymentDiagnostics.failureMessage( + baseMessage: "Payment failed", + paymentIntentId: nil + ) + + XCTAssertEqual(message, "Payment failed") + } +} diff --git a/Kits/rokt-payment-extension/rokt-payment-extension-ios/Tests/RoktPaymentExtensionTests/TestHelpers.swift b/Kits/rokt-payment-extension/rokt-payment-extension-ios/Tests/RoktPaymentExtensionTests/TestHelpers.swift new file mode 100644 index 000000000..75c0432e3 --- /dev/null +++ b/Kits/rokt-payment-extension/rokt-payment-extension-ios/Tests/RoktPaymentExtensionTests/TestHelpers.swift @@ -0,0 +1,28 @@ +import Foundation + +/// `Bundle` subclass whose `infoDictionary` is fully controllable from tests. +/// Used to exercise `RoktPaymentExtension`'s `CFBundleURLSchemes` validation +/// without touching the real test bundle's Info.plist. +final class MockBundle: Bundle, @unchecked Sendable { + var mockInfo: [String: Any]? + override var infoDictionary: [String: Any]? { mockInfo } +} + +/// Builds a `MockBundle` whose `Info.plist` declares `CFBundleURLSchemes` +/// under a single `CFBundleURLTypes` entry. +func makeBundle(withSchemes schemes: [String]) -> Bundle { + let mock = MockBundle() + mock.mockInfo = [ + "CFBundleURLTypes": [ + ["CFBundleURLSchemes": schemes] + ] + ] + return mock +} + +/// Builds a `MockBundle` with no URL scheme entries in Info.plist. +func makeBundleWithoutSchemes() -> Bundle { + let mock = MockBundle() + mock.mockInfo = [:] + return mock +} diff --git a/Kits/rokt-sdk-plus/rokt-sdk-plus-ios/Package.swift b/Kits/rokt-sdk-plus/rokt-sdk-plus-ios/Package.swift index e0413b576..0cf66f560 100644 --- a/Kits/rokt-sdk-plus/rokt-sdk-plus-ios/Package.swift +++ b/Kits/rokt-sdk-plus/rokt-sdk-plus-ios/Package.swift @@ -8,6 +8,7 @@ let version = "9.2.1" let useLocalVersion = ProcessInfo.processInfo.environment["USE_LOCAL_VERSION"] != nil let mParticleRoktKitURL = "https://github.com/mparticle-integrations/mp-apple-integration-rokt.git" +let paymentExtensionURL = "https://github.com/ROKT/rokt-payment-extension-ios.git" let mParticleRoktDependency: Package.Dependency = { if useLocalVersion { @@ -19,10 +20,24 @@ let mParticleRoktDependency: Package.Dependency = { return .package(url: mParticleRoktKitURL, .upToNextMajor(from: Version(version)!)) }() +let paymentExtensionDependency: Package.Dependency = { + if useLocalVersion { + return .package(path: "../../rokt-payment-extension/rokt-payment-extension-ios") + } + if version.isEmpty { + return .package(url: paymentExtensionURL, branch: "main") + } + return .package(url: paymentExtensionURL, .upToNextMajor(from: Version(version)!)) +}() + let mParticleRoktProduct: Target.Dependency = useLocalVersion ? .product(name: "mParticle-Rokt", package: "rokt") : .product(name: "mParticle-Rokt", package: "mp-apple-integration-rokt") +let paymentExtensionProduct: Target.Dependency = useLocalVersion + ? .product(name: "RoktPaymentExtension", package: "rokt-payment-extension-ios") + : .product(name: "RoktPaymentExtension", package: "rokt-payment-extension-ios") + let package = Package( name: "RoktSDKPlus", platforms: [.iOS(.v15)], @@ -34,17 +49,14 @@ let package = Package( ], dependencies: [ mParticleRoktDependency, - .package( - url: "https://github.com/ROKT/rokt-payment-extension-ios.git", - .upToNextMajor(from: "2.0.2") - ) + paymentExtensionDependency ], targets: [ .target( name: "RoktSDKPlus", dependencies: [ mParticleRoktProduct, - .product(name: "RoktPaymentExtension", package: "rokt-payment-extension-ios") + paymentExtensionProduct ], path: "Sources/RoktSDKPlus" ) diff --git a/Kits/rokt-sdk-plus/rokt-sdk-plus-ios/README.md b/Kits/rokt-sdk-plus/rokt-sdk-plus-ios/README.md index 7312b2f4a..218fa2b78 100644 --- a/Kits/rokt-sdk-plus/rokt-sdk-plus-ios/README.md +++ b/Kits/rokt-sdk-plus/rokt-sdk-plus-ios/README.md @@ -17,7 +17,7 @@ Official documentation: [mParticle iOS SDK](https://docs.mparticle.com/developer ## Versioning -**RoktSDKPlus**, **mParticle-Rokt**, and **mParticle-Apple-SDK** share the same semver for each monorepo release. **RoktPaymentExtension** remains on its own 2.x line. +**RoktSDKPlus**, **mParticle-Rokt**, **mParticle-Apple-SDK**, and **RoktPaymentExtension** share the same semver for each monorepo release (see `Kits/rokt-payment-extension/rokt-payment-extension-ios` on the mirror). ## Swift Package Manager @@ -26,7 +26,7 @@ In `Package.swift` or Xcode → _Package Dependencies_: ```swift .package( url: "https://github.com/ROKT/rokt-sdk-plus-ios.git", - .upToNextMajor(from: "9.2.0") + .upToNextMajor(from: "9.2.1") ) ``` diff --git a/Kits/rokt-sdk-plus/rokt-sdk-plus-ios/RoktSDKPlus.podspec b/Kits/rokt-sdk-plus/rokt-sdk-plus-ios/RoktSDKPlus.podspec index e3b17fc19..0e414c094 100644 --- a/Kits/rokt-sdk-plus/rokt-sdk-plus-ios/RoktSDKPlus.podspec +++ b/Kits/rokt-sdk-plus/rokt-sdk-plus-ios/RoktSDKPlus.podspec @@ -15,5 +15,5 @@ Pod::Spec.new do |s| s.requires_arc = true s.source_files = 'Sources/RoktSDKPlus/**/*.swift' s.dependency 'mParticle-Rokt', s.version.to_s - s.dependency 'RoktPaymentExtension', '~> 2.0' + s.dependency 'RoktPaymentExtension', s.version.to_s end diff --git a/RELEASE.md b/RELEASE.md index f5b1e6a7e..58e831f09 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -27,12 +27,12 @@ This bumps versions across podspecs, `Package.swift`, constants files, and `CHAN Review and merge the PR. On merge, the **Release – Publish** workflow runs automatically: - Builds xcframeworks for every kit -- Mirrors each kit subtree to its own repo under `mparticle-integrations/` (and **RoktSDKPlus** to `ROKT/rokt-sdk-plus-ios`) +- Mirrors each kit subtree to its own repo under `mparticle-integrations/` (and **Rokt** packages to **ROKT**: `rokt-payment-extension-ios`, `rokt-sdk-plus-ios`) - Creates GitHub releases and tags (used by SPM consumers) - Publishes the core SDK and all kit podspecs to CocoaPods trunk > [!NOTE] -> The release GitHub App must be installed on **ROKT** with access to `rokt-sdk-plus-ios` for that mirror push to succeed (same app credentials as `mparticle-integrations` mirrors). +> **ROKT mirrors:** The release GitHub App must be installed on **ROKT** with access to `rokt-payment-extension-ios` and `rokt-sdk-plus-ios`. Rows with **`mirror_force_push_main`** use `git push --force` when the mirror `main` is not subtree-only (e.g. initial README commit). Prefer an empty mirror repo for first publish, or force-push once. > [!NOTE] > The Swift SDK podspec (`mParticle-Apple-SDK-Swift`) is not yet published automatically — push it manually before the core SDK if required: