diff --git a/.gitignore b/.gitignore index ed7805a1..e20f678a 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ apollo-ios-cli # Android / Gradle .gradle/ +.kotlin/ build/ captures/ .externalNativeBuild diff --git a/AGENTS.md b/AGENTS.md index 63a4b7e1..a43da5ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,3 +9,56 @@ protocol/ # cross-platform communication layer based on UCP e2e/ # cross-platform end-to-end tests .github/ # workflows, issue templates, CODEOWNERS ``` + +## React Native development with local native SDK changes + +Until the new native SDK libraries have stable released versions, assume React Native validation needs the local native SDK workflow. Use `--local` whenever running the React Native sample or native React Native tests that depend on the in-repo Swift/Kotlin SDKs. + +Use the React Native `--local` workflow when you need to test React Native against native SDK changes that exist in this repository but have not been released as a SemVer/CocoaPods/Maven version yet. + +This applies when changes are made under: + +- `platforms/swift/` — the iOS Swift SDK / CocoaPods sources +- `platforms/android/` — the Android SDK / Maven artifact sources + +It does **not** refer to the React Native wrapper platform folders: + +- `platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/` +- `platforms/react-native/modules/@shopify/checkout-kit-react-native/android/` + +### What `--local` does + +- For React Native iOS, `--local` wires CocoaPods to the in-repo `platforms/swift/` sources via a local path instead of a released pod version. +- For React Native Android, `--local` publishes/uses the in-repo `platforms/android/` SDK through Maven Local so Gradle resolves the local SDK artifact instead of a released Maven version. + +### When to use it + +Use `--local` whenever you are validating React Native behavior that depends on unreleased native SDK changes, for example: + +- a new Swift SDK API that the React Native iOS bridge calls +- a new Android SDK API that the React Native Android bridge calls +- generated protocol/model changes under the native SDKs that the React Native module consumes +- any change in `platforms/swift/` or `platforms/android/` that has not yet been released and consumed through normal dependency versions + +Re-run the relevant local workflow whenever `platforms/swift/` or `platforms/android/` changes, because the React Native sample/tests need to re-resolve those local native SDK sources/artifacts. + +```bash +# iOS sample using local platforms/swift sources +dev rn ios --local + +# Android sample using local platforms/android via Maven Local +dev rn android --local + +# React Native Android unit tests using local platforms/android via Maven Local +# `dev rn test android` publishes platforms/android/lib to ~/.m2 first, then runs the RN module tests. +dev rn test android +``` + +For ad-hoc Android Gradle test commands, publish the local Android SDK first and set `USE_LOCAL_SDK=1` so the React Native module resolves `com.shopify:checkout-kit:1.0.0` from Maven Local instead of the unreleased placeholder artifact: + +```bash +cd platforms/react-native +USE_LOCAL_SDK=1 ./scripts/publish_android_snapshot +cd sample/android +USE_LOCAL_SDK=1 ./gradlew :shopify_checkout-kit-react-native:testDebugUnitTest +``` diff --git a/dev.yml b/dev.yml index b9d9855e..ed88e5f0 100644 --- a/dev.yml +++ b/dev.yml @@ -281,6 +281,35 @@ commands: build: desc: Build the @shopify/checkout-kit-react-native module run: cd platforms/react-native && pnpm module build + test: + desc: Run React Native module tests (JS + iOS + Android) + long_desc: | + Runs unit tests across all three React Native targets: + - JS: Jest tests in `platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/` + - iOS: Swift Package tests at `platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/` + - Android: Gradle JVM tests for `:shopify_checkout-kit-react-native` (requires a local Maven publish of `:lib`) + run: | + set -e + cd platforms/react-native && pnpm test + cd modules/@shopify/checkout-kit-react-native/ios && swift test + cd ../../../../ + USE_LOCAL_SDK=1 ./scripts/publish_android_snapshot + cd sample/android && USE_LOCAL_SDK=1 ./gradlew :shopify_checkout-kit-react-native:test + subcommands: + js: + desc: Run JS unit tests via jest + run: cd platforms/react-native && pnpm test + ios: + desc: Run native iOS unit tests (Swift Package at modules/.../ios) + run: cd platforms/react-native/modules/@shopify/checkout-kit-react-native/ios && swift test + android: + desc: Run native Android unit tests for the RN module (publishes/uses local platforms/android SDK) + run: | + set -e + cd platforms/react-native + USE_LOCAL_SDK=1 ./scripts/publish_android_snapshot + cd sample/android + USE_LOCAL_SDK=1 ./gradlew :shopify_checkout-kit-react-native:test lint: desc: Run all React Native lint checks (Swift, module, sample) aliases: [style] diff --git a/platforms/react-native/README.md b/platforms/react-native/README.md index 11b4e0c5..6c54e7f9 100644 --- a/platforms/react-native/README.md +++ b/platforms/react-native/README.md @@ -615,10 +615,16 @@ shopify.present(checkoutUrl, { `onClose` and `onFail` are mutually exclusive — exactly one of them fires per `present(...)` call, after which both handles are released. -> Protocol-level callbacks (`start`, `complete`, `error` on the protocol -> client) are not part of this section and will land in a follow-up release -> alongside a `` component. Checkout completion is not -> currently surfaced through the per-call callbacks. +> [!IMPORTANT] +> `present(...)` supports one active checkout presentation at a time. Starting +> another presentation while a checkout sheet is already active is unsupported; +> callbacks and protocol handlers are scoped to the currently active +> presentation. For multiple inline checkout surfaces, use component-scoped APIs +> where available. + +> Protocol-level callbacks are configured separately from these SDK lifecycle +> callbacks. Checkout completion is not currently surfaced through the per-call +> SDK lifecycle callbacks. ## Identity & customer accounts diff --git a/platforms/react-native/__mocks__/react-native.ts b/platforms/react-native/__mocks__/react-native.ts index a0faa309..af433253 100644 --- a/platforms/react-native/__mocks__/react-native.ts +++ b/platforms/react-native/__mocks__/react-native.ts @@ -49,6 +49,7 @@ const exampleConfig = { colorScheme: 'automatic', logLevel: 'error', }; +const shopifyCheckoutKitEventEmitter = createMockEmitter(); const ShopifyCheckoutKit = { version: '0.7.0', @@ -56,6 +57,9 @@ const ShopifyCheckoutKit = { version: '0.7.0', dispatchEventTypes: ['close', 'fail', 'geolocationRequest'], })), + onDispatch: jest.fn((callback: (envelopeJson: string) => void) => + shopifyCheckoutKitEventEmitter.addListener('onDispatch', callback), + ), preload: jest.fn(), present: jest.fn(), dismiss: jest.fn(), @@ -76,7 +80,7 @@ module.exports = { PermissionsAndroid: { requestMultiple: jest.fn(async () => ({})), }, - NativeEventEmitter: jest.fn(() => createMockEmitter()), + NativeEventEmitter: jest.fn(() => shopifyCheckoutKitEventEmitter), requireNativeComponent, codegenNativeComponent, TurboModuleRegistry: { @@ -90,7 +94,7 @@ module.exports = { NativeModules: { ShopifyCheckoutKit: { ...ShopifyCheckoutKit, - eventEmitter: createMockEmitter(), + eventEmitter: shopifyCheckoutKitEventEmitter, }, }, StyleSheet, diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/RNShopifyCheckoutKit.podspec b/platforms/react-native/modules/@shopify/checkout-kit-react-native/RNShopifyCheckoutKit.podspec index dc5c1b7f..ff23ab6e 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/RNShopifyCheckoutKit.podspec +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/RNShopifyCheckoutKit.podspec @@ -14,6 +14,11 @@ Pod::Spec.new do |s| s.source = { :git => "https://github.com/Shopify/checkout-kit.git", :tag => "#{s.version}" } s.source_files = "ios/*.{h,m,mm,swift}" + # `ios/Package.swift` is the manifest for the nested SwiftPM test package + # (CasingTransform / ProtocolRelay unit tests). It imports `PackageDescription` + # which only exists in the SwiftPM toolchain, so it must not be compiled by + # CocoaPods/Xcode when the RN module is consumed from an iOS app. + s.exclude_files = "ios/Package.swift" s.dependency "React-Core" diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/build.gradle b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/build.gradle index cc2dc2a3..588c0666 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/build.gradle +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/build.gradle @@ -1,4 +1,6 @@ buildscript { + ext.kotlin_version = rootProject.ext.has("kotlinVersion") ? rootProject.ext.get("kotlinVersion") : "2.1.20" + repositories { google() mavenCentral() @@ -6,11 +8,15 @@ buildscript { dependencies { classpath "com.android.tools.build:gradle:8.11.0" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version" } } apply plugin: "com.android.library" apply plugin: "com.facebook.react" +apply plugin: "org.jetbrains.kotlin.android" +apply plugin: "org.jetbrains.kotlin.plugin.serialization" def getExtOrIntegerDefault(name) { return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties[name]).toInteger() @@ -73,8 +79,17 @@ android { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } + + kotlinOptions { + jvmTarget = "1.8" + } + + testOptions { + unitTests.includeAndroidResources = true + } } + repositories { mavenLocal() mavenCentral() @@ -97,6 +112,11 @@ dependencies { implementation(shopifySdkArtifact) implementation("com.fasterxml.jackson.core:jackson-databind:2.12.5") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") debugImplementation(shopifySdkArtifact) + + testImplementation "junit:junit:4.13.2" + testImplementation "org.assertj:assertj-core:3.27.7" + testImplementation "org.robolectric:robolectric:4.16.1" } diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/gradle.properties b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/gradle.properties index 08a3c77a..08703c8d 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/gradle.properties +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/gradle.properties @@ -3,3 +3,8 @@ targetSdkVersion=35 compileSdkVersion=36 ndkVersion=23.1.7779620 buildToolsVersion = "35.0.0" + +# Opt out of the React Native Gradle plugin's JdkConfiguratorUtils, which otherwise +# silently rewrites compileOptions to 17 and pins the Kotlin JVM toolchain to 17 for +# every com.android.library it sees. We mirror :lib's pinned JVM 1.8 contract instead. +react.internal.disableJavaVersionAlignment=true diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CasingTransform.kt b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CasingTransform.kt new file mode 100644 index 00000000..07a1a53c --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CasingTransform.kt @@ -0,0 +1,88 @@ +/* + * MIT License + * + * Copyright 2023-present, Shopify Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.shopify.reactnative.checkoutkit + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement + +/** + * Bridges typed snake_case payloads (per @SerialName annotations on the native models) + * with camelCase JSON expected by JavaScript consumers. + */ +internal object CasingTransform { + + internal val json: Json = Json { ignoreUnknownKeys = true } + + fun snakeToCamel(s: String): String { + if (s.isEmpty() || !s.contains('_')) return s + val builder = StringBuilder(s.length) + var upperNext = false + for (ch in s) { + if (ch == '_') { + upperNext = true + } else if (upperNext) { + builder.append(ch.uppercaseChar()) + upperNext = false + } else { + builder.append(ch) + } + } + return builder.toString() + } + + fun camelToSnake(s: String): String { + if (s.isEmpty()) return s + val uppercaseCount = s.count { it.isUpperCase() } + val builder = StringBuilder(s.length + uppercaseCount) + for (ch in s) { + if (ch.isUpperCase()) { + builder.append('_').append(ch.lowercaseChar()) + } else { + builder.append(ch) + } + } + return builder.toString() + } + + fun transformKeys(element: JsonElement, fn: (String) -> String): JsonElement = when (element) { + is JsonObject -> JsonObject(element.entries.associate { (key, value) -> fn(key) to transformKeys(value, fn) }) + is JsonArray -> JsonArray(element.map { transformKeys(it, fn) }) + else -> element + } + + inline fun encodeForJS(payload: T): String { + val element = json.encodeToJsonElement(payload) + val transformed = transformKeys(element, ::snakeToCamel) + return json.encodeToString(JsonElement.serializer(), transformed) + } + + inline fun decodeFromJS(json: String): T { + val element = Json.parseToJsonElement(json) + val transformed = transformKeys(element, ::camelToSnake) + return CasingTransform.json.decodeFromJsonElement(transformed) + } +} diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListener.java b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListener.java index 133de8e4..2282f611 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListener.java +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListener.java @@ -30,7 +30,6 @@ of this software and associated documentation files (the "Software"), to deal import androidx.annotation.Nullable; import com.shopify.checkoutkit.*; -import com.facebook.react.bridge.Callback; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import java.io.IOException; @@ -42,16 +41,19 @@ public class CustomCheckoutListener extends DefaultCheckoutListener { private final ObjectMapper mapper = new ObjectMapper(); - @Nullable - private Callback dispatchCallback; + private final DispatchHandle dispatch; // Geolocation-specific variables private String geolocationOrigin; private GeolocationPermissions.Callback geolocationCallback; - public CustomCheckoutListener(@Nullable Callback dispatch) { - this.dispatchCallback = dispatch; + public CustomCheckoutListener(@NonNull DispatchCallback dispatch) { + this(new DispatchHandle(dispatch)); + } + + public CustomCheckoutListener(@NonNull DispatchHandle dispatch) { + this.dispatch = dispatch; } // Public methods @@ -65,7 +67,7 @@ public void invokeGeolocationCallback(boolean allow) { } public void release() { - dispatchCallback = null; + dispatch.release(); geolocationCallback = null; geolocationOrigin = null; } @@ -86,20 +88,21 @@ public void release() { public void onGeolocationPermissionsShowPrompt(@NonNull String origin, @NonNull GeolocationPermissions.Callback callback) { - this.geolocationCallback = callback; - this.geolocationOrigin = origin; - - if (dispatchCallback == null) { + if (dispatch.isReleased()) { // Multi-shot geolocation requests can in principle arrive after a - // terminal event has nulled the dispatcher. Log so the silence is - // observable rather than mystifying. - Log.w(TAG, "Dropping geolocationRequest \u2014 dispatcher already released by a terminal event."); + // terminal event or explicit dismiss has released the dispatcher. Log + // so the silence is observable rather than mystifying. + Log.w(TAG, "Dropping geolocationRequest — dispatcher already released."); return; } + + this.geolocationCallback = callback; + this.geolocationOrigin = origin; + try { Map payload = new HashMap<>(); payload.put("origin", origin); - dispatchCallback.invoke(buildEnvelope(DispatchEventTypes.GEOLOCATION_REQUEST, payload)); + dispatch.invoke(buildEnvelope(DispatchEventTypes.GEOLOCATION_REQUEST, payload)); } catch (IOException e) { Log.e(TAG, "Error emitting \"geolocationRequest\" event", e); } @@ -115,9 +118,7 @@ public void onGeolocationPermissionsHidePrompt() { @Override public void onCheckoutFailed(CheckoutException checkoutError) { - Callback dispatch = dispatchCallback; - if (dispatch == null) { - release(); + if (dispatch.isReleased()) { return; } try { @@ -131,9 +132,7 @@ public void onCheckoutFailed(CheckoutException checkoutError) { @Override public void onCheckoutCanceled() { - Callback dispatch = dispatchCallback; - if (dispatch == null) { - release(); + if (dispatch.isReleased()) { return; } try { diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchEnvelope.kt b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchEnvelope.kt new file mode 100644 index 00000000..45c85910 --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchEnvelope.kt @@ -0,0 +1,31 @@ +/* + * MIT License + * + * Copyright 2023-present, Shopify Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.shopify.reactnative.checkoutkit + +import kotlinx.serialization.Serializable + +@Serializable +internal data class DispatchEnvelope

( + val type: String, + val payload: P, +) diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchHandle.java b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchHandle.java new file mode 100644 index 00000000..b74b46c9 --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchHandle.java @@ -0,0 +1,57 @@ +/* +MIT License + +Copyright 2023 - Present, Shopify Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ + +package com.shopify.reactnative.checkoutkit; + +import androidx.annotation.NonNull; + +/** + * Shared per-presentation dispatch handle. + * + * SDK lifecycle events and protocol events both invoke the same handle. Terminal + * lifecycle events release it so subsequent protocol emissions are dropped, + * matching the iOS pendingDispatchCallback lifecycle. + */ +public class DispatchHandle implements DispatchCallback { + private final DispatchCallback downstream; + private boolean released = false; + + public DispatchHandle(@NonNull DispatchCallback downstream) { + this.downstream = downstream; + } + + @Override + public synchronized void invoke(String json) { + if (!released) { + downstream.invoke(json); + } + } + + public synchronized void release() { + released = true; + } + + public synchronized boolean isReleased() { + return released; + } +} diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ProtocolRelay.kt b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ProtocolRelay.kt new file mode 100644 index 00000000..7562bf1e --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ProtocolRelay.kt @@ -0,0 +1,63 @@ +/* + * MIT License + * + * Copyright 2023-present, Shopify Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.shopify.reactnative.checkoutkit + +import com.shopify.checkoutkit.CheckoutProtocol + +fun interface DispatchCallback { + fun invoke(json: String) +} + +object ProtocolRelay { + + @JvmStatic + fun makeClient( + subscribedMethods: List, + dispatch: DispatchCallback, + ): CheckoutProtocol.Client { + var client = CheckoutProtocol.Client() + for (method in subscribedMethods) { + when (method) { + CheckoutProtocol.start.method -> { + client = client.on(CheckoutProtocol.start) { checkout -> + forwardEnvelope(method, checkout, dispatch) + } + } + } + } + return client + } + + private inline fun forwardEnvelope( + type: String, + payload: P, + dispatch: DispatchCallback, + ) { + try { + val json = CasingTransform.encodeForJS(DispatchEnvelope(type, payload)) + dispatch.invoke(json) + } catch (e: Exception) { + // dispatch failures are swallowed — there is no native consumer for them + } + } +} diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java index 120eec1c..a7e94ae4 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java @@ -25,8 +25,6 @@ of this software and associated documentation files (the "Software"), to deal import android.app.Activity; import androidx.activity.ComponentActivity; -import androidx.annotation.Nullable; -import com.facebook.react.bridge.Callback; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReactMethod; import com.facebook.react.bridge.Arguments; @@ -36,7 +34,9 @@ of this software and associated documentation files (the "Software"), to deal import com.shopify.checkoutkit.NativeShopifyCheckoutKitSpec; import com.shopify.checkoutkit.*; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Objects; @@ -44,8 +44,6 @@ public class ShopifyCheckoutKitModule extends NativeShopifyCheckoutKitSpec { public static Configuration checkoutConfig = new Configuration(); - private final ReactApplicationContext reactContext; - private CheckoutKitDialog checkoutSheet; private CustomCheckoutListener checkoutListener; @@ -53,8 +51,6 @@ public class ShopifyCheckoutKitModule extends NativeShopifyCheckoutKitSpec { public ShopifyCheckoutKitModule(ReactApplicationContext reactContext) { super(reactContext); - this.reactContext = reactContext; - ShopifyCheckoutKit.configure(configuration -> { configuration.setPlatform(new Platform.ReactNative()); checkoutConfig = configuration; @@ -82,16 +78,30 @@ public void removeListeners(double count) { } @ReactMethod - public void present(String checkoutURL, @Nullable Callback dispatch) { + public void present(String checkoutURL, ReadableArray subscribedMethods) { releaseCheckoutListener(); Activity currentActivity = getCurrentActivity(); if (currentActivity instanceof ComponentActivity) { + DispatchHandle dispatch = new DispatchHandle(json -> emitOnDispatch(json)); CustomCheckoutListener listener = new CustomCheckoutListener(dispatch); checkoutListener = listener; + + List methods = new ArrayList<>(); + for (int i = 0; i < subscribedMethods.size(); i++) { + String method = subscribedMethods.getString(i); + if (method != null) { + methods.add(method); + } + } + CheckoutProtocol.Client client = ProtocolRelay.makeClient(methods, dispatch); + currentActivity.runOnUiThread(() -> { + if (checkoutListener != listener) { + return; + } checkoutSheet = ShopifyCheckoutKit.present(checkoutURL, (ComponentActivity) currentActivity, - listener); + listener, client); }); } } diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/CasingTransformTest.kt b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/CasingTransformTest.kt new file mode 100644 index 00000000..e926be4f --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/CasingTransformTest.kt @@ -0,0 +1,328 @@ +/* + * MIT License + * + * Copyright 2023-present, Shopify Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.shopify.reactnative.checkoutkit + +import com.shopify.checkoutkit.Checkout +import com.shopify.checkoutkit.CheckoutLineItem +import com.shopify.checkoutkit.CheckoutStatus +import com.shopify.checkoutkit.ItemClass +import com.shopify.checkoutkit.UCPCheckoutResponseSchema +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class CasingTransformTest { + + // region snakeToCamel + + @Test + fun `snakeToCamel converts continue_url to lower camel`() { + assertThat(CasingTransform.snakeToCamel("continue_url")).isEqualTo("continueUrl") + } + + @Test + fun `snakeToCamel converts line_items to lower camel`() { + assertThat(CasingTransform.snakeToCamel("line_items")).isEqualTo("lineItems") + } + + @Test + fun `snakeToCamel leaves single-word keys unchanged`() { + assertThat(CasingTransform.snakeToCamel("foo")).isEqualTo("foo") + } + + @Test + fun `snakeToCamel returns empty string unchanged`() { + assertThat(CasingTransform.snakeToCamel("")).isEqualTo("") + } + + @Test + fun `snakeToCamel converts oauth_2_0_access_token`() { + assertThat(CasingTransform.snakeToCamel("oauth_2_0_access_token")).isEqualTo("oauth20AccessToken") + } + + @Test + fun `snakeToCamel converts http_request_finish`() { + assertThat(CasingTransform.snakeToCamel("http_request_finish")).isEqualTo("httpRequestFinish") + } + + @Test + fun `snakeToCamel converts accelerated_checkouts_apple_pay_configuration`() { + assertThat(CasingTransform.snakeToCamel("accelerated_checkouts_apple_pay_configuration")) + .isEqualTo("acceleratedCheckoutsApplePayConfiguration") + } + + @Test + fun `snakeToCamel converts iso_8601_timestamp`() { + assertThat(CasingTransform.snakeToCamel("iso_8601_timestamp")).isEqualTo("iso8601Timestamp") + } + + @Test + fun `snakeToCamel converts x_forwarded_for_header`() { + assertThat(CasingTransform.snakeToCamel("x_forwarded_for_header")).isEqualTo("xForwardedForHeader") + } + + @Test + fun `snakeToCamel passes through already-camel input unchanged`() { + assertThat(CasingTransform.snakeToCamel("alreadyCamel")).isEqualTo("alreadyCamel") + } + + @Test + fun `snakeToCamel preserves embedded digits as non-letter characters`() { + assertThat(CasingTransform.snakeToCamel("field_v2")).isEqualTo("fieldV2") + } + + // endregion + + // region camelToSnake + + @Test + fun `camelToSnake converts continueUrl to snake`() { + assertThat(CasingTransform.camelToSnake("continueUrl")).isEqualTo("continue_url") + } + + @Test + fun `camelToSnake converts lineItems to snake`() { + assertThat(CasingTransform.camelToSnake("lineItems")).isEqualTo("line_items") + } + + @Test + fun `camelToSnake leaves single-word keys unchanged`() { + assertThat(CasingTransform.camelToSnake("foo")).isEqualTo("foo") + } + + @Test + fun `camelToSnake converts acceleratedCheckoutsApplePayConfiguration`() { + assertThat(CasingTransform.camelToSnake("acceleratedCheckoutsApplePayConfiguration")) + .isEqualTo("accelerated_checkouts_apple_pay_configuration") + } + + @Test + fun `camelToSnake converts httpRequestFinish`() { + assertThat(CasingTransform.camelToSnake("httpRequestFinish")).isEqualTo("http_request_finish") + } + + @Test + fun `camelToSnake splits each uppercase letter in consecutive-uppercase runs`() { + assertThat(CasingTransform.camelToSnake("imageURL")).isEqualTo("image_u_r_l") + } + + // endregion + + // region round-trip + + @Test + fun `snakeToCamel round-trips typical wire keys through camelToSnake`() { + val keys = listOf( + "continue_url", + "line_items", + "http_request_finish", + "x_forwarded_for_header", + "accelerated_checkouts_apple_pay_configuration", + ) + keys.forEach { key -> + val camel = CasingTransform.snakeToCamel(key) + assertThat(CasingTransform.camelToSnake(camel)).isEqualTo(key) + } + } + + // endregion + + // region transformKeys + + @Test + fun `transformKeys recursively transforms keys in nested objects and arrays`() { + val input = buildJsonObject { + put("outer_key", JsonPrimitive("v")) + put( + "nested_object", + buildJsonObject { + put("inner_key", JsonPrimitive(1)) + } + ) + put( + "list_of_objects", + buildJsonArray { + add( + buildJsonObject { + put("array_item_key", JsonPrimitive("a")) + } + ) + add( + buildJsonObject { + put("array_item_key", JsonPrimitive("b")) + } + ) + } + ) + } + + val transformed = CasingTransform.transformKeys(input, CasingTransform::snakeToCamel) as JsonObject + + assertThat(transformed.keys).containsExactlyInAnyOrder("outerKey", "nestedObject", "listOfObjects") + val nested = transformed["nestedObject"] as JsonObject + assertThat(nested.keys).containsExactly("innerKey") + val list = transformed["listOfObjects"] as JsonArray + assertThat(list).hasSize(2) + list.forEach { element -> + assertThat((element as JsonObject).keys).containsExactly("arrayItemKey") + } + } + + @Test + fun `transformKeys passes through JsonPrimitive unchanged`() { + val primitive = JsonPrimitive("hello_there") + val result = CasingTransform.transformKeys(primitive, CasingTransform::snakeToCamel) + assertThat(result).isSameAs(primitive) + assertThat((result as JsonPrimitive).content).isEqualTo("hello_there") + } + + @Test + fun `transformKeys passes through JsonNull unchanged`() { + val result = CasingTransform.transformKeys(JsonNull, CasingTransform::snakeToCamel) + assertThat(result).isEqualTo(JsonNull) + } + + // endregion + + // region encodeForJS round-trip + + @Test + fun `encodeForJS produces camelCase keys for a Checkout payload`() { + val checkout = sampleCheckout() + + val jsonString = CasingTransform.encodeForJS(checkout) + val parsed = Json.parseToJsonElement(jsonString).jsonObject + + assertThat(parsed.keys).contains("continueUrl", "lineItems", "expiresAt") + assertThat(parsed.keys).doesNotContain("continue_url", "line_items", "expires_at") + + val ucp = parsed["ucp"]!!.jsonObject + assertThat(ucp.keys).contains("paymentHandlers") + assertThat(ucp.keys).doesNotContain("payment_handlers") + } + + @Test + fun `encodeForJS transforms keys inside list elements`() { + val checkout = sampleCheckout( + lineItems = listOf( + CheckoutLineItem( + id = "li1", + item = ItemClass( + id = "i1", + title = "Widget", + price = 100, + imageURL = "https://example.com/img.png", + ), + quantity = 1, + totals = emptyList(), + ) + ) + ) + + val jsonString = CasingTransform.encodeForJS(checkout) + val parsed = Json.parseToJsonElement(jsonString).jsonObject + val lineItem = parsed["lineItems"]!!.jsonArray[0].jsonObject + val item = lineItem["item"]!!.jsonObject + + assertThat(item.keys).contains("imageUrl") + assertThat(item.keys).doesNotContain("image_url") + } + + // endregion + + // region decodeFromJS reverse round-trip + + @Test + fun `decodeFromJS decodes camelCase JSON back into a Checkout`() { + val camelJson = """ + { + "id":"chk1", + "currency":"USD", + "status":"incomplete", + "continueUrl":"https://example.com/continue", + "expiresAt":"2026-12-31T23:59:59Z", + "lineItems":[ + {"id":"li1","item":{"id":"i1","title":"Widget","price":100,"imageUrl":"https://example.com/img.png"},"quantity":1,"totals":[]} + ], + "links":[], + "totals":[], + "ucp":{"paymentHandlers":{},"version":"1.0"} + } + """.trimIndent() + + val checkout = CasingTransform.decodeFromJS(camelJson) + + assertThat(checkout.id).isEqualTo("chk1") + assertThat(checkout.currency).isEqualTo("USD") + assertThat(checkout.continueURL).isEqualTo("https://example.com/continue") + assertThat(checkout.expiresAt).isEqualTo("2026-12-31T23:59:59Z") + assertThat(checkout.lineItems).hasSize(1) + assertThat(checkout.lineItems[0].item.imageURL).isEqualTo("https://example.com/img.png") + assertThat(checkout.ucp.paymentHandlers).isEmpty() + } + + @Test + fun `encode then decode round-trips Checkout instance`() { + val original = sampleCheckout() + val encoded = CasingTransform.encodeForJS(original) + val decoded = CasingTransform.decodeFromJS(encoded) + + assertThat(decoded.id).isEqualTo(original.id) + assertThat(decoded.currency).isEqualTo(original.currency) + assertThat(decoded.continueURL).isEqualTo(original.continueURL) + assertThat(decoded.expiresAt).isEqualTo(original.expiresAt) + } + + // endregion + + // region helpers + + private fun sampleCheckout( + id: String = "chk1", + currency: String = "USD", + lineItems: List = emptyList(), + ): Checkout = Checkout( + id = id, + currency = currency, + status = CheckoutStatus.Incomplete, + continueURL = "https://example.com/continue", + expiresAt = "2026-12-31T23:59:59Z", + lineItems = lineItems, + links = emptyList(), + totals = emptyList(), + ucp = UCPCheckoutResponseSchema( + paymentHandlers = emptyMap(), + version = "1.0", + ), + ) + + // endregion +} diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/ProtocolRelayTest.kt b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/ProtocolRelayTest.kt new file mode 100644 index 00000000..c31e83b2 --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/ProtocolRelayTest.kt @@ -0,0 +1,158 @@ +/* + * MIT License + * + * Copyright 2023-present, Shopify Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.shopify.reactnative.checkoutkit + +import android.os.Looper +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf + +@RunWith(RobolectricTestRunner::class) +class ProtocolRelayTest { + + @Test + fun `envelope encodes type and camelCase payload`() { + val payload = SnakePayload(continueUrl = "https://example.com", lineItems = emptyList()) + val envelope = DispatchEnvelope(type = "ec.start", payload = payload) + + val json = CasingTransform.encodeForJS(envelope) + + val parsed = Json.parseToJsonElement(json).jsonObject + assertThat(parsed["type"]?.jsonPrimitive?.content).isEqualTo("ec.start") + + val payloadObj = parsed["payload"]!!.jsonObject + assertThat(payloadObj["continueUrl"]?.jsonPrimitive?.content).isEqualTo("https://example.com") + assertThat(payloadObj).containsKey("lineItems") + assertThat(payloadObj).doesNotContainKey("continue_url") + assertThat(payloadObj).doesNotContainKey("line_items") + } + + @Test + fun `relay dispatches envelope on ec start`() { + var captured: String? = null + val client = ProtocolRelay.makeClient( + listOf("ec.start"), + DispatchCallback { json -> captured = json }, + ) + + client.process(ecStartNotificationFixture) + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + + val json = captured + assertThat(json).isNotNull() + val parsed = Json.parseToJsonElement(json!!).jsonObject + assertThat(parsed["type"]?.jsonPrimitive?.content).isEqualTo("ec.start") + + val payload = parsed["payload"]!!.jsonObject + assertThat(payload["id"]?.jsonPrimitive?.content).isEqualTo("checkout-123") + assertThat(payload["currency"]?.jsonPrimitive?.content).isEqualTo("USD") + + val lineItems = payload["lineItems"]!!.jsonArray + assertThat(lineItems).hasSize(1) + val firstItem = lineItems[0].jsonObject["item"]!!.jsonObject + assertThat(firstItem["imageUrl"]?.jsonPrimitive?.content).isEqualTo("https://example.com/image.png") + } + + @Test + fun `relay ignores methods not in subscribed list`() { + var captured: String? = null + val client = ProtocolRelay.makeClient( + emptyList(), + DispatchCallback { json -> captured = json }, + ) + + client.process(ecStartNotificationFixture) + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + + assertThat(captured).isNull() + } + + @Test + fun `relay drops protocol envelopes after dispatch handle release`() { + var captured: String? = null + val dispatch = DispatchHandle(DispatchCallback { json -> captured = json }) + val client = ProtocolRelay.makeClient( + listOf("ec.start"), + dispatch, + ) + + dispatch.release() + client.process(ecStartNotificationFixture) + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + + assertThat(captured).isNull() + } +} + +@Serializable +private data class SnakePayload( + @SerialName("continue_url") val continueUrl: String, + @SerialName("line_items") val lineItems: List, +) + +private val ecStartNotificationFixture = """ +{ + "jsonrpc": "2.0", + "method": "ec.start", + "params": { + "checkout": { + "ucp": { + "version": "2026-04-08", + "payment_handlers": {} + }, + "id": "checkout-123", + "status": "incomplete", + "currency": "USD", + "line_items": [ + { + "id": "li-1", + "quantity": 1, + "item": { + "id": "product-1", + "title": "Test Product", + "price": 2999, + "image_url": "https://example.com/image.png" + }, + "totals": [ + {"type": "subtotal", "amount": 2999} + ] + } + ], + "totals": [ + {"type": "total", "amount": 2999} + ], + "links": [ + {"type": "privacy_policy", "url": "https://example.com/privacy"} + ] + } + } +} +""".trimIndent() diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/api/checkout-kit-react-native.api.md b/platforms/react-native/modules/@shopify/checkout-kit-react-native/api/checkout-kit-react-native.api.md index bb39293b..bf0732cb 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/api/checkout-kit-react-native.api.md +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/api/checkout-kit-react-native.api.md @@ -4,7 +4,9 @@ ```ts +import { Checkout } from '@shopify/checkout-kit-protocol'; import type { PropsWithChildren } from 'react'; +import type { ProtocolHandlers as ProtocolHandlers_2 } from './protocol'; import { default as React_2 } from 'react'; // @public (undocumented) @@ -46,6 +48,21 @@ export enum AcceleratedCheckoutWallet { shopPay = 'shopPay', } +// @public (undocumented) +export interface AndroidAutomaticColors { + dark: AndroidColors; + light: AndroidColors; +} + +// @public (undocumented) +export interface AndroidColors { + backgroundColor: string; + closeButtonColor?: string; + headerBackgroundColor: string; + headerTextColor: string; + progressIndicator: string; +} + // @public (undocumented) export enum ApplePayContactField { // (undocumented) @@ -104,6 +121,8 @@ export enum ApplePayStyle { whiteOutline = "whiteOutline" } +export { Checkout } + // Warning: (ae-forgotten-export) The symbol "GenericErrorWithCode" needs to be exported by the entry point index.d.ts // // @public (undocumented) @@ -166,6 +185,17 @@ export enum CheckoutNativeErrorType { UnknownError = 'UnknownError', } +// @public (undocumented) +export const CheckoutProtocol: { + readonly start: "ec.start"; +}; + +// @public (undocumented) +export interface CheckoutProtocolPayloads { + // (undocumented) + 'ec.start': Checkout; +} + // @public (undocumented) export enum ColorScheme { // (undocumented) @@ -250,6 +280,13 @@ export class InternalError { message: string; } +// @public (undocumented) +export interface IosColors { + backgroundColor?: string; + closeButtonColor?: string; + tintColor?: string; +} + // @public (undocumented) export class LifecycleEventParseError extends Error { constructor(message?: string, options?: ErrorOptions); @@ -268,6 +305,11 @@ export interface PresentCallbacks { onGeolocationRequest?: (event: GeolocationRequestEvent) => void; } +// @public (undocumented) +export type ProtocolHandlers = Partial<{ + [K in keyof CheckoutProtocolPayloads]: (payload: CheckoutProtocolPayloads[K]) => void; +}>; + // @public (undocumented) export enum RenderState { // (undocumented) @@ -297,7 +339,7 @@ export class ShopifyCheckout implements ShopifyCheckoutKit { dismiss(): void; getConfig(): Configuration; isAcceleratedCheckoutAvailable(): boolean; - present(checkoutUrl: string, callbacks?: PresentCallbacks): void; + present(checkoutUrl: string, callbacks?: PresentCallbacks, protocol?: ProtocolHandlers): void; setConfig(configuration: Configuration): void; teardown(): void; // (undocumented) @@ -314,12 +356,6 @@ export function ShopifyCheckoutProvider(input: PropsWithChildren): React_ // @public (undocumented) export function useShopifyCheckout(): Context; -// Warnings were encountered during analysis: -// -// lib/typescript/src/_types/index.d.ts:148:11 - (ae-forgotten-export) The symbol "IosColors" needs to be exported by the entry point index.d.ts -// lib/typescript/src/_types/index.d.ts:149:11 - (ae-forgotten-export) The symbol "AndroidColors" needs to be exported by the entry point index.d.ts -// lib/typescript/src/_types/index.d.ts:162:11 - (ae-forgotten-export) The symbol "AndroidAutomaticColors" needs to be exported by the entry point index.d.ts - // (No @packageDocumentation comment for this package) ``` diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/CasingTransform.swift b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/CasingTransform.swift new file mode 100644 index 00000000..6eeb36a6 --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/CasingTransform.swift @@ -0,0 +1,97 @@ +/* + MIT License + + Copyright 2023 - Present, Shopify Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import Foundation + +enum CasingTransform { + static func snakeToCamel(_ s: String) -> String { + guard !s.isEmpty else { return s } + let parts = s.split(separator: "_", omittingEmptySubsequences: false) + guard let first = parts.first else { return s } + let head = String(first) + let tail = parts.dropFirst().map { part -> String in + guard let initial = part.first else { return "" } + return initial.uppercased() + part.dropFirst() + } + return ([head] + tail).joined() + } + + static func camelToSnake(_ s: String) -> String { + guard !s.isEmpty else { return s } + var result = "" + for character in s { + if character.isUppercase { + if !result.isEmpty { + result.append("_") + } + result.append(character.lowercased()) + } else { + result.append(character) + } + } + return result + } + + static func transformKeys(_ value: Any, _ fn: (String) -> String) -> Any { + if let dict = value as? [String: Any] { + var transformed: [String: Any] = [:] + for (key, item) in dict { + transformed[fn(key)] = transformKeys(item, fn) + } + return transformed + } + if let array = value as? [Any] { + return array.map { transformKeys($0, fn) } + } + return value + } + + static func encodeForJS(_ payload: some Encodable) throws -> String { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(payload) + let object = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) + let transformed = transformKeys(object, snakeToCamel) + let outputData = try JSONSerialization.data(withJSONObject: transformed, options: [.fragmentsAllowed]) + guard let string = String(data: outputData, encoding: .utf8) else { + throw CasingTransformError.invalidUTF8 + } + return string + } + + static func decodeFromJS(_ json: String, as type: T.Type) throws -> T { + guard let data = json.data(using: .utf8) else { + throw CasingTransformError.invalidUTF8 + } + let object = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) + let transformed = transformKeys(object, camelToSnake) + let snakeData = try JSONSerialization.data(withJSONObject: transformed, options: [.fragmentsAllowed]) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(type, from: snakeData) + } +} + +enum CasingTransformError: Error { + case invalidUTF8 +} diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/CheckoutProtocolBridge.swift b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/CheckoutProtocolBridge.swift new file mode 100644 index 00000000..d86e5f08 --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/CheckoutProtocolBridge.swift @@ -0,0 +1,28 @@ +/* + MIT License + + Copyright 2023 - Present, Shopify Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +#if COCOAPODS + import ShopifyCheckoutKit + + extension CheckoutProtocol.Client: @retroactive CheckoutCommunicationProtocol {} +#endif diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/DispatchEnvelope.swift b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/DispatchEnvelope.swift new file mode 100644 index 00000000..2e80d8af --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/DispatchEnvelope.swift @@ -0,0 +1,29 @@ +/* + MIT License + + Copyright 2023 - Present, Shopify Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import Foundation + +struct DispatchEnvelope: Encodable { + let type: String + let payload: Payload +} diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/Package.swift b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/Package.swift new file mode 100644 index 00000000..ba604e69 --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/Package.swift @@ -0,0 +1,28 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "RNShopifyCheckoutKitCasingTransform", + platforms: [.iOS(.v13), .macOS(.v10_15)], + products: [ + .library(name: "RNShopifyCheckoutKitCasingTransform", targets: ["RNShopifyCheckoutKitCasingTransform"]) + ], + dependencies: [ + .package(path: "../../../../../../protocol/languages/swift") + ], + targets: [ + .target( + name: "RNShopifyCheckoutKitCasingTransform", + dependencies: [ + .product(name: "ShopifyCheckoutProtocol", package: "swift") + ], + path: ".", + sources: ["CasingTransform.swift", "DispatchEnvelope.swift", "ProtocolRelay.swift"] + ), + .testTarget( + name: "RNShopifyCheckoutKitCasingTransformTests", + dependencies: ["RNShopifyCheckoutKitCasingTransform"], + path: "Tests" + ) + ] +) diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ProtocolRelay.swift b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ProtocolRelay.swift new file mode 100644 index 00000000..68ac74f5 --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ProtocolRelay.swift @@ -0,0 +1,61 @@ +/* + MIT License + + Copyright 2023 - Present, Shopify Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import Foundation +#if COCOAPODS + import ShopifyCheckoutKit +#else + import ShopifyCheckoutProtocol +#endif + +func makeRelayClient( + subscribedMethods: [String], + dispatch: @escaping @MainActor @Sendable (String) -> Void +) -> CheckoutProtocol.Client { + var client = CheckoutProtocol.Client() + + for method in subscribedMethods { + switch method { + case CheckoutProtocol.start.method: + client = client.on(CheckoutProtocol.start) { checkout in + forwardEnvelope(type: method, payload: checkout, dispatch: dispatch) + } + default: + continue + } + } + + return client +} + +@MainActor +private func forwardEnvelope( + type: String, + payload: P, + dispatch: @MainActor @Sendable (String) -> Void +) { + guard let json = try? CasingTransform.encodeForJS(DispatchEnvelope(type: type, payload: payload)) else { + return + } + dispatch(json) +} diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.mm b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.mm index 77062f64..f43e1636 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.mm +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.mm @@ -25,6 +25,7 @@ of this software and associated documentation files (the "Software"), to deal #import #import #import +#import // Registers the Swift module class (ShopifyCheckoutKit.swift) with the RN // runtime under the name 'RCTShopifyCheckoutKit', extending the codegen @@ -41,7 +42,41 @@ @interface RCT_EXTERN_MODULE (RCTShopifyCheckoutKit, NativeShopifyCheckoutKitSpe RCT_EXTERN_METHOD(setConfig:(NSDictionary *)configuration) RCT_EXTERN_METHOD(present:(NSString *)checkoutURL - dispatch:(RCTResponseSenderBlock)dispatch) + subscribedMethods:(NSArray *)subscribedMethods) + +@end + +static const void *RCTShopifyCheckoutKitEventEmitterCallbackKey = + &RCTShopifyCheckoutKitEventEmitterCallbackKey; + +// Swift cannot directly subclass/import the codegen-generated +// NativeShopifyCheckoutKitSpecBase in this CocoaPods setup. The TurboModule +// runtime still calls `setEventEmitterCallback:` on the module instance, so this +// Objective-C++ category stores that generated callback and exposes a small +// selector Swift can call to emit the typed `onDispatch` event. +@implementation RCTShopifyCheckoutKit (DispatchEmitter) + +- (void)setEventEmitterCallback:(EventEmitterCallbackWrapper *)eventEmitterCallbackWrapper +{ + objc_setAssociatedObject( + self, + RCTShopifyCheckoutKitEventEmitterCallbackKey, + eventEmitterCallbackWrapper, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} + +- (void)emitOnDispatchFromSwift:(NSString *)value +{ + EventEmitterCallbackWrapper *eventEmitterCallbackWrapper = + (EventEmitterCallbackWrapper *)objc_getAssociatedObject( + self, RCTShopifyCheckoutKitEventEmitterCallbackKey); + + if (eventEmitterCallbackWrapper == nil) { + return; + } + + eventEmitterCallbackWrapper->_eventEmitterCallback("onDispatch", value); +} @end diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.swift b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.swift index 76ac0da7..b4cb4416 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.swift +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.swift @@ -48,13 +48,6 @@ class RCTShopifyCheckoutKit: NSObject { private var acceleratedCheckoutsApplePayConfiguration: Any? private var defaultLogLevel: LogLevel = .error - /// Per-call dispatcher passed in from JS. Holds onto an - /// `RCTResponseSenderBlock` for the duration of one `present()` call; - /// nulled on the first terminal SDK lifecycle event so a single - /// presentation can only ever fire `close` or `fail` once. Matches - /// the Android `CustomCheckoutListener.dispatchCallback` lifecycle. - private var pendingDispatchCallback: RCTResponseSenderBlock? - @objc var methodQueue: DispatchQueue { return DispatchQueue.main } @@ -112,7 +105,6 @@ class RCTShopifyCheckoutKit: NSObject { @objc func dismiss() { DispatchQueue.main.async { - self.pendingDispatchCallback = nil self.checkoutSheet?.dismiss(animated: true) self.checkoutSheet = nil } @@ -122,17 +114,30 @@ class RCTShopifyCheckoutKit: NSObject { // Retained for compatibility with the generated native module interface. } - @objc func present(_ checkoutURL: String, dispatch: RCTResponseSenderBlock?) { + @objc func present(_ checkoutURL: String, subscribedMethods: [String]) { DispatchQueue.main.async { - self.pendingDispatchCallback = nil - - guard let url = URL(string: checkoutURL), let viewController = self.getCurrentViewController() else { - return - } - - self.pendingDispatchCallback = dispatch - let view = CheckoutViewController(checkout: url, delegate: self) - viewController.present(view, animated: true) + guard let url = URL(string: checkoutURL), + let viewController = self.getCurrentViewController() else { return } + + // Protocol relay: forwards UCP messages from native to the JS + // dispatch event stream. + let client = makeRelayClient( + subscribedMethods: subscribedMethods, + dispatch: { [weak self] json in + self?.emitDispatchEvent(json) + } + ) + + // `delegate: self` wires the SDK lifecycle events (close/fail) + // into the same JS dispatcher; `client:` wires the UCP + // protocol event stream. They are independent inputs feeding + // the same outbound envelope channel. + let view = ShopifyCheckoutKit.present( + checkout: url, + from: viewController, + delegate: self, + client: client + ) self.checkoutSheet = view } } @@ -302,12 +307,9 @@ extension RCTShopifyCheckoutKit: CheckoutDelegate { /// without a terminal error. Mirrors /// `CustomCheckoutListener.onCheckoutCanceled()` on Android. /// - /// Unlike Android — where the dialog handles its own dismissal before - /// notifying the listener — the iOS SDK invokes this delegate from - /// `CheckoutWebViewController.@IBAction close()` and `presentationControllerDidDismiss` - /// without dismissing the presented view controller itself. Without - /// the explicit `dismiss(animated:)` below, tapping the X in the - /// sheet header fires `onClose` to JS but leaves the sheet visible. + /// The iOS SDK dismisses the presented checkout when the buyer taps + /// the close button; this wrapper also clears its local reference so + /// future presentations start from a clean state. func checkoutDidCancel() { emitDispatchEnvelope(type: .close, payload: nil) dismissCheckoutSheet() @@ -343,18 +345,13 @@ extension RCTShopifyCheckoutKit: CheckoutDelegate { // MARK: - Dispatch envelope helpers private extension RCTShopifyCheckoutKit { + func emitDispatchEvent(_ json: String) { + perform(NSSelectorFromString("emitOnDispatchFromSwift:"), with: json) + } + /// Builds a `{ "type": ..., "payload": ... }` envelope and forwards - /// it to the pending JS dispatcher. SDK lifecycle envelopes are - /// single-shot: the callback is released after emission so the same - /// presentation can only fire one terminal event. + /// it to the JS dispatch event stream. func emitDispatchEnvelope(type: DispatchEventType, payload: [String: Any]?) { - guard let dispatch = pendingDispatchCallback else { return } - // Single-shot for SDK lifecycle events — release before invoking - // so a delegate callback that re-enters this code path (e.g. via - // a synchronous JS callback that triggers `dismiss()`) cannot - // emit a second envelope on the same handle. - pendingDispatchCallback = nil - var envelope: [String: Any] = ["type": type.rawValue] if let payload { envelope["payload"] = payload @@ -366,7 +363,7 @@ private extension RCTShopifyCheckoutKit { NSLog("[ShopifyCheckoutKit] Failed to encode dispatch envelope for \(type.rawValue): non-UTF8 result") return } - dispatch([json]) + emitDispatchEvent(json) } catch { NSLog("[ShopifyCheckoutKit] Failed to serialize dispatch envelope for \(type.rawValue): \(error)") } diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/Tests/CasingTransformTests.swift b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/Tests/CasingTransformTests.swift new file mode 100644 index 00000000..e0da1a1e --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/Tests/CasingTransformTests.swift @@ -0,0 +1,254 @@ +/* + MIT License + + Copyright 2023 - Present, Shopify Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import Foundation +@testable import RNShopifyCheckoutKitCasingTransform +import Testing + +@Suite("Casing Transform Tests") +struct CasingTransformTests { + @Test func snakeToCamelConvertsSingleUnderscore() { + #expect(CasingTransform.snakeToCamel("continue_url") == "continueUrl") + } + + @Test func snakeToCamelConvertsLineItems() { + #expect(CasingTransform.snakeToCamel("line_items") == "lineItems") + } + + @Test func snakeToCamelConvertsImageUrl() { + #expect(CasingTransform.snakeToCamel("image_url") == "imageUrl") + } + + @Test func snakeToCamelLeavesNonSnakeUnchanged() { + #expect(CasingTransform.snakeToCamel("foo") == "foo") + } + + @Test func snakeToCamelHandlesEmptyString() { + #expect(CasingTransform.snakeToCamel("") == "") + } + + @Test func snakeToCamelConvertsMultipleUnderscores() { + #expect(CasingTransform.snakeToCamel("a_b_c") == "aBC") + } + + @Test func snakeToCamelConvertsLongMultiSegmentField() { + #expect(CasingTransform.snakeToCamel("accelerated_checkouts_apple_pay_configuration") == "acceleratedCheckoutsApplePayConfiguration") + } + + @Test func snakeToCamelConvertsOAuthAccessToken() { + #expect(CasingTransform.snakeToCamel("oauth_2_0_access_token") == "oauth20AccessToken") + } + + @Test func snakeToCamelConvertsHttpRequestFinish() { + #expect(CasingTransform.snakeToCamel("http_request_finish") == "httpRequestFinish") + } + + @Test func snakeToCamelConvertsIso8601Timestamp() { + #expect(CasingTransform.snakeToCamel("iso_8601_timestamp") == "iso8601Timestamp") + } + + @Test func snakeToCamelConvertsXForwardedForHeader() { + #expect(CasingTransform.snakeToCamel("x_forwarded_for_header") == "xForwardedForHeader") + } + + @Test func snakeToCamelLeavesAlreadyCamelInputUnchanged() { + #expect(CasingTransform.snakeToCamel("alreadyCamel") == "alreadyCamel") + } + + @Test func snakeToCamelTreatsNumbersAsNonSpecialCharacters() { + #expect(CasingTransform.snakeToCamel("field_v2") == "fieldV2") + } + + @Test func camelToSnakeConvertsContinueUrl() { + #expect(CasingTransform.camelToSnake("continueUrl") == "continue_url") + } + + @Test func camelToSnakeConvertsLineItems() { + #expect(CasingTransform.camelToSnake("lineItems") == "line_items") + } + + @Test func camelToSnakeLeavesLowercaseUnchanged() { + #expect(CasingTransform.camelToSnake("foo") == "foo") + } + + @Test func camelToSnakeHandlesEmptyString() { + #expect(CasingTransform.camelToSnake("") == "") + } + + @Test func camelToSnakeConvertsAcceleratedCheckoutsApplePayConfiguration() { + #expect(CasingTransform.camelToSnake("acceleratedCheckoutsApplePayConfiguration") == "accelerated_checkouts_apple_pay_configuration") + } + + @Test func camelToSnakeConvertsHttpRequestFinish() { + #expect(CasingTransform.camelToSnake("httpRequestFinish") == "http_request_finish") + } + + @Test func camelToSnakeConvertsXForwardedForHeader() { + #expect(CasingTransform.camelToSnake("xForwardedForHeader") == "x_forwarded_for_header") + } + + @Test func camelToSnakeSplitsEachConsecutiveUppercaseCharacter() { + #expect(CasingTransform.camelToSnake("imageURL") == "image_u_r_l") + } + + @Test func snakeToCamelRoundTripsTypicalWireKeys() { + let keys = [ + "continue_url", + "line_items", + "image_url", + "http_request_finish", + "accelerated_checkouts_apple_pay_configuration", + "x_forwarded_for_header" + ] + for key in keys { + #expect(CasingTransform.camelToSnake(CasingTransform.snakeToCamel(key)) == key) + } + } + + @Test func transformKeysRecursesNestedDictionariesAndArrays() throws { + let input: [String: Any] = [ + "outer_key": [ + "inner_key": "value", + "nested_list": [ + ["item_id": "1"], + ["item_id": "2"] + ] + ] + ] + + let result = try #require( + CasingTransform.transformKeys(input, CasingTransform.snakeToCamel) as? [String: Any] + ) + + let outer = try #require(result["outerKey"] as? [String: Any]) + #expect(outer["innerKey"] as? String == "value") + let list = try #require(outer["nestedList"] as? [[String: Any]]) + #expect(list[0]["itemId"] as? String == "1") + #expect(list[1]["itemId"] as? String == "2") + } + + @Test func transformKeysPassesThroughPrimitives() { + #expect(CasingTransform.transformKeys("text", CasingTransform.snakeToCamel) as? String == "text") + #expect(CasingTransform.transformKeys(42, CasingTransform.snakeToCamel) as? Int == 42) + #expect(CasingTransform.transformKeys(true, CasingTransform.snakeToCamel) as? Bool == true) + #expect(CasingTransform.transformKeys(NSNull(), CasingTransform.snakeToCamel) is NSNull) + } + + @Test func encodeForJSConvertsTopLevelKeysToCamelCase() throws { + let payload = makePayload() + let json = try CasingTransform.encodeForJS(payload) + + let parsed = try #require(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) + + #expect(parsed["continueUrl"] != nil) + #expect(parsed["continue_url"] == nil) + #expect(parsed["lineItems"] != nil) + #expect(parsed["line_items"] == nil) + #expect(parsed["expiresAt"] != nil) + #expect(parsed["expires_at"] == nil) + } + + @Test func encodeForJSConvertsNestedKeysToCamelCase() throws { + let payload = makePayload() + let json = try CasingTransform.encodeForJS(payload) + + let parsed = try #require(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) + let ucp = try #require(parsed["ucp"] as? [String: Any]) + #expect(ucp["paymentHandlers"] != nil) + #expect(ucp["payment_handlers"] == nil) + } + + @Test func decodeFromJSAcceptsCamelCaseInputAndDecodesIntoTypedModel() throws { + let camelJSON = #""" + { + "id": "chk_1", + "currency": "USD", + "continueUrl": "https://example.com/continue", + "expiresAt": "2023-11-14T22:13:20Z", + "lineItems": [], + "ucp": { + "version": "2026-04-08", + "paymentHandlers": {} + } + } + """# + + let payload = try CasingTransform.decodeFromJS(camelJSON, as: TestPayload.self) + + #expect(payload.id == "chk_1") + #expect(payload.currency == "USD") + #expect(payload.continueURL == "https://example.com/continue") + #expect(payload.lineItems.isEmpty) + #expect(payload.ucp.version == "2026-04-08") + #expect(payload.ucp.paymentHandlers.isEmpty) + } + + private func makePayload() -> TestPayload { + TestPayload( + id: "chk_1", + currency: "USD", + continueURL: "https://example.com/continue", + expiresAt: Date(timeIntervalSince1970: 1_700_000_000), + lineItems: [], + ucp: TestUCP(version: "2026-04-08", paymentHandlers: [:]) + ) + } +} + +private struct TestPayload: Codable { + let id: String + let currency: String + let continueURL: String + let expiresAt: Date + let lineItems: [TestLineItem] + let ucp: TestUCP + + enum CodingKeys: String, CodingKey { + case id + case currency + case continueURL = "continue_url" + case expiresAt = "expires_at" + case lineItems = "line_items" + case ucp + } +} + +private struct TestLineItem: Codable { + let id: String + let imageURL: String + + enum CodingKeys: String, CodingKey { + case id + case imageURL = "image_url" + } +} + +private struct TestUCP: Codable { + let version: String + let paymentHandlers: [String: String] + + enum CodingKeys: String, CodingKey { + case version + case paymentHandlers = "payment_handlers" + } +} diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/Tests/ProtocolRelayTests.swift b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/Tests/ProtocolRelayTests.swift new file mode 100644 index 00000000..eec25bde --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/Tests/ProtocolRelayTests.swift @@ -0,0 +1,131 @@ +/* + MIT License + + Copyright 2023 - Present, Shopify Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import Foundation +@testable import RNShopifyCheckoutKitCasingTransform +import ShopifyCheckoutProtocol +import Testing + +@Suite("Protocol Relay Tests") +struct ProtocolRelayTests { + @Test func envelopeEncodesTypeAndCamelCasePayload() throws { + let payload = SnakePayload(continueURL: "https://example.com", lineItems: []) + let envelope = DispatchEnvelope(type: "ec.start", payload: payload) + let json = try CasingTransform.encodeForJS(envelope) + + let parsed = try #require( + JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any] + ) + #expect(parsed["type"] as? String == "ec.start") + + let payloadDict = try #require(parsed["payload"] as? [String: Any]) + #expect(payloadDict["continueUrl"] as? String == "https://example.com") + #expect(payloadDict["lineItems"] as? [Any] != nil) + #expect(payloadDict["continue_url"] == nil) + #expect(payloadDict["line_items"] == nil) + } + + @MainActor + @Test func relayDispatchesEnvelopeOnEcStart() async throws { + var captured: String? + let client = makeRelayClient( + subscribedMethods: ["ec.start"], + dispatch: { json in captured = json } + ) + + _ = await client.process(ecStartNotificationFixture) + + let json = try #require(captured) + let parsed = try #require(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) + #expect(parsed["type"] as? String == "ec.start") + let payload = try #require(parsed["payload"] as? [String: Any]) + #expect(payload["id"] as? String == "checkout-123") + #expect(payload["currency"] as? String == "USD") + let lineItems = try #require(payload["lineItems"] as? [[String: Any]]) + #expect(lineItems.count == 1) + let firstItem = try #require(lineItems.first?["item"] as? [String: Any]) + #expect(firstItem["imageUrl"] as? String == "https://example.com/image.png") + } + + @MainActor + @Test func relayIgnoresMethodsNotInSubscribedList() async throws { + var captured: String? + let client = makeRelayClient( + subscribedMethods: [], + dispatch: { json in captured = json } + ) + + _ = await client.process(ecStartNotificationFixture) + + #expect(captured == nil) + } +} + +private struct SnakePayload: Codable { + let continueURL: String + let lineItems: [String] + + enum CodingKeys: String, CodingKey { + case continueURL = "continue_url" + case lineItems = "line_items" + } +} + +private let ecStartNotificationFixture = #""" +{ + "jsonrpc": "2.0", + "method": "ec.start", + "params": { + "checkout": { + "ucp": { + "version": "2026-04-08", + "payment_handlers": {} + }, + "id": "checkout-123", + "status": "incomplete", + "currency": "USD", + "line_items": [ + { + "id": "li-1", + "quantity": 1, + "item": { + "id": "product-1", + "title": "Test Product", + "price": 2999, + "image_url": "https://example.com/image.png" + }, + "totals": [ + {"type": "subtotal", "amount": 2999} + ] + } + ], + "totals": [ + {"type": "total", "amount": 2999} + ], + "links": [ + {"type": "privacy_policy", "url": "https://example.com/privacy"} + ] + } + } +} +"""# diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/package.json b/platforms/react-native/modules/@shopify/checkout-kit-react-native/package.json index 3a0d4725..d092bdc5 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/package.json +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/package.json @@ -53,9 +53,11 @@ "react": "*", "react-native": "*" }, + "dependencies": { + "@shopify/checkout-kit-protocol": "workspace:*" + }, "devDependencies": { "@microsoft/api-extractor": "^7.58.7", - "@shopify/checkout-kit-protocol": "workspace:*", "react-native-builder-bob": "^0.23.2", "typescript": "^5.9.2" }, diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/package.snapshot.json b/platforms/react-native/modules/@shopify/checkout-kit-react-native/package.snapshot.json index 792be1dd..133c502a 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/package.snapshot.json +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/package.snapshot.json @@ -4,17 +4,30 @@ "android/proguard-rules.pro", "android/src/main/AndroidManifest.xml", "android/src/main/AndroidManifestNew.xml", + "android/src/main/java/com/shopify/reactnative/checkoutkit/CasingTransform.kt", "android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListener.java", + "android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchEnvelope.kt", "android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchEventTypes.java", + "android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchHandle.java", + "android/src/main/java/com/shopify/reactnative/checkoutkit/ProtocolRelay.kt", "android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java", "android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitPackage.java", + "android/src/test/java/com/shopify/reactnative/checkoutkit/CasingTransformTest.kt", + "android/src/test/java/com/shopify/reactnative/checkoutkit/ProtocolRelayTest.kt", "ios/AcceleratedCheckoutButtons.swift", "ios/AcceleratedCheckoutButtons+Extensions.swift", + "ios/CasingTransform.swift", + "ios/CheckoutProtocolBridge.swift", + "ios/DispatchEnvelope.swift", + "ios/Package.swift", + "ios/ProtocolRelay.swift", "ios/ShopifyCheckoutKit-Bridging-Header.h", "ios/ShopifyCheckoutKit.mm", "ios/ShopifyCheckoutKit.swift", "ios/ShopifyCheckoutKit+EventSerialization.swift", "ios/ShopifyCheckoutKit+Extensions.swift", + "ios/Tests/CasingTransformTests.swift", + "ios/Tests/ProtocolRelayTests.swift", "lib/commonjs/components/AcceleratedCheckoutButtons.js", "lib/commonjs/components/AcceleratedCheckoutButtons.js.map", "lib/commonjs/context.js", @@ -27,6 +40,8 @@ "lib/commonjs/index.d.js.map", "lib/commonjs/index.js", "lib/commonjs/index.js.map", + "lib/commonjs/protocol.js", + "lib/commonjs/protocol.js.map", "lib/commonjs/specs/NativeShopifyCheckoutKit.js", "lib/commonjs/specs/NativeShopifyCheckoutKit.js.map", "lib/commonjs/specs/RCTAcceleratedCheckoutButtonsNativeComponent.js", @@ -43,6 +58,8 @@ "lib/module/index.d.js.map", "lib/module/index.js", "lib/module/index.js.map", + "lib/module/protocol.js", + "lib/module/protocol.js.map", "lib/module/specs/NativeShopifyCheckoutKit.js", "lib/module/specs/NativeShopifyCheckoutKit.js.map", "lib/module/specs/RCTAcceleratedCheckoutButtonsNativeComponent.js", @@ -55,6 +72,8 @@ "lib/typescript/src/dispatch-events.d.ts.map", "lib/typescript/src/index.d.ts", "lib/typescript/src/index.d.ts.map", + "lib/typescript/src/protocol.d.ts", + "lib/typescript/src/protocol.d.ts.map", "lib/typescript/src/specs/NativeShopifyCheckoutKit.d.ts", "lib/typescript/src/specs/NativeShopifyCheckoutKit.d.ts.map", "lib/typescript/src/specs/RCTAcceleratedCheckoutButtonsNativeComponent.d.ts", @@ -68,6 +87,7 @@ "src/errors.d.ts", "src/index.d.ts", "src/index.ts", + "src/protocol.ts", "src/specs/NativeShopifyCheckoutKit.ts", "src/specs/RCTAcceleratedCheckoutButtonsNativeComponent.ts" ] diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/context.tsx b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/context.tsx index 268c9f0b..5d1caad5 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/context.tsx +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/context.tsx @@ -25,6 +25,7 @@ import React, {useCallback, useMemo, useRef, useEffect, useState} from 'react'; import type {PropsWithChildren} from 'react'; import {ShopifyCheckout} from './index'; import type {Configuration, Features, PresentCallbacks} from './index.d'; +import type {ProtocolHandlers} from './protocol'; type Maybe = T | undefined; @@ -32,7 +33,11 @@ interface Context { acceleratedCheckoutsAvailable: boolean; getConfig: () => Configuration | undefined; setConfig: (config: Configuration) => void; - present: (checkoutUrl: string, callbacks?: PresentCallbacks) => void; + present: ( + checkoutUrl: string, + callbacks?: PresentCallbacks, + protocol?: ProtocolHandlers, + ) => void; dismiss: () => void; version: Maybe; } @@ -81,9 +86,13 @@ export function ShopifyCheckoutProvider({ }, [configuration]); const present = useCallback( - (checkoutUrl: string, callbacks?: PresentCallbacks) => { + ( + checkoutUrl: string, + callbacks?: PresentCallbacks, + protocol?: ProtocolHandlers, + ) => { if (checkoutUrl) { - instance.current?.present(checkoutUrl, callbacks); + instance.current?.present(checkoutUrl, callbacks, protocol); } }, [], diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.d.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.d.ts index 31df3b23..a3fb8a12 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.d.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.d.ts @@ -22,6 +22,8 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SO */ import type {CheckoutException} from './errors'; +import type {ProtocolHandlers} from './protocol'; +export type {Checkout, CheckoutProtocolPayloads, ProtocolHandlers} from './protocol'; export type Maybe = T | undefined; @@ -180,7 +182,7 @@ export interface GeolocationRequestEvent { } /** - * Per-call SDK callbacks for `present(url, callbacks)`. + * Per-call SDK callbacks for `present(url, callbacks, protocol)`. * * Exactly one of `onClose` or `onFail` fires per `present(...)` invocation, * after which the callbacks are released. @@ -296,8 +298,13 @@ export interface ShopifyCheckoutKit { * @param callbacks Optional per-call SDK callbacks. Exactly one of * `onClose` or `onFail` fires per call, after which the callbacks are * released. + * @param protocol Optional per-call Checkout Protocol event handlers. */ - present(checkoutURL: string, callbacks?: PresentCallbacks): void; + present( + checkoutURL: string, + callbacks?: PresentCallbacks, + protocol?: ProtocolHandlers, + ): void; /** * Configure the checkout. See README.md for more details. */ diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts index 348b8400..2473be19 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts @@ -34,9 +34,12 @@ import { } from './dispatch-events'; import type { AcceleratedCheckoutConfiguration, + AndroidAutomaticColors, + AndroidColors, Configuration, Features, GeolocationRequestEvent, + IosColors, PresentCallbacks, ShopifyCheckoutKit, } from './index.d'; @@ -60,6 +63,12 @@ import type { AcceleratedCheckoutButtonsProps, RenderStateChangeEvent, } from './components/AcceleratedCheckoutButtons'; +import {CheckoutProtocol} from './protocol'; +import type { + Checkout, + CheckoutProtocolPayloads, + ProtocolHandlers, +} from './protocol'; const defaultFeatures: Features = { handleGeolocationRequests: true, @@ -77,6 +86,8 @@ const logLevelValues: ReadonlySet = new Set(Object.values(LogLevel)); class ShopifyCheckout implements ShopifyCheckoutKit { private features: Features; + private dispatchSubscription?: {remove: () => void}; + private _acceleratedCheckoutsReady = false; // TurboModule constants are immutable for the lifetime of the process — @@ -118,6 +129,7 @@ class ShopifyCheckout implements ShopifyCheckoutKit { * Dismisses the currently displayed checkout sheet */ public dismiss(): void { + this.releaseDispatchSubscription(); RNShopifyCheckoutKit.dismiss(); } @@ -125,13 +137,33 @@ class ShopifyCheckout implements ShopifyCheckoutKit { * Presents the checkout sheet for a given checkout URL. * * Exactly one of `callbacks.onClose` or `callbacks.onFail` fires per - * call, after which the native bridge releases both handles. + * call, after which the per-presentation dispatch subscription is released. * * @param checkoutUrl The URL of the checkout to display * @param callbacks Optional per-call SDK callbacks */ - public present(checkoutUrl: string, callbacks?: PresentCallbacks): void { - RNShopifyCheckoutKit.present(checkoutUrl, this.buildDispatcher(callbacks)); + public present( + checkoutUrl: string, + callbacks?: PresentCallbacks, + protocol?: ProtocolHandlers, + ): void { + this.releaseDispatchSubscription(); + + const dispatcher = this.buildDispatcher(callbacks, protocol); + const subscribedMethods = Object.entries(protocol ?? {}) + .filter(([, handler]) => typeof handler === 'function') + .map(([method]) => method); + + if (dispatcher) { + this.dispatchSubscription = RNShopifyCheckoutKit.onDispatch(envelopeJson => { + dispatcher(envelopeJson); + if (isTerminalDispatchEnvelope(envelopeJson)) { + this.releaseDispatchSubscription(); + } + }); + } + + RNShopifyCheckoutKit.present(checkoutUrl, subscribedMethods); } /** @@ -160,7 +192,9 @@ class ShopifyCheckout implements ShopifyCheckoutKit { * Currently a no-op — retained as part of the public API for forward * compatibility with future protocol-client subscriptions. */ - public teardown() {} + public teardown() { + this.releaseDispatchSubscription(); + } /** * Configure AcceleratedCheckouts for Shop Pay and Apple Pay buttons @@ -288,6 +322,11 @@ class ShopifyCheckout implements ShopifyCheckoutKit { return this.features[feature] ?? true; } + private releaseDispatchSubscription(): void { + this.dispatchSubscription?.remove(); + this.dispatchSubscription = undefined; + } + /** * Resolves the pending Android WebView geolocation permission request. * This does not request OS location permissions; callers should check @@ -300,19 +339,23 @@ class ShopifyCheckout implements ShopifyCheckoutKit { } /** - * Builds the single per-call dispatcher passed to the native bridge. - * Returns null when there is nothing for the bridge to deliver back — - * no user callbacks and no default-handler responsibilities — so the - * native side can skip serializing envelopes. + * Builds the single per-call dispatcher used by the native dispatch + * event stream. Returns null when there is nothing for JS to handle — + * no user callbacks and no default-handler responsibilities. */ private buildDispatcher( callbacks: PresentCallbacks | undefined, + protocol: ProtocolHandlers | undefined, ): ((envelopeJson: string) => void) | null { const needsDefaultGeolocation = Platform.OS === 'android' && this.featureEnabled('handleGeolocationRequests'); - if (!callbacks && !needsDefaultGeolocation) { + const hasProtocolHandlers = + protocol != null && + Object.values(protocol).some(handler => typeof handler === 'function'); + + if (!callbacks && !needsDefaultGeolocation && !hasProtocolHandlers) { return null; } @@ -346,15 +389,41 @@ class ShopifyCheckout implements ShopifyCheckoutKit { return; } + // Protocol method names (e.g. `ec.start`) live one layer down — owned + // by `@shopify/checkout-kit-protocol`. Accept any string that has + // a registered handler, but validate the payload shape minimally + // before forwarding. + const protocolHandler = + protocol == null + ? undefined + : (protocol as Record< + string, + ((payload: unknown) => void) | undefined + >)[type]; + + if (protocolHandler) { + if (!isPlainObject(payload)) { + logParseError( + `protocol envelope "${type}" payload is not an object`, + envelopeJson, + ); + return; + } + protocolHandler(payload); + return; + } + // Loud default. The parity check at construction time should have - // already caught a native/JS mismatch — hitting this branch means - // either the bundled native module emitted something we do not - // recognise, or we are missing a handler for a future event. + // already caught an SDK-lifecycle mismatch — hitting this branch + // means either the native module emitted an event the JS layer + // does not know how to handle, or no protocol handler was + // registered for it. // eslint-disable-next-line no-console console.warn( `[ShopifyCheckoutKit] Ignoring dispatch envelope with unknown type "${type}". ` + - 'The native module emitted an event the JS layer does not know how to handle. ' + - 'Confirm both sides are on compatible versions.', + 'Either the native module emitted an event the JS layer does not know how ' + + 'to handle, or no protocol handler was registered for it. Confirm both sides ' + + 'are on compatible versions.', ); }; } @@ -521,6 +590,18 @@ function isPlainObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +function isTerminalDispatchEnvelope(envelopeJson: string): boolean { + try { + const envelope: unknown = JSON.parse(envelopeJson); + return ( + isPlainObject(envelope) && + (envelope.type === 'close' || envelope.type === 'fail') + ); + } catch { + return false; + } +} + /** * Narrow validator for `fail` envelope payloads. Only confirms the * shape the JS dispatcher relies on — full coercion happens later in @@ -561,6 +642,7 @@ export { ApplePayContactField, ApplePayLabel, ApplePayStyle, + CheckoutProtocol, ColorScheme, DispatchEventParityError, LogLevel, @@ -585,11 +667,17 @@ export { export type { AcceleratedCheckoutButtonsProps, AcceleratedCheckoutConfiguration, + AndroidAutomaticColors, + AndroidColors, + Checkout, CheckoutException, + CheckoutProtocolPayloads, Configuration, Features, GeolocationRequestEvent, + IosColors, PresentCallbacks, + ProtocolHandlers, RenderStateChangeEvent, }; diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/protocol.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/protocol.ts new file mode 100644 index 00000000..9f5d3c1b --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/protocol.ts @@ -0,0 +1,40 @@ +/* +MIT License + +Copyright 2023 - Present, Shopify Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ + +import type {Checkout} from '@shopify/checkout-kit-protocol'; + +export type {Checkout} from '@shopify/checkout-kit-protocol'; + +export const CheckoutProtocol = { + start: 'ec.start', +} as const; + +export interface CheckoutProtocolPayloads { + 'ec.start': Checkout; +} + +export type ProtocolHandlers = Partial<{ + [K in keyof CheckoutProtocolPayloads]: ( + payload: CheckoutProtocolPayloads[K], + ) => void; +}>; diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/NativeShopifyCheckoutKit.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/NativeShopifyCheckoutKit.ts index 08979505..c490f15f 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/NativeShopifyCheckoutKit.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/NativeShopifyCheckoutKit.ts @@ -23,6 +23,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SO import type {TurboModule} from 'react-native'; import {TurboModuleRegistry} from 'react-native'; +import type {EventEmitter} from 'react-native/Libraries/Types/CodegenTypes'; type IosColorsSpec = { tintColor?: string; @@ -70,9 +71,11 @@ type ConfigurationResultSpec = { }; export interface Spec extends TurboModule { + readonly onDispatch: EventEmitter; + present( checkoutUrl: string, - dispatch: ((envelopeJson: string) => void) | null, + subscribedMethods: string[], ): void; dismiss(): void; setConfig(configuration: ConfigurationSpec): void; diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/context.test.tsx b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/context.test.tsx index 5ae0223d..216b8595 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/context.test.tsx +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/context.test.tsx @@ -5,7 +5,12 @@ import { ShopifyCheckoutProvider, useShopifyCheckout, } from '../src/context'; -import {ApplePayContactField, ColorScheme, type Configuration} from '../src'; +import { + ApplePayContactField, + CheckoutProtocol, + ColorScheme, + type Configuration, +} from '../src'; const checkoutUrl = 'https://shopify.com/checkout'; const config: Configuration = { @@ -154,7 +159,7 @@ describe('useShopifyCheckout', () => { jest.clearAllMocks(); }); - it('provides present function and calls it with checkoutUrl and a null dispatcher when no callbacks are passed', () => { + it('provides present function and calls native present when no callbacks are passed', () => { let hookValue: any; const onHookValue = (value: any) => { hookValue = value; @@ -172,11 +177,11 @@ describe('useShopifyCheckout', () => { expect(NativeModules.ShopifyCheckoutKit.present).toHaveBeenCalledWith( checkoutUrl, - null, + [], ); }); - it('forwards a dispatcher to native when callbacks are supplied', () => { + it('subscribes to dispatch events when callbacks are supplied', () => { let hookValue: any; const onHookValue = (value: any) => { hookValue = value; @@ -196,10 +201,40 @@ describe('useShopifyCheckout', () => { hookValue.present(checkoutUrl, {onClose, onFail, onGeolocationRequest}); }); + expect(NativeModules.ShopifyCheckoutKit.onDispatch).toHaveBeenCalledWith( + expect.any(Function), + ); expect(NativeModules.ShopifyCheckoutKit.present).toHaveBeenCalledWith( checkoutUrl, + [], + ); + }); + + it('forwards protocol handlers through the provider present function', () => { + let hookValue: any; + const onHookValue = (value: any) => { + hookValue = value; + }; + + render( + + + , + ); + + act(() => { + hookValue.present(checkoutUrl, undefined, { + [CheckoutProtocol.start]: jest.fn(), + }); + }); + + expect(NativeModules.ShopifyCheckoutKit.onDispatch).toHaveBeenCalledWith( expect.any(Function), ); + expect(NativeModules.ShopifyCheckoutKit.present).toHaveBeenCalledWith( + checkoutUrl, + [CheckoutProtocol.start], + ); }); it('does not call present with empty checkoutUrl', () => { diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/index.test.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/index.test.ts index b317943d..b2761563 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/index.test.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/index.test.ts @@ -17,6 +17,7 @@ import { LogLevel, ColorScheme, CheckoutNativeErrorType, + CheckoutProtocol, type Configuration, type AcceleratedCheckoutConfiguration, } from '../src'; @@ -78,12 +79,12 @@ describe('Exports', () => { type Dispatch = (envelopeJson: string) => void; function lastDispatch(): Dispatch { - const dispatch = NativeModule.present.mock.calls[ - NativeModule.present.mock.calls.length - 1 - ][1] as Dispatch | null; + const dispatch = NativeModule.onDispatch.mock.calls[ + NativeModule.onDispatch.mock.calls.length - 1 + ]?.[0] as Dispatch | undefined; if (!dispatch) { throw new Error( - 'Expected the last present() call to receive a non-null dispatcher', + 'Expected the last present() call to subscribe to dispatch events', ); } return dispatch; @@ -132,14 +133,14 @@ describe('ShopifyCheckoutKit', () => { const instance = new ShopifyCheckout(); instance.present(checkoutUrl); expect(NativeModule.present).toHaveBeenCalledTimes(1); - expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl, null); + expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl, []); }); it('calls `present` with a dispatcher when callbacks are provided', () => { const instance = new ShopifyCheckout(); instance.present(checkoutUrl, {onClose: jest.fn()}); - expect(NativeModule.present).toHaveBeenCalledWith( - checkoutUrl, + expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl, []); + expect(NativeModule.onDispatch).toHaveBeenCalledWith( expect.any(Function), ); }); @@ -265,6 +266,86 @@ describe('ShopifyCheckoutKit', () => { }); }); + describe('protocol handlers', () => { + const startPayload = { + id: 'chk_123', + currency: 'USD', + lineItems: [], + links: [], + status: 'active', + totals: [], + ucp: {}, + }; + + it('routes envelope.type via the protocol handler map', () => { + const instance = new ShopifyCheckout(); + const onStart = jest.fn(); + instance.present(checkoutUrl, undefined, { + [CheckoutProtocol.start]: onStart, + }); + lastDispatch()( + JSON.stringify({type: CheckoutProtocol.start, payload: startPayload}), + ); + expect(onStart).toHaveBeenCalledTimes(1); + expect(onStart).toHaveBeenCalledWith(startPayload); + expect(onStart.mock.calls[0][0].id).toBe('chk_123'); + }); + + it('passes subscribedMethods to native present()', () => { + const instance = new ShopifyCheckout(); + instance.present(checkoutUrl, undefined, { + [CheckoutProtocol.start]: jest.fn(), + }); + expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl, [ + CheckoutProtocol.start, + ]); + expect(NativeModule.onDispatch).toHaveBeenCalledWith( + expect.any(Function), + ); + }); + + it('still routes existing close/fail/geolocationRequest cases alongside protocol handlers', () => { + Platform.OS = 'ios'; + const instance = new ShopifyCheckout(); + const onClose = jest.fn(); + const onFail = jest.fn(); + const onGeolocationRequest = jest.fn(); + const onStart = jest.fn(); + instance.present( + checkoutUrl, + {onClose, onFail, onGeolocationRequest}, + {[CheckoutProtocol.start]: onStart}, + ); + const dispatch = lastDispatch(); + dispatch(JSON.stringify({type: 'close'})); + dispatch( + JSON.stringify({ + type: 'fail', + payload: { + __typename: CheckoutNativeErrorType.InternalError, + message: 'boom', + code: CheckoutErrorCode.unknown, + recoverable: true, + }, + }), + ); + dispatch( + JSON.stringify({ + type: 'geolocationRequest', + payload: {origin: 'https://shopify.com'}, + }), + ); + expect(onClose).toHaveBeenCalledTimes(1); + expect(onFail).toHaveBeenCalledTimes(1); + expect(onFail.mock.calls[0][0]).toBeInstanceOf(InternalError); + expect(onGeolocationRequest).toHaveBeenCalledWith({ + origin: 'https://shopify.com', + respond: expect.any(Function), + }); + expect(onStart).not.toHaveBeenCalled(); + }); + }); + describe('envelope parsing', () => { it('logs a LifecycleEventParseError when the envelope is invalid JSON', () => { const instance = new ShopifyCheckout(); @@ -427,21 +508,21 @@ describe('ShopifyCheckoutKit', () => { Platform.OS = originalPlatform; }); - it('passes a dispatcher when the default handler is enabled, even without callbacks', () => { + it('subscribes to dispatch events when the default handler is enabled, even without callbacks', () => { const instance = new ShopifyCheckout(); instance.present(checkoutUrl); - expect(NativeModule.present).toHaveBeenCalledWith( - checkoutUrl, + expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl, []); + expect(NativeModule.onDispatch).toHaveBeenCalledWith( expect.any(Function), ); }); - it('passes a null dispatcher when no callbacks and the default handler is disabled', () => { + it('does not subscribe to dispatch events when no callbacks and the default handler is disabled', () => { const instance = new ShopifyCheckout(undefined, { handleGeolocationRequests: false, }); instance.present(checkoutUrl); - expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl, null); + expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl, []); }); it('handles geolocation permission grant correctly', async () => { @@ -552,7 +633,7 @@ describe('ShopifyCheckoutKit', () => { it('passes a null dispatcher by default — no default geolocation handling on iOS', () => { const instance = new ShopifyCheckout(); instance.present(checkoutUrl); - expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl, null); + expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl, []); }); it('does not run the default geolocation handler on iOS even if dispatcher fires', async () => { diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/protocol.test.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/protocol.test.ts new file mode 100644 index 00000000..e30528ff --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/protocol.test.ts @@ -0,0 +1,41 @@ +import { + CheckoutProtocol, + type Checkout, + type ProtocolHandlers, +} from '../src'; + +describe('CheckoutProtocol', () => { + describe('runtime values', () => { + it('exposes ec.start as the literal method string', () => { + expect(CheckoutProtocol.start).toBe('ec.start'); + }); + }); + + describe('ProtocolHandlers typing', () => { + it('accepts a handler keyed by CheckoutProtocol.start', () => { + const handlers: ProtocolHandlers = { + [CheckoutProtocol.start]: chk => { + expect(typeof chk.id).toBe('string'); + }, + }; + + expect(typeof handlers[CheckoutProtocol.start]).toBe('function'); + }); + + it('infers Checkout as the start handler payload type', () => { + type StartHandler = NonNullable; + type StartParam = Parameters[0]; + + const _typeCheck: Checkout extends StartParam ? true : false = true; + const _reverseCheck: StartParam extends Checkout ? true : false = true; + + expect(_typeCheck).toBe(true); + expect(_reverseCheck).toBe(true); + }); + + it('accepts an empty handlers map', () => { + const empty: ProtocolHandlers = {}; + expect(empty).toEqual({}); + }); + }); +}); diff --git a/platforms/react-native/pnpm-lock.yaml b/platforms/react-native/pnpm-lock.yaml index b5f6e5e7..22326dec 100644 --- a/platforms/react-native/pnpm-lock.yaml +++ b/platforms/react-native/pnpm-lock.yaml @@ -104,6 +104,9 @@ importers: modules/@shopify/checkout-kit-react-native: dependencies: + '@shopify/checkout-kit-protocol': + specifier: workspace:* + version: link:../../../../../protocol/languages/typescript react: specifier: '*' version: 19.1.0 @@ -114,9 +117,6 @@ importers: '@microsoft/api-extractor': specifier: ^7.58.7 version: 7.58.7(@types/node@20.9.3) - '@shopify/checkout-kit-protocol': - specifier: workspace:* - version: link:../../../../../protocol/languages/typescript react-native-builder-bob: specifier: ^0.23.2 version: 0.23.2 @@ -1047,13 +1047,13 @@ packages: engines: {node: '>=0.8.0'} '@emnapi/core@1.4.3': - resolution: {integrity: sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==} + resolution: {integrity: sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==, tarball: https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz} '@emnapi/runtime@1.4.3': - resolution: {integrity: sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==} + resolution: {integrity: sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==, tarball: https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz} '@emnapi/wasi-threads@1.0.2': - resolution: {integrity: sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA==} + resolution: {integrity: sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA==, tarball: https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.2.tgz} '@eslint-community/eslint-utils@4.4.0': resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} @@ -1264,7 +1264,7 @@ packages: resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} '@napi-rs/wasm-runtime@0.2.11': - resolution: {integrity: sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==} + resolution: {integrity: sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==, tarball: https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.11.tgz} '@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1': resolution: {integrity: sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==} @@ -1282,7 +1282,7 @@ packages: engines: {node: '>= 8'} '@pkgjs/parseargs@0.11.0': - resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==, tarball: https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz} engines: {node: '>=14'} '@pkgr/core@0.2.7': @@ -1540,7 +1540,7 @@ packages: resolution: {integrity: sha512-cWG+s5ZJfEBhaJbCs8QqeWhGbYHhUoq93+wOAdGzh1k/m7FkEmJkUTVsCVJ+rhLpwTNIVrLaHL/IUfBne5D6mw==} '@tybys/wasm-util@0.9.0': - resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} + resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==, tarball: https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz} '@types/argparse@1.0.38': resolution: {integrity: sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==} @@ -1710,97 +1710,97 @@ packages: resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} '@unrs/resolver-binding-android-arm-eabi@1.9.2': - resolution: {integrity: sha512-tS+lqTU3N0kkthU+rYp0spAYq15DU8ld9kXkaKg9sbQqJNF+WPMuNHZQGCgdxrUOEO0j22RKMwRVhF1HTl+X8A==} + resolution: {integrity: sha512-tS+lqTU3N0kkthU+rYp0spAYq15DU8ld9kXkaKg9sbQqJNF+WPMuNHZQGCgdxrUOEO0j22RKMwRVhF1HTl+X8A==, tarball: https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.9.2.tgz} cpu: [arm] os: [android] '@unrs/resolver-binding-android-arm64@1.9.2': - resolution: {integrity: sha512-MffGiZULa/KmkNjHeuuflLVqfhqLv1vZLm8lWIyeADvlElJ/GLSOkoUX+5jf4/EGtfwrNFcEaB8BRas03KT0/Q==} + resolution: {integrity: sha512-MffGiZULa/KmkNjHeuuflLVqfhqLv1vZLm8lWIyeADvlElJ/GLSOkoUX+5jf4/EGtfwrNFcEaB8BRas03KT0/Q==, tarball: https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.9.2.tgz} cpu: [arm64] os: [android] '@unrs/resolver-binding-darwin-arm64@1.9.2': - resolution: {integrity: sha512-dzJYK5rohS1sYl1DHdJ3mwfwClJj5BClQnQSyAgEfggbUwA9RlROQSSbKBLqrGfsiC/VyrDPtbO8hh56fnkbsQ==} + resolution: {integrity: sha512-dzJYK5rohS1sYl1DHdJ3mwfwClJj5BClQnQSyAgEfggbUwA9RlROQSSbKBLqrGfsiC/VyrDPtbO8hh56fnkbsQ==, tarball: https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.9.2.tgz} cpu: [arm64] os: [darwin] '@unrs/resolver-binding-darwin-x64@1.9.2': - resolution: {integrity: sha512-gaIMWK+CWtXcg9gUyznkdV54LzQ90S3X3dn8zlh+QR5Xy7Y+Efqw4Rs4im61K1juy4YNb67vmJsCDAGOnIeffQ==} + resolution: {integrity: sha512-gaIMWK+CWtXcg9gUyznkdV54LzQ90S3X3dn8zlh+QR5Xy7Y+Efqw4Rs4im61K1juy4YNb67vmJsCDAGOnIeffQ==, tarball: https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.9.2.tgz} cpu: [x64] os: [darwin] '@unrs/resolver-binding-freebsd-x64@1.9.2': - resolution: {integrity: sha512-S7QpkMbVoVJb0xwHFwujnwCAEDe/596xqY603rpi/ioTn9VDgBHnCCxh+UFrr5yxuMH+dliHfjwCZJXOPJGPnw==} + resolution: {integrity: sha512-S7QpkMbVoVJb0xwHFwujnwCAEDe/596xqY603rpi/ioTn9VDgBHnCCxh+UFrr5yxuMH+dliHfjwCZJXOPJGPnw==, tarball: https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.9.2.tgz} cpu: [x64] os: [freebsd] '@unrs/resolver-binding-linux-arm-gnueabihf@1.9.2': - resolution: {integrity: sha512-+XPUMCuCCI80I46nCDFbGum0ZODP5NWGiwS3Pj8fOgsG5/ctz+/zzuBlq/WmGa+EjWZdue6CF0aWWNv84sE1uw==} + resolution: {integrity: sha512-+XPUMCuCCI80I46nCDFbGum0ZODP5NWGiwS3Pj8fOgsG5/ctz+/zzuBlq/WmGa+EjWZdue6CF0aWWNv84sE1uw==, tarball: https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.9.2.tgz} cpu: [arm] os: [linux] '@unrs/resolver-binding-linux-arm-musleabihf@1.9.2': - resolution: {integrity: sha512-sqvUyAd1JUpwbz33Ce2tuTLJKM+ucSsYpPGl2vuFwZnEIg0CmdxiZ01MHQ3j6ExuRqEDUCy8yvkDKvjYFPb8Zg==} + resolution: {integrity: sha512-sqvUyAd1JUpwbz33Ce2tuTLJKM+ucSsYpPGl2vuFwZnEIg0CmdxiZ01MHQ3j6ExuRqEDUCy8yvkDKvjYFPb8Zg==, tarball: https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.9.2.tgz} cpu: [arm] os: [linux] '@unrs/resolver-binding-linux-arm64-gnu@1.9.2': - resolution: {integrity: sha512-UYA0MA8ajkEDCFRQdng/FVx3F6szBvk3EPnkTTQuuO9lV1kPGuTB+V9TmbDxy5ikaEgyWKxa4CI3ySjklZ9lFA==} + resolution: {integrity: sha512-UYA0MA8ajkEDCFRQdng/FVx3F6szBvk3EPnkTTQuuO9lV1kPGuTB+V9TmbDxy5ikaEgyWKxa4CI3ySjklZ9lFA==, tarball: https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.9.2.tgz} cpu: [arm64] os: [linux] '@unrs/resolver-binding-linux-arm64-musl@1.9.2': - resolution: {integrity: sha512-P/CO3ODU9YJIHFqAkHbquKtFst0COxdphc8TKGL5yCX75GOiVpGqd1d15ahpqu8xXVsqP4MGFP2C3LRZnnL5MA==} + resolution: {integrity: sha512-P/CO3ODU9YJIHFqAkHbquKtFst0COxdphc8TKGL5yCX75GOiVpGqd1d15ahpqu8xXVsqP4MGFP2C3LRZnnL5MA==, tarball: https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.9.2.tgz} cpu: [arm64] os: [linux] '@unrs/resolver-binding-linux-ppc64-gnu@1.9.2': - resolution: {integrity: sha512-uKStFlOELBxBum2s1hODPtgJhY4NxYJE9pAeyBgNEzHgTqTiVBPjfTlPFJkfxyTjQEuxZbbJlJnMCrRgD7ubzw==} + resolution: {integrity: sha512-uKStFlOELBxBum2s1hODPtgJhY4NxYJE9pAeyBgNEzHgTqTiVBPjfTlPFJkfxyTjQEuxZbbJlJnMCrRgD7ubzw==, tarball: https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.9.2.tgz} cpu: [ppc64] os: [linux] '@unrs/resolver-binding-linux-riscv64-gnu@1.9.2': - resolution: {integrity: sha512-LkbNnZlhINfY9gK30AHs26IIVEZ9PEl9qOScYdmY2o81imJYI4IMnJiW0vJVtXaDHvBvxeAgEy5CflwJFIl3tQ==} + resolution: {integrity: sha512-LkbNnZlhINfY9gK30AHs26IIVEZ9PEl9qOScYdmY2o81imJYI4IMnJiW0vJVtXaDHvBvxeAgEy5CflwJFIl3tQ==, tarball: https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.9.2.tgz} cpu: [riscv64] os: [linux] '@unrs/resolver-binding-linux-riscv64-musl@1.9.2': - resolution: {integrity: sha512-vI+e6FzLyZHSLFNomPi+nT+qUWN4YSj8pFtQZSFTtmgFoxqB6NyjxSjAxEC1m93qn6hUXhIsh8WMp+fGgxCoRg==} + resolution: {integrity: sha512-vI+e6FzLyZHSLFNomPi+nT+qUWN4YSj8pFtQZSFTtmgFoxqB6NyjxSjAxEC1m93qn6hUXhIsh8WMp+fGgxCoRg==, tarball: https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.9.2.tgz} cpu: [riscv64] os: [linux] '@unrs/resolver-binding-linux-s390x-gnu@1.9.2': - resolution: {integrity: sha512-sSO4AlAYhSM2RAzBsRpahcJB1msc6uYLAtP6pesPbZtptF8OU/CbCPhSRW6cnYOGuVmEmWVW5xVboAqCnWTeHQ==} + resolution: {integrity: sha512-sSO4AlAYhSM2RAzBsRpahcJB1msc6uYLAtP6pesPbZtptF8OU/CbCPhSRW6cnYOGuVmEmWVW5xVboAqCnWTeHQ==, tarball: https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.9.2.tgz} cpu: [s390x] os: [linux] '@unrs/resolver-binding-linux-x64-gnu@1.9.2': - resolution: {integrity: sha512-jkSkwch0uPFva20Mdu8orbQjv2A3G88NExTN2oPTI1AJ+7mZfYW3cDCTyoH6OnctBKbBVeJCEqh0U02lTkqD5w==} + resolution: {integrity: sha512-jkSkwch0uPFva20Mdu8orbQjv2A3G88NExTN2oPTI1AJ+7mZfYW3cDCTyoH6OnctBKbBVeJCEqh0U02lTkqD5w==, tarball: https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.9.2.tgz} cpu: [x64] os: [linux] '@unrs/resolver-binding-linux-x64-musl@1.9.2': - resolution: {integrity: sha512-Uk64NoiTpQbkpl+bXsbeyOPRpUoMdcUqa+hDC1KhMW7aN1lfW8PBlBH4mJ3n3Y47dYE8qi0XTxy1mBACruYBaw==} + resolution: {integrity: sha512-Uk64NoiTpQbkpl+bXsbeyOPRpUoMdcUqa+hDC1KhMW7aN1lfW8PBlBH4mJ3n3Y47dYE8qi0XTxy1mBACruYBaw==, tarball: https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.9.2.tgz} cpu: [x64] os: [linux] '@unrs/resolver-binding-wasm32-wasi@1.9.2': - resolution: {integrity: sha512-EpBGwkcjDicjR/ybC0g8wO5adPNdVuMrNalVgYcWi+gYtC1XYNuxe3rufcO7dA76OHGeVabcO6cSkPJKVcbCXQ==} + resolution: {integrity: sha512-EpBGwkcjDicjR/ybC0g8wO5adPNdVuMrNalVgYcWi+gYtC1XYNuxe3rufcO7dA76OHGeVabcO6cSkPJKVcbCXQ==, tarball: https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.9.2.tgz} engines: {node: '>=14.0.0'} cpu: [wasm32] '@unrs/resolver-binding-win32-arm64-msvc@1.9.2': - resolution: {integrity: sha512-EdFbGn7o1SxGmN6aZw9wAkehZJetFPao0VGZ9OMBwKx6TkvDuj6cNeLimF/Psi6ts9lMOe+Dt6z19fZQ9Ye2fw==} + resolution: {integrity: sha512-EdFbGn7o1SxGmN6aZw9wAkehZJetFPao0VGZ9OMBwKx6TkvDuj6cNeLimF/Psi6ts9lMOe+Dt6z19fZQ9Ye2fw==, tarball: https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.9.2.tgz} cpu: [arm64] os: [win32] '@unrs/resolver-binding-win32-ia32-msvc@1.9.2': - resolution: {integrity: sha512-JY9hi1p7AG+5c/dMU8o2kWemM8I6VZxfGwn1GCtf3c5i+IKcMo2NQ8OjZ4Z3/itvY/Si3K10jOBQn7qsD/whUA==} + resolution: {integrity: sha512-JY9hi1p7AG+5c/dMU8o2kWemM8I6VZxfGwn1GCtf3c5i+IKcMo2NQ8OjZ4Z3/itvY/Si3K10jOBQn7qsD/whUA==, tarball: https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.9.2.tgz} cpu: [ia32] os: [win32] '@unrs/resolver-binding-win32-x64-msvc@1.9.2': - resolution: {integrity: sha512-ryoo+EB19lMxAd80ln9BVf8pdOAxLb97amrQ3SFN9OCRn/5M5wvwDgAe4i8ZjhpbiHoDeP8yavcTEnpKBo7lZg==} + resolution: {integrity: sha512-ryoo+EB19lMxAd80ln9BVf8pdOAxLb97amrQ3SFN9OCRn/5M5wvwDgAe4i8ZjhpbiHoDeP8yavcTEnpKBo7lZg==, tarball: https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.9.2.tgz} cpu: [x64] os: [win32] @@ -2736,7 +2736,7 @@ packages: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==, tarball: https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] @@ -4527,32 +4527,32 @@ packages: typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' turbo-darwin-64@1.13.4: - resolution: {integrity: sha512-A0eKd73R7CGnRinTiS7txkMElg+R5rKFp9HV7baDiEL4xTG1FIg/56Vm7A5RVgg8UNgG2qNnrfatJtb+dRmNdw==} + resolution: {integrity: sha512-A0eKd73R7CGnRinTiS7txkMElg+R5rKFp9HV7baDiEL4xTG1FIg/56Vm7A5RVgg8UNgG2qNnrfatJtb+dRmNdw==, tarball: https://registry.npmjs.org/turbo-darwin-64/-/turbo-darwin-64-1.13.4.tgz} cpu: [x64] os: [darwin] turbo-darwin-arm64@1.13.4: - resolution: {integrity: sha512-eG769Q0NF6/Vyjsr3mKCnkG/eW6dKMBZk6dxWOdrHfrg6QgfkBUk0WUUujzdtVPiUIvsh4l46vQrNVd9EOtbyA==} + resolution: {integrity: sha512-eG769Q0NF6/Vyjsr3mKCnkG/eW6dKMBZk6dxWOdrHfrg6QgfkBUk0WUUujzdtVPiUIvsh4l46vQrNVd9EOtbyA==, tarball: https://registry.npmjs.org/turbo-darwin-arm64/-/turbo-darwin-arm64-1.13.4.tgz} cpu: [arm64] os: [darwin] turbo-linux-64@1.13.4: - resolution: {integrity: sha512-Bq0JphDeNw3XEi+Xb/e4xoKhs1DHN7OoLVUbTIQz+gazYjigVZvtwCvgrZI7eW9Xo1eOXM2zw2u1DGLLUfmGkQ==} + resolution: {integrity: sha512-Bq0JphDeNw3XEi+Xb/e4xoKhs1DHN7OoLVUbTIQz+gazYjigVZvtwCvgrZI7eW9Xo1eOXM2zw2u1DGLLUfmGkQ==, tarball: https://registry.npmjs.org/turbo-linux-64/-/turbo-linux-64-1.13.4.tgz} cpu: [x64] os: [linux] turbo-linux-arm64@1.13.4: - resolution: {integrity: sha512-BJcXw1DDiHO/okYbaNdcWN6szjXyHWx9d460v6fCHY65G8CyqGU3y2uUTPK89o8lq/b2C8NK0yZD+Vp0f9VoIg==} + resolution: {integrity: sha512-BJcXw1DDiHO/okYbaNdcWN6szjXyHWx9d460v6fCHY65G8CyqGU3y2uUTPK89o8lq/b2C8NK0yZD+Vp0f9VoIg==, tarball: https://registry.npmjs.org/turbo-linux-arm64/-/turbo-linux-arm64-1.13.4.tgz} cpu: [arm64] os: [linux] turbo-windows-64@1.13.4: - resolution: {integrity: sha512-OFFhXHOFLN7A78vD/dlVuuSSVEB3s9ZBj18Tm1hk3aW1HTWTuAw0ReN6ZNlVObZUHvGy8d57OAGGxf2bT3etQw==} + resolution: {integrity: sha512-OFFhXHOFLN7A78vD/dlVuuSSVEB3s9ZBj18Tm1hk3aW1HTWTuAw0ReN6ZNlVObZUHvGy8d57OAGGxf2bT3etQw==, tarball: https://registry.npmjs.org/turbo-windows-64/-/turbo-windows-64-1.13.4.tgz} cpu: [x64] os: [win32] turbo-windows-arm64@1.13.4: - resolution: {integrity: sha512-u5A+VOKHswJJmJ8o8rcilBfU5U3Y1TTAfP9wX8bFh8teYF1ghP0EhtMRLjhtp6RPa+XCxHHVA2CiC3gbh5eg5g==} + resolution: {integrity: sha512-u5A+VOKHswJJmJ8o8rcilBfU5U3Y1TTAfP9wX8bFh8teYF1ghP0EhtMRLjhtp6RPa+XCxHHVA2CiC3gbh5eg5g==, tarball: https://registry.npmjs.org/turbo-windows-arm64/-/turbo-windows-arm64-1.13.4.tgz} cpu: [arm64] os: [win32] @@ -4614,7 +4614,7 @@ packages: hasBin: true uglify-js@3.19.3: - resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==, tarball: https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz} engines: {node: '>=0.8.0'} hasBin: true @@ -6555,7 +6555,9 @@ snapshots: metro-runtime: 0.82.5 transitivePeerDependencies: - '@babel/core' + - bufferutil - supports-color + - utf-8-validate '@react-native/normalize-colors@0.80.2': {} diff --git a/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkitreactnative/ShopifyCheckoutKitModuleTest.java b/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkitreactnative/ShopifyCheckoutKitModuleTest.java index 52343330..b6091857 100644 --- a/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkitreactnative/ShopifyCheckoutKitModuleTest.java +++ b/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkitreactnative/ShopifyCheckoutKitModuleTest.java @@ -5,12 +5,11 @@ import androidx.activity.ComponentActivity; import com.facebook.react.bridge.Arguments; -import com.facebook.react.bridge.Callback; +import com.facebook.react.bridge.JavaOnlyArray; import com.facebook.react.bridge.JavaOnlyMap; import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.WritableMap; -import com.facebook.react.modules.core.DeviceEventManagerModule; import com.shopify.checkoutkit.CheckoutException; import com.shopify.checkoutkit.CheckoutExpiredException; import com.shopify.checkoutkit.CheckoutKitException; @@ -22,6 +21,7 @@ import com.shopify.checkoutkit.LogLevel; import com.shopify.reactnative.checkoutkit.ShopifyCheckoutKitModule; import com.shopify.reactnative.checkoutkit.CustomCheckoutListener; +import com.shopify.reactnative.checkoutkit.DispatchCallback; import org.junit.After; import org.junit.Before; @@ -46,9 +46,6 @@ public class ShopifyCheckoutKitModuleTest { private ReactApplicationContext mockReactContext; @Mock private ComponentActivity mockComponentActivity; - @Mock - private DeviceEventManagerModule.RCTDeviceEventEmitter mockEventEmitter; - @Captor ArgumentCaptor runnableCaptor; @Captor @@ -83,12 +80,6 @@ public void setup() { mockedArguments.when(Arguments::createMap).thenAnswer(invocation -> new JavaOnlyMap()); when(mockReactContext.getCurrentActivity()).thenReturn(mockComponentActivity); - // Note: the old `CustomCheckoutListener` used `reactContext.getJSModule(...)` - // to emit DeviceEventManagerModule events. Both the field and the method - // call are gone now, replaced by the per-`present()` dispatcher callback, - // so no `getJSModule(...)` stub is required here. `mockEventEmitter` is - // still referenced from a few `verify(..., never()).emit(...)` assertions - // below that defensively confirm the legacy emit path stays dead. shopifyCheckoutKitModule = new ShopifyCheckoutKitModule(mockReactContext); // Capture initial configuration state to restore after each test @@ -123,54 +114,58 @@ public void testCanPresentCheckout() { try (MockedStatic mockedShopifyCheckoutKit = Mockito .mockStatic(ShopifyCheckoutKit.class)) { String checkoutUrl = "https://shopify.com"; - shopifyCheckoutKitModule.present(checkoutUrl, null); + // An empty JavaOnlyArray stands in for "no UCP methods subscribed", + // matching the JS-side default of `protocol = {}`. + shopifyCheckoutKitModule.present(checkoutUrl, new JavaOnlyArray()); verify(mockComponentActivity).runOnUiThread(runnableCaptor.capture()); runnableCaptor.getValue().run(); mockedShopifyCheckoutKit.verify(() -> { - ShopifyCheckoutKit.present(eq(checkoutUrl), any(), any()); + // (url, activity, checkoutListener, protocolClient) — the protocol + // client is the new fourth arg from `ShopifyCheckoutKit.present` + // when UCP wiring is enabled. + ShopifyCheckoutKit.present(eq(checkoutUrl), any(), any(), any()); }); } } @Test public void testPresentForwardsOnCloseCallback() { - Callback dispatch = mock(Callback.class); + DispatchCallback dispatch = mock(DispatchCallback.class); CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); processor.onCheckoutCanceled(); - ArgumentCaptor args = ArgumentCaptor.forClass(Object[].class); - verify(dispatch).invoke(args.capture()); - assertThat((String) args.getValue()[0]).contains("\"type\":\"close\""); + verify(dispatch).invoke(stringCaptor.capture()); + assertThat(stringCaptor.getValue()).contains("\"type\":\"close\""); } @Test public void testOnCloseCallbackIsSingleShot() { - Callback dispatch = mock(Callback.class); + DispatchCallback dispatch = mock(DispatchCallback.class); CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); processor.onCheckoutCanceled(); processor.onCheckoutCanceled(); - verify(dispatch, times(1)).invoke(any(Object[].class)); + verify(dispatch, times(1)).invoke(anyString()); } @Test public void testReleaseDropsPendingDispatchCallback() { - Callback dispatch = mock(Callback.class); + DispatchCallback dispatch = mock(DispatchCallback.class); CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); processor.release(); processor.onCheckoutCanceled(); - verify(dispatch, never()).invoke(any(Object[].class)); + verify(dispatch, never()).invoke(anyString()); } @Test public void testReleaseClearsPendingGeolocationCallback() { - Callback dispatch = mock(Callback.class); + DispatchCallback dispatch = mock(DispatchCallback.class); GeolocationPermissions.Callback permissionsCallback = mock(GeolocationPermissions.Callback.class); CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); @@ -183,7 +178,7 @@ public void testReleaseClearsPendingGeolocationCallback() { @Test public void testTerminalEventClearsPendingGeolocationCallback() { - Callback dispatch = mock(Callback.class); + DispatchCallback dispatch = mock(DispatchCallback.class); GeolocationPermissions.Callback permissionsCallback = mock(GeolocationPermissions.Callback.class); CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); @@ -196,48 +191,27 @@ public void testTerminalEventClearsPendingGeolocationCallback() { @Test public void testGeolocationDispatchesEnvelopeWithOrigin() { - Callback dispatch = mock(Callback.class); + DispatchCallback dispatch = mock(DispatchCallback.class); GeolocationPermissions.Callback permissionsCallback = mock(GeolocationPermissions.Callback.class); CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); processor.onGeolocationPermissionsShowPrompt("https://shopify.com", permissionsCallback); - ArgumentCaptor args = ArgumentCaptor.forClass(Object[].class); - verify(dispatch).invoke(args.capture()); - assertThat((String) args.getValue()[0]) + verify(dispatch).invoke(stringCaptor.capture()); + assertThat(stringCaptor.getValue()) .contains("\"type\":\"geolocationRequest\"", "\"origin\":\"https://shopify.com\""); - verify(mockEventEmitter, never()).emit(eq("geolocationRequest"), any()); } @Test public void testGeolocationDispatchIsMultiShot() { - Callback dispatch = mock(Callback.class); + DispatchCallback dispatch = mock(DispatchCallback.class); GeolocationPermissions.Callback permissionsCallback = mock(GeolocationPermissions.Callback.class); CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); processor.onGeolocationPermissionsShowPrompt("https://shopify.com", permissionsCallback); processor.onGeolocationPermissionsShowPrompt("https://shopify.com", permissionsCallback); - verify(dispatch, times(2)).invoke(any(Object[].class)); - } - - @Test - public void testGeolocationWithNoDispatchCallbackDoesNotInvoke() { - GeolocationPermissions.Callback permissionsCallback = mock(GeolocationPermissions.Callback.class); - CustomCheckoutListener processor = new CustomCheckoutListener(null); - - processor.onGeolocationPermissionsShowPrompt("https://shopify.com", permissionsCallback); - - verify(mockEventEmitter, never()).emit(eq("geolocationRequest"), any()); - } - - @Test - public void testCheckoutCanceledWithNoDispatchCallbackDoesNotEmitCloseEvent() { - CustomCheckoutListener processor = new CustomCheckoutListener(null); - - processor.onCheckoutCanceled(); - - verify(mockEventEmitter, never()).emit(eq("close"), any()); + verify(dispatch, times(2)).invoke(anyString()); } /** @@ -558,7 +532,7 @@ public void testGetConfigReturnsDefaultLogLevel() { @Test public void testCanProcessCheckoutExpiredErrors() { - Callback dispatch = mock(Callback.class); + DispatchCallback dispatch = mock(DispatchCallback.class); CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); CheckoutExpiredException mockException = mock(CheckoutExpiredException.class); @@ -567,16 +541,15 @@ public void testCanProcessCheckoutExpiredErrors() { processor.onCheckoutFailed(mockException); - ArgumentCaptor args = ArgumentCaptor.forClass(Object[].class); - verify(dispatch).invoke(args.capture()); + verify(dispatch).invoke(stringCaptor.capture()); - assertThat((String) args.getValue()[0]) + assertThat(stringCaptor.getValue()) .contains("\"type\":\"fail\"", "CheckoutExpiredError", "Cart has expired", "cart_expired"); } @Test public void testCanProcessClientErrors() { - Callback dispatch = mock(Callback.class); + DispatchCallback dispatch = mock(DispatchCallback.class); CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); ClientException mockException = mock(ClientException.class); @@ -585,16 +558,15 @@ public void testCanProcessClientErrors() { processor.onCheckoutFailed(mockException); - ArgumentCaptor args = ArgumentCaptor.forClass(Object[].class); - verify(dispatch).invoke(args.capture()); + verify(dispatch).invoke(stringCaptor.capture()); - assertThat((String) args.getValue()[0]) + assertThat(stringCaptor.getValue()) .contains("\"type\":\"fail\"", "CheckoutClientError", "Customer account required", "customer_account_required"); } @Test public void testCanProcessHttpErrors() { - Callback dispatch = mock(Callback.class); + DispatchCallback dispatch = mock(DispatchCallback.class); CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); HttpException mockException = mock(HttpException.class); @@ -604,16 +576,15 @@ public void testCanProcessHttpErrors() { processor.onCheckoutFailed(mockException); - ArgumentCaptor args = ArgumentCaptor.forClass(Object[].class); - verify(dispatch).invoke(args.capture()); + verify(dispatch).invoke(stringCaptor.capture()); - assertThat((String) args.getValue()[0]) + assertThat(stringCaptor.getValue()) .contains("\"type\":\"fail\"", "CheckoutHTTPError", "Not Found", "http_error", "\"statusCode\":404"); } @Test public void testOnFailCallbackIsSingleShot() { - Callback dispatch = mock(Callback.class); + DispatchCallback dispatch = mock(DispatchCallback.class); CustomCheckoutListener processor = new CustomCheckoutListener(dispatch); CheckoutExpiredException mockException = mock(CheckoutExpiredException.class); @@ -623,18 +594,7 @@ public void testOnFailCallbackIsSingleShot() { processor.onCheckoutFailed(mockException); processor.onCheckoutFailed(mockException); - verify(dispatch, times(1)).invoke(any(Object[].class)); - } - - @Test - public void testCheckoutFailedWithNoDispatchCallbackDoesNotEmitFailEvent() { - CustomCheckoutListener processor = new CustomCheckoutListener(null); - - CheckoutExpiredException mockException = mock(CheckoutExpiredException.class); - - processor.onCheckoutFailed(mockException); - - verify(mockEventEmitter, never()).emit(eq("error"), any()); + verify(dispatch, times(1)).invoke(anyString()); } /** diff --git a/platforms/react-native/sample/ios/Podfile.lock b/platforms/react-native/sample/ios/Podfile.lock index 25fef04b..e8380678 100644 --- a/platforms/react-native/sample/ios/Podfile.lock +++ b/platforms/react-native/sample/ios/Podfile.lock @@ -2999,7 +2999,7 @@ SPEC CHECKSUMS: RNGestureHandler: eeb622199ef1fb3a076243131095df1c797072f0 RNReanimated: 237d420b7bb4378ef1dacc7d7a5c674fddb4b5d2 RNScreens: 3fc29af06302e1f1c18a7829fe57cbc2c0259912 - RNShopifyCheckoutKit: 20d4b7d715f33326ec27e73dbf0e22031d248067 + RNShopifyCheckoutKit: 78c2937cf9b9f68524daf7c303a471cc8b734895 RNVectorIcons: be4d047a76ad307ffe54732208fb0498fcb8477f ShopifyCheckoutKit: 066d577fd5fc220cd07b7027ac85c41c870533a6 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 diff --git a/platforms/react-native/sample/ios/ReactNativeTests/ShopifyCheckoutKitTests.swift b/platforms/react-native/sample/ios/ReactNativeTests/ShopifyCheckoutKitTests.swift index 69204b95..b3a1a0f3 100644 --- a/platforms/react-native/sample/ios/ReactNativeTests/ShopifyCheckoutKitTests.swift +++ b/platforms/react-native/sample/ios/ReactNativeTests/ShopifyCheckoutKitTests.swift @@ -303,17 +303,13 @@ class ShopifyCheckoutKitTests: XCTestCase { XCTAssertEqual(result?["logLevel"] as? String, "error") } - func testFailedPresentReleasesPendingDispatchCallback() { + func testFailedPresentDoesNotRetainCheckoutSheet() { let presentAttemptCompleted = expectation(description: "present attempt completed") - var dispatchCount = 0 - shopifyCheckoutKit.present("", dispatch: { _ in - dispatchCount += 1 - }) + shopifyCheckoutKit.present("", subscribedMethods: []) DispatchQueue.main.async { - self.shopifyCheckoutKit.checkoutDidCancel() - XCTAssertEqual(dispatchCount, 0) + XCTAssertNil(self.shopifyCheckoutKit.checkoutSheet) presentAttemptCompleted.fulfill() } diff --git a/platforms/react-native/sample/src/screens/CartScreen.tsx b/platforms/react-native/sample/src/screens/CartScreen.tsx index 489c8147..0910e90d 100644 --- a/platforms/react-native/sample/src/screens/CartScreen.tsx +++ b/platforms/react-native/sample/src/screens/CartScreen.tsx @@ -40,6 +40,7 @@ import { AcceleratedCheckoutButtons, ApplePayLabel, AcceleratedCheckoutWallet, + CheckoutProtocol, } from '@shopify/checkout-kit-react-native'; import {useConfig} from '../context/Config'; import useShopify from '../hooks/useShopify'; @@ -92,10 +93,23 @@ function CartScreen(): React.JSX.Element { const presentCheckout = async () => { if (checkoutURL) { - ShopifyCheckout.present(checkoutURL, { - onClose: () => sheetEventHandlers.onCancel?.(), - onFail: error => sheetEventHandlers.onFail?.(error), - }); + ShopifyCheckout.present( + checkoutURL, + { + onClose: () => sheetEventHandlers.onCancel?.(), + onFail: error => sheetEventHandlers.onFail?.(error), + }, + { + // First UCP protocol event surfaced end-to-end. Logging only — + // once we're satisfied the relay is wired correctly we'll route + // protocol events through `useShopifyEventHandlers` (or an + // equivalent) just like the SDK lifecycle ones above. + [CheckoutProtocol.start]: checkout => { + // eslint-disable-next-line no-console + console.log('[Cart - Protocol.ec.start]', checkout); + }, + }, + ); } }; diff --git a/platforms/react-native/scripts/copy_license b/platforms/react-native/scripts/copy_license index af75710e..16462164 100755 --- a/platforms/react-native/scripts/copy_license +++ b/platforms/react-native/scripts/copy_license @@ -90,9 +90,16 @@ end def copy_license(dir, license_block, normalized_license, check_only: false) modified = [] supported_exts = %w[.swift .h .mm .java .js .ts .tsx] + # File basenames that must NOT receive a prepended license block because + # their first line is a toolchain directive that has to remain on line 1. + # `Package.swift` for example requires `// swift-tools-version:` as the + # very first line — prepending a `/* MIT License */` block above it + # makes SwiftPM unable to parse the manifest. + skip_basenames = %w[Package.swift] Find.find(dir) do |path| next unless File.file?(path) && supported_exts.any? { |ext| path.end_with?(ext) } + next if skip_basenames.include?(File.basename(path)) changed = process_file(path, license_block, normalized_license, write: !check_only) if changed diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift index e6fcc6d4..6f8ba13b 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutWebViewController.swift @@ -170,16 +170,20 @@ class CheckoutWebViewController: UIViewController, UIAdaptivePresentationControl } @IBAction func close() { - didCancel() + didCancel(dismiss: true) } public func presentationControllerDidDismiss(_: UIPresentationController) { - didCancel() + didCancel(dismiss: false) } - private func didCancel() { + private func didCancel(dismiss shouldDismiss: Bool) { onCancel?() delegate?.checkoutDidCancel() + + if shouldDismiss { + dismiss(animated: true) + } } } diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewControllerTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewControllerTests.swift index d78deebc..ce611516 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewControllerTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewControllerTests.swift @@ -78,12 +78,24 @@ class CheckoutWebViewControllerTests: XCTestCase { XCTAssertEqual(delegate.didFailErrors.count, 1) } - func test_presentationControllerDidDismiss_invokesDelegateCancel() { + func test_close_dismissesCheckoutAndInvokesDelegateCancel() { + let delegate = MockCheckoutDelegate() + let viewController = TestableCheckoutWebViewController(checkoutURL: url, delegate: delegate, entryPoint: nil) + + viewController.close() + + XCTAssertEqual(delegate.didCancelCount, 1) + XCTAssertTrue(viewController.dismissCalled) + XCTAssertTrue(viewController.dismissAnimated) + } + + func test_presentationControllerDidDismiss_invokesDelegateCancelWithoutDismissingAgain() { let delegate = MockCheckoutDelegate() let viewController = TestableCheckoutWebViewController(checkoutURL: url, delegate: delegate, entryPoint: nil) viewController.presentationControllerDidDismiss(UIPresentationController(presentedViewController: viewController, presenting: nil)) XCTAssertEqual(delegate.didCancelCount, 1) + XCTAssertFalse(viewController.dismissCalled) } } diff --git a/protocol/languages/typescript/package.json b/protocol/languages/typescript/package.json index 35c5eab6..0222575f 100644 --- a/protocol/languages/typescript/package.json +++ b/protocol/languages/typescript/package.json @@ -4,7 +4,7 @@ "private": true, "license": "MIT", "description": "Generated TypeScript models for the Shopify Checkout Kit protocol.", - "types": "src/index.ts", + "types": "src/index.d.ts", "scripts": { "codegen": "../../scripts/generate_models.sh --lang typescript", "typecheck": "tsc --noEmit" diff --git a/protocol/languages/typescript/src/generated/Models.d.ts b/protocol/languages/typescript/src/generated/Models.d.ts new file mode 100644 index 00000000..2bb695ac --- /dev/null +++ b/protocol/languages/typescript/src/generated/Models.d.ts @@ -0,0 +1,2917 @@ +/* +MIT License + +Copyright 2023 - Present, Shopify Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ + +/** + * Unit price in ISO 4217 minor units. + * + * Monetary amount in the currency's minor unit as defined by ISO 4217. Refer to the + * currency's exponent to determine minor-to-major ratio (e.g., 2 for USD, 0 for JPY, 3 for + * KWD). + */ +export type Amount = number; + +/** + * Error code identifying the type of error. Standard errors are defined in specification + * (see examples), and have standardized semantics; freeform codes are permitted. + */ +export type ErrorCode = string; + +/** + * Reverse-domain identifier used for collision-safe namespacing of capabilities, services, + * handlers, eligibility claims, and extension-contributed keys. Must contain at least two + * dot-separated segments (e.g., 'dev.ucp.shopping.checkout', 'com.example.loyalty_gold'). + */ +export type ReverseDomainName = string; + +/** + * Monetary amount in the currency's minor unit as defined by ISO 4217. Refer to the + * currency's exponent to determine minor-to-major ratio (e.g., 2 for USD, 0 for JPY, 3 for + * KWD). May be negative — the sign is intrinsic to the value (e.g., discounts are negative, + * charges are positive). + */ +export type SignedAmount = number; + +/** + * Base checkout schema. Extensions compose onto this using allOf. + */ +export interface Checkout { + /** + * Representation of the buyer. + */ + buyer?: BuyerObject; + context?: ContextObject; + /** + * URL for checkout handoff and session recovery. MUST be provided when status is + * requires_escalation. See specification for format and availability requirements. + */ + continueUrl?: string; + /** + * ISO 4217 currency code reflecting the merchant's market determination. Derived from + * address, context, and geo IP—buyers provide signals, merchants determine currency. + */ + currency: string; + /** + * RFC 3339 expiry timestamp. Default TTL is 6 hours from creation if not sent. + */ + expiresAt?: string; + /** + * Unique identifier of the checkout session. + */ + id: string; + /** + * List of line items being checked out. + */ + lineItems: CheckoutLineItem[]; + /** + * Links to be displayed by the platform (Privacy Policy, TOS). Mandatory for legal + * compliance. + */ + links: LinkElement[]; + /** + * List of messages with error and info about the checkout session state. + */ + messages?: MessageElement[]; + /** + * Details about an order created for this checkout session. + */ + order?: OrderObject; + payment?: PaymentObject; + signals?: SignalsObject; + /** + * Checkout state indicating the current phase and required action. See Checkout Status + * lifecycle documentation for state transition details. + */ + status: CheckoutStatus; + /** + * Different cart totals. + */ + totals: CheckoutTotal[]; + ucp: UcpCheckoutResponseSchema; + [property: string]: any; +} + +/** + * Representation of the buyer. + */ +export interface BuyerObject { + /** + * Email of the buyer. + */ + email?: string; + /** + * First name of the buyer. + */ + firstName?: string; + /** + * Last name of the buyer. + */ + lastName?: string; + /** + * E.164 standard. + */ + phoneNumber?: string; + [property: string]: any; +} + +/** + * Provisional buyer signals for relevance and localization—not authoritative data. + * Businesses SHOULD use these values when verified inputs (e.g., shipping address) are + * absent, and MAY ignore or down-rank them if inconsistent with higher-confidence signals + * (authenticated account, risk detection) or regulatory constraints (export controls). + * Eligibility and policy enforcement MUST occur at checkout time using binding transaction + * data. Context SHOULD be non-identifying and can be disclosed progressively—coarse signals + * early, finer resolution as the session progresses. Higher-resolution data (shipping + * address, billing address) supersedes context. + */ +export interface ContextObject { + /** + * The country. Recommended to be in 2-letter ISO 3166-1 alpha-2 format, for example "US". + * For backward compatibility, a 3-letter ISO 3166-1 alpha-3 country code such as "SGP" or a + * full country name such as "Singapore" can also be used. Optional hint for market context + * (currency, availability, pricing)—higher-resolution data (e.g., shipping address) + * supersedes this value. + */ + addressCountry?: string; + /** + * The region in which the locality is, and which is in the country. For example, California + * or another appropriate first-level Administrative division. Optional hint for progressive + * localization—higher-resolution data (e.g., shipping address) supersedes this value. + */ + addressRegion?: string; + /** + * Preferred currency (ISO 4217, e.g., 'EUR', 'USD'). Businesses determine presentment + * currency from context and authoritative signals; this hint MAY inform selection in + * multi-currency markets. Also serves as the denomination for price filter values — + * platforms SHOULD include this field when sending price filters. Response prices include + * explicit currency confirming the resolution. + */ + currency?: string; + /** + * Buyer claims about eligible benefits such as loyalty membership, payment instrument + * perks, and similar. Recognized claims MAY inform the Business response (e.g., member-only + * product availability, adjusted pricing in catalog, provisional discounts at cart or + * checkout). Businesses MUST ignore unrecognized values without error. Values MUST use + * reverse-domain naming (e.g., 'com.example.loyalty_gold', 'org.school.student') and MUST + * be non-identifying. + */ + eligibility?: string[]; + /** + * Background context describing buyer's intent (e.g., 'looking for a gift under $50', 'need + * something durable for outdoor use'). Informs relevance, recommendations, and + * personalization. + */ + intent?: string; + /** + * Preferred language for content. Use IETF BCP 47 language tags (e.g., 'en', 'fr-CA', + * 'zh-Hans'). For REST, equivalent to Accept-Language header—platforms SHOULD fall back to + * Accept-Language when this field is absent; when provided, overrides Accept-Language. + * Businesses MAY return content in a different language if unavailable. + */ + language?: string; + /** + * The postal code. For example, 94043. Optional hint for regional + * refinement—higher-resolution data (e.g., shipping address) supersedes this value. + */ + postalCode?: string; + [property: string]: any; +} + +/** + * Line item object. Expected to use the currency of the parent object. + */ +export interface CheckoutLineItem { + id: string; + item: ItemObject; + /** + * Parent line item identifier for any nested structures. + */ + parentId?: string; + /** + * Quantity of the item being purchased. + */ + quantity: number; + /** + * Line item totals breakdown. + */ + totals: LineItemTotal[]; + [property: string]: any; +} + +/** + * Product data (id, title, price, image_url). + */ +export interface ItemObject { + /** + * The product identifier, often the SKU, required to resolve the product details associated + * with this line item. Should be recognized by both the Platform, and the Business. + */ + id: string; + /** + * Product image URI. + */ + imageUrl?: string; + /** + * Unit price in ISO 4217 minor units. + */ + price: number; + /** + * Product title. + */ + title: string; + [property: string]: any; +} + +/** + * A cost breakdown entry with a category, amount, and optional display text. + */ +export interface LineItemTotal { + amount: number; + /** + * Text to display against the amount. Should reflect appropriate method (e.g., 'Shipping', + * 'Delivery'). + */ + displayText?: string; + /** + * Cost category. Well-known values: subtotal, items_discount, discount, fulfillment, tax, + * fee, total. Businesses MAY use additional values. + */ + type: string; + [property: string]: any; +} + +export interface LinkElement { + /** + * Optional display text for the link. When provided, use this instead of generating from + * type. + */ + title?: string; + /** + * Type of link. Well-known values: `privacy_policy`, `terms_of_service`, `refund_policy`, + * `shipping_policy`, `faq`. Consumers SHOULD handle unknown values gracefully by displaying + * them using the `title` field or omitting the link. + */ + type: string; + /** + * The actual URL pointing to the content to be displayed. + */ + url: string; + [property: string]: any; +} + +/** + * Container for error, warning, or info messages. + */ +export interface MessageElement { + /** + * Warning code. Machine-readable identifier for the warning type (e.g., final_sale, prop65, + * fulfillment_changed, age_restricted, etc.). + * + * Info code for programmatic handling. + */ + code?: string; + /** + * Human-readable message. + * + * Human-readable warning message that MUST be displayed. + */ + content: string; + /** + * Content format, default = plain. + */ + contentType?: ContentType; + /** + * RFC 9535 JSONPath to the component the message refers to (e.g., $.items[1]). + * + * JSONPath (RFC 9535) to related field (e.g., $.line_items[0]). + * + * RFC 9535 JSONPath to the component the message refers to. + */ + path?: string; + /** + * Reflects the resource state and recommended action. 'recoverable': platform can resolve + * by modifying inputs and retrying via API. 'requires_buyer_input': merchant requires + * information their API doesn't support collecting programmatically (checkout incomplete). + * 'requires_buyer_review': buyer must authorize before order placement due to policy, + * regulatory, or entitlement rules. 'unrecoverable': no valid resource exists to act on, + * retry with new resource or inputs. Errors with 'requires_*' severity contribute to + * 'status: requires_escalation'. + */ + severity?: Severity; + /** + * Message type discriminator. + */ + type: MessageType; + /** + * URL to a required visual element (e.g., warning symbol, energy class label). + */ + imageUrl?: string; + /** + * Rendering contract for this warning. 'notice' (default): platform MUST display, MAY + * dismiss. 'disclosure': platform MUST display in proximity to the path-referenced + * component, MUST NOT hide or auto-dismiss. See specification for full contract. + */ + presentation?: string; + /** + * Reference URL for more information (e.g., regulatory site, registry entry, policy page). + */ + url?: string; + [property: string]: any; +} + +/** + * Content format, default = plain. + */ +export type ContentType = "plain" | "markdown"; + +/** + * Reflects the resource state and recommended action. 'recoverable': platform can resolve + * by modifying inputs and retrying via API. 'requires_buyer_input': merchant requires + * information their API doesn't support collecting programmatically (checkout incomplete). + * 'requires_buyer_review': buyer must authorize before order placement due to policy, + * regulatory, or entitlement rules. 'unrecoverable': no valid resource exists to act on, + * retry with new resource or inputs. Errors with 'requires_*' severity contribute to + * 'status: requires_escalation'. + */ +export type Severity = "recoverable" | "requires_buyer_input" | "requires_buyer_review" | "unrecoverable"; + +export type MessageType = "error" | "warning" | "info"; + +/** + * Details about an order created for this checkout session. + * + * Order details available at the time of checkout completion. + */ +export interface OrderObject { + /** + * Unique order identifier. + */ + id: string; + /** + * Human-readable label for identifying the order. MUST only be provided by the business. + */ + label?: string; + /** + * Permalink to access the order on merchant site. + */ + permalinkUrl: string; + [property: string]: any; +} + +/** + * Payment configuration containing handlers. + */ +export interface PaymentObject { + /** + * The payment instruments available for this payment. Each instrument is associated with a + * specific handler via the handler_id field. Handlers can extend the base + * payment_instrument schema to add handler-specific fields. + */ + instruments?: PaymentSelectedPaymentInstrument[]; + [property: string]: any; +} + +/** + * A payment instrument with selection state. + * + * The base definition for any payment instrument. It links the instrument to a specific + * payment handler. + */ +export interface PaymentSelectedPaymentInstrument { + /** + * The billing address associated with this payment method. + */ + billingAddress?: BillingAddressObject; + credential?: CredentialObject; + /** + * Display information for this payment instrument. Each payment instrument schema defines + * its specific display properties, as outlined by the payment handler. + */ + display?: { [key: string]: any }; + /** + * The unique identifier for the handler instance that produced this instrument. This + * corresponds to the 'id' field in the Payment Handler definition. + */ + handlerId: string; + /** + * A unique identifier for this instrument instance, assigned by the platform. + */ + id: string; + /** + * The broad category of the instrument (e.g., 'card', 'tokenized_card'). Specific schemas + * will constrain this to a constant value. + */ + type: string; + /** + * Whether this instrument is selected by the user. + */ + selected?: boolean; + [property: string]: any; +} + +/** + * The billing address associated with this payment method. + * + * Delivery destination address. + * + * Physical address of the location. + */ +export interface BillingAddressObject { + /** + * The country. Recommended to be in 2-letter ISO 3166-1 alpha-2 format, for example "US". + * For backward compatibility, a 3-letter ISO 3166-1 alpha-3 country code such as "SGP" or a + * full country name such as "Singapore" can also be used. + */ + addressCountry?: string; + /** + * The locality in which the street address is, and which is in the region. For example, + * Mountain View. + */ + addressLocality?: string; + /** + * The region in which the locality is, and which is in the country. Required for applicable + * countries (i.e. state in US, province in CA). For example, California or another + * appropriate first-level Administrative division. + */ + addressRegion?: string; + /** + * An address extension such as an apartment number, C/O or alternative name. + */ + extendedAddress?: string; + /** + * Optional. First name of the contact associated with the address. + */ + firstName?: string; + /** + * Optional. Last name of the contact associated with the address. + */ + lastName?: string; + /** + * Optional. Phone number of the contact associated with the address. + */ + phoneNumber?: string; + /** + * The postal code. For example, 94043. + */ + postalCode?: string; + /** + * The street address. + */ + streetAddress?: string; + [property: string]: any; +} + +/** + * The base definition for any payment credential. Handlers define specific credential types. + */ +export interface CredentialObject { + /** + * The credential type discriminator. Specific schemas will constrain this to a constant + * value. + */ + type: string; + [property: string]: any; +} + +/** + * Environment data provided by the platform to support authorization and abuse prevention. + * Values MUST NOT be buyer-asserted claims — platforms provide signals based on direct + * observation or independently verifiable third-party attestations. All signal keys MUST + * use reverse-domain naming to ensure provenance and prevent collisions when multiple + * extensions contribute to the shared namespace. + */ +export interface SignalsObject { + /** + * Client's IP address (IPv4 or IPv6). + */ + devUcpBuyerIp?: string; + /** + * Client's HTTP User-Agent header or equivalent. + */ + devUcpUserAgent?: string; + [property: string]: any; +} + +/** + * Checkout state indicating the current phase and required action. See Checkout Status + * lifecycle documentation for state transition details. + */ +export type CheckoutStatus = "incomplete" | "requires_escalation" | "ready_for_complete" | "complete_in_progress" | "completed" | "canceled"; + +/** + * Different cart totals. + * + * Pricing breakdown provided by the business. MUST contain exactly one subtotal and one + * total entry. Detail types (tax, fee, discount, fulfillment) may appear multiple times for + * itemization. Platforms MUST render all entries in order using display_text and amount. + * + * A cost breakdown entry with a category, amount, and optional display text. + */ +export interface CheckoutTotal { + amount: number; + /** + * Text to display against the amount. Should reflect appropriate method (e.g., 'Shipping', + * 'Delivery'). + */ + displayText?: string; + /** + * Cost category. Well-known values: subtotal, items_discount, discount, fulfillment, tax, + * fee, total. Businesses MAY use additional values. + */ + type: string; + /** + * Optional itemized breakdown. The parent entry is always rendered; lines are + * supplementary. Sum of line amounts MUST equal the parent entry amount. + */ + lines?: TotalLine[]; + [property: string]: any; +} + +/** + * Sub-line entry. Additional metadata MAY be included. + */ +export interface TotalLine { + amount: number; + /** + * Human-readable label for this sub-line. + */ + displayText: string; + [property: string]: any; +} + +/** + * UCP metadata for checkout responses. + * + * Base UCP metadata with shared properties for all schema types. + */ +export interface UcpCheckoutResponseSchema { + /** + * Capability registry keyed by reverse-domain name. + */ + capabilities?: { [key: string]: CapabilityResponseSchema[] }; + /** + * Payment handler registry keyed by reverse-domain name. + */ + paymentHandlers: { [key: string]: PaymentHandlerResponseSchema[] }; + /** + * Service registry keyed by reverse-domain name. + */ + services?: { [key: string]: ServiceResponseSchema[] }; + /** + * Application-level status of the UCP operation. + */ + status?: UcpCheckoutResponseSchemaStatus; + version: string; + [property: string]: any; +} + +/** + * Capability reference in responses. Only name/version required to confirm active + * capabilities. + * + * Shared foundation for all UCP entities. + */ +export interface CapabilityResponseSchema { + /** + * Entity-specific configuration. Structure defined by each entity's schema. + */ + config?: { [key: string]: any }; + /** + * Unique identifier for this entity instance. Used to disambiguate when multiple instances + * exist. + */ + id?: string; + /** + * URL to JSON Schema defining this entity's structure and payloads. + */ + schema?: string; + /** + * URL to human-readable specification document. + */ + spec?: string; + /** + * Entity version in YYYY-MM-DD format. + */ + version: string; + /** + * Parent capability(s) this extends. Present for extensions, absent for root capabilities. + * Use array for multi-parent extensions. + */ + extends?: string[] | string; + [property: string]: any; +} + +/** + * Handler reference in responses. May include full config state for runtime usage of the + * handler. + * + * Shared foundation for all UCP entities. + */ +export interface PaymentHandlerResponseSchema { + /** + * Entity-specific configuration. Structure defined by each entity's schema. + */ + config?: { [key: string]: any }; + /** + * Unique identifier for this entity instance. Used to disambiguate when multiple instances + * exist. + */ + id: string; + /** + * URL to JSON Schema defining this entity's structure and payloads. + */ + schema?: string; + /** + * URL to human-readable specification document. + */ + spec?: string; + /** + * Entity version in YYYY-MM-DD format. + */ + version: string; + /** + * Instrument types this handler supports, with optional constraints. When absent, every + * instrument should be considered available. + */ + availableInstruments?: PaymentHandlerResponseSchemaAvailableInstrument[]; + [property: string]: any; +} + +/** + * An instrument type available from a payment handler with optional constraints. + */ +export interface PaymentHandlerResponseSchemaAvailableInstrument { + /** + * Constraints on this instrument type. Structure depends on instrument type and active + * capabilities. + */ + constraints?: { [key: string]: any }; + /** + * The instrument type identifier (e.g., 'card', 'gift_card'). References an instrument + * schema's type constant. + */ + type: string; + [property: string]: any; +} + +/** + * Service binding in API responses. Includes per-resource transport configuration via typed + * config. + * + * Shared foundation for all UCP entities. + */ +export interface ServiceResponseSchema { + /** + * Entity-specific configuration. Structure defined by each entity's schema. + */ + config?: EmbeddedTransportConfig; + /** + * Unique identifier for this entity instance. Used to disambiguate when multiple instances + * exist. + */ + id?: string; + /** + * URL to JSON Schema defining this entity's structure and payloads. + */ + schema?: string; + /** + * URL to human-readable specification document. + */ + spec?: string; + /** + * Entity version in YYYY-MM-DD format. + */ + version: string; + /** + * Endpoint URL for this transport binding. + */ + endpoint?: string; + /** + * Transport protocol for this service binding. + */ + transport: Transport; + [property: string]: any; +} + +/** + * Entity-specific configuration. Structure defined by each entity's schema. + * + * Per-session configuration for embedded transport binding. Allows businesses to vary EP + * availability and delegations based on cart contents, agent authorization, or policy. + */ +export interface EmbeddedTransportConfig { + /** + * Color schemes the business supports. Hosts use ec_color_scheme query parameter to request + * a scheme from this list. + */ + colorScheme?: EmbeddedColorScheme[]; + /** + * Delegations the business allows. At service-level, declares available delegations. In UCP + * responses, confirms accepted delegations for this session. + */ + delegate?: string[]; + [property: string]: any; +} + +export type EmbeddedColorScheme = "light" | "dark"; + +/** + * Transport protocol for this service binding. + */ +export type Transport = "rest" | "mcp" | "a2a" | "embedded"; + +/** + * Application-level status of the UCP operation. + */ +export type UcpCheckoutResponseSchemaStatus = "success" | "error"; + +/** + * Non-sensitive backend identifiers for linking. + */ +export interface PaymentAccountInfo { + /** + * EMVCo PAR. A unique identifier linking a payment card to a specific account, enabling + * tracking across tokens (Apple Pay, physical card, etc). + */ + paymentAccountReference?: string; + [property: string]: any; +} + +/** + * Post-order event that exists independently of fulfillment. Typically represents money + * movements but can be any post-order change. Polymorphic type that can optionally + * reference line items. + */ +export interface Adjustment { + /** + * Human-readable reason or description (e.g., 'Defective item', 'Customer requested'). + */ + description?: string; + /** + * Adjustment event identifier. + */ + id: string; + /** + * Which line items and quantities are affected (optional). + */ + lineItems?: AdjustmentLineItem[]; + /** + * RFC 3339 timestamp when this adjustment occurred. + */ + occurredAt: string; + /** + * Adjustment status. + */ + status: AdjustmentStatus; + /** + * Adjustment totals breakdown. Signed values - negative for money returned to buyer + * (refunds, credits), positive for additional charges (exchanges). + */ + totals?: LineItemTotal[]; + /** + * Type of adjustment (open string). Typically money-related like: refund, return, credit, + * price_adjustment, dispute, cancellation. Can be any value that makes sense for the + * merchant's business. + */ + type: string; + [property: string]: any; +} + +export interface AdjustmentLineItem { + /** + * Line item ID reference. + */ + id: string; + /** + * Signed quantity affected by this adjustment. Negative values represent reductions (e.g. + * returns); positive values represent additions (e.g. exchanges). + */ + quantity: number; + [property: string]: any; +} + +/** + * Adjustment status. + */ +export type AdjustmentStatus = "pending" | "completed" | "failed"; + +/** + * An instrument type available from a payment handler with optional constraints. + */ +export interface AvailablePaymentInstrument { + /** + * Constraints on this instrument type. Structure depends on instrument type and active + * capabilities. + */ + constraints?: { [key: string]: any }; + /** + * The instrument type identifier (e.g., 'card', 'gift_card'). References an instrument + * schema's type constant. + */ + type: string; + [property: string]: any; +} + +/** + * Binds a token to a specific checkout session and participant. Prevents token reuse across + * different checkouts or participants. + */ +export interface TokenBinding { + /** + * The checkout session identifier this token is bound to. + */ + checkoutId: string; + /** + * The participant this token is bound to. Required when acting on behalf of another + * participant (e.g., agent tokenizing for merchant). Omit when the authenticated caller is + * the binding target. + */ + identity?: IdentityObject; + [property: string]: any; +} + +/** + * The participant this token is bound to. Required when acting on behalf of another + * participant (e.g., agent tokenizing for merchant). Omit when the authenticated caller is + * the binding target. + * + * Identity of a participant for token binding. The access_token uniquely identifies the + * participant who tokens should be bound to. + */ +export interface IdentityObject { + /** + * Unique identifier for this participant, obtained during onboarding with the tokenizer. + */ + accessToken: string; + [property: string]: any; +} + +/** + * Business's fulfillment configuration. + */ +export interface BusinessFulfillmentConfig { + /** + * Allowed method type combinations. + */ + allowsMethodCombinations?: Array; + /** + * Permits multiple destinations per method type. + */ + allowsMultiDestination?: BusinessFulfillmentConfigAllowsMultiDestination; + [property: string]: any; +} + +/** + * Fulfillment method type this availability applies to. + * + * Fulfillment method type. + */ +export type TypeElement = "shipping" | "pickup"; + +/** + * Permits multiple destinations per method type. + */ +export interface BusinessFulfillmentConfigAllowsMultiDestination { + /** + * Multiple pickup locations allowed. + */ + pickup?: boolean; + /** + * Multiple shipping destinations allowed. + */ + shipping?: boolean; +} + +export interface Buyer { + /** + * Email of the buyer. + */ + email?: string; + /** + * First name of the buyer. + */ + firstName?: string; + /** + * Last name of the buyer. + */ + lastName?: string; + /** + * E.164 standard. + */ + phoneNumber?: string; + [property: string]: any; +} + +/** + * A card credential containing sensitive payment card details including raw Primary Account + * Numbers (PANs). This credential type MUST NOT be used for checkout, only with payment + * handlers that tokenize or encrypt credentials. CRITICAL: Both parties handling + * CardCredential (sender and receiver) MUST be PCI DSS compliant. Transmission MUST use + * HTTPS/TLS with strong cipher suites. + * + * The base definition for any payment credential. Handlers define specific credential types. + */ +export interface CardCredential { + /** + * The credential type discriminator. Specific schemas will constrain this to a constant + * value. + * + * The credential type identifier for card credentials. + */ + type: TypeEnum; + /** + * The type of card number. Network tokens are preferred with fallback to FPAN. See PCI + * Scope for more details. + */ + cardNumberType: CardNumberType; + /** + * Cryptogram provided with network tokens. + */ + cryptogram?: string; + /** + * Card CVC number. + */ + cvc?: string; + /** + * Electronic Commerce Indicator / Security Level Indicator provided with network tokens. + */ + eciValue?: string; + /** + * The month of the card's expiration date (1-12). + */ + expiryMonth?: number; + /** + * The year of the card's expiration date. + */ + expiryYear?: number; + /** + * Cardholder name. + */ + name?: string; + /** + * Card number. + */ + number?: string; + [property: string]: any; +} + +/** + * The type of card number. Network tokens are preferred with fallback to FPAN. See PCI + * Scope for more details. + */ +export type CardNumberType = "fpan" | "network_token" | "dpan"; + +/** + * Error code identifying the type of error. Standard errors are defined in specification + * (see examples), and have standardized semantics; freeform codes are permitted. + */ +export type TypeEnum = "card"; + +/** + * A basic card payment instrument with visible card details. Can be inherited by a + * handler's instrument schema to define handler-specific display details or more complex + * credential structures. + * + * The base definition for any payment instrument. It links the instrument to a specific + * payment handler. + */ +export interface CardPaymentInstrument { + /** + * The billing address associated with this payment method. + */ + billingAddress?: BillingAddressObject; + credential?: CredentialObject; + /** + * Display information for this payment instrument. Each payment instrument schema defines + * its specific display properties, as outlined by the payment handler. + * + * Display information for this card payment instrument. + */ + display?: Display; + /** + * The unique identifier for the handler instance that produced this instrument. This + * corresponds to the 'id' field in the Payment Handler definition. + */ + handlerId: string; + /** + * A unique identifier for this instrument instance, assigned by the platform. + */ + id: string; + /** + * The broad category of the instrument (e.g., 'card', 'tokenized_card'). Specific schemas + * will constrain this to a constant value. + * + * Indicates this is a card payment instrument. + */ + type: TypeEnum; + [property: string]: any; +} + +/** + * Display information for this payment instrument. Each payment instrument schema defines + * its specific display properties, as outlined by the payment handler. + * + * Display information for this card payment instrument. + */ +export interface Display { + /** + * The card brand/network (e.g., visa, mastercard, amex). + */ + brand?: string; + /** + * An optional URI to a rich image representing the card (e.g., card art provided by the + * issuer). + */ + cardArt?: string; + /** + * An optional rich text description of the card to display to the user (e.g., 'Visa ending + * in 1234, expires 12/2025'). + */ + description?: string; + /** + * The month of the card's expiration date (1-12). + */ + expiryMonth?: number; + /** + * The year of the card's expiration date. + */ + expiryYear?: number; + /** + * Last 4 digits of the card number. + */ + lastDigits?: string; + [property: string]: any; +} + +/** + * Provisional buyer signals for relevance and localization—not authoritative data. + * Businesses SHOULD use these values when verified inputs (e.g., shipping address) are + * absent, and MAY ignore or down-rank them if inconsistent with higher-confidence signals + * (authenticated account, risk detection) or regulatory constraints (export controls). + * Eligibility and policy enforcement MUST occur at checkout time using binding transaction + * data. Context SHOULD be non-identifying and can be disclosed progressively—coarse signals + * early, finer resolution as the session progresses. Higher-resolution data (shipping + * address, billing address) supersedes context. + */ +export interface Context { + /** + * The country. Recommended to be in 2-letter ISO 3166-1 alpha-2 format, for example "US". + * For backward compatibility, a 3-letter ISO 3166-1 alpha-3 country code such as "SGP" or a + * full country name such as "Singapore" can also be used. Optional hint for market context + * (currency, availability, pricing)—higher-resolution data (e.g., shipping address) + * supersedes this value. + */ + addressCountry?: string; + /** + * The region in which the locality is, and which is in the country. For example, California + * or another appropriate first-level Administrative division. Optional hint for progressive + * localization—higher-resolution data (e.g., shipping address) supersedes this value. + */ + addressRegion?: string; + /** + * Preferred currency (ISO 4217, e.g., 'EUR', 'USD'). Businesses determine presentment + * currency from context and authoritative signals; this hint MAY inform selection in + * multi-currency markets. Also serves as the denomination for price filter values — + * platforms SHOULD include this field when sending price filters. Response prices include + * explicit currency confirming the resolution. + */ + currency?: string; + /** + * Buyer claims about eligible benefits such as loyalty membership, payment instrument + * perks, and similar. Recognized claims MAY inform the Business response (e.g., member-only + * product availability, adjusted pricing in catalog, provisional discounts at cart or + * checkout). Businesses MUST ignore unrecognized values without error. Values MUST use + * reverse-domain naming (e.g., 'com.example.loyalty_gold', 'org.school.student') and MUST + * be non-identifying. + */ + eligibility?: string[]; + /** + * Background context describing buyer's intent (e.g., 'looking for a gift under $50', 'need + * something durable for outdoor use'). Informs relevance, recommendations, and + * personalization. + */ + intent?: string; + /** + * Preferred language for content. Use IETF BCP 47 language tags (e.g., 'en', 'fr-CA', + * 'zh-Hans'). For REST, equivalent to Accept-Language header—platforms SHOULD fall back to + * Accept-Language when this field is absent; when provided, overrides Accept-Language. + * Businesses MAY return content in a different language if unavailable. + */ + language?: string; + /** + * The postal code. For example, 94043. Optional hint for regional + * refinement—higher-resolution data (e.g., shipping address) supersedes this value. + */ + postalCode?: string; + [property: string]: any; +} + +/** + * Generic error response when business logic prevents resource creation or failed to + * retrieve resource. Used when no valid resource can be established. + */ +export interface ErrorResponse { + /** + * URL for buyer handoff or session recovery. + */ + continueUrl?: string; + /** + * Array of messages describing why the operation failed. + */ + messages: MessageElement[]; + /** + * UCP protocol metadata. Status MUST be 'error' for error response. + */ + ucp: ErrorResponseUcp; +} + +/** + * UCP protocol metadata. Status MUST be 'error' for error response. + * + * UCP metadata with status 'error'. Use for response branches that carry error + * information. + * + * Base UCP metadata with shared properties for all schema types. + */ +export interface ErrorResponseUcp { + /** + * Capability registry keyed by reverse-domain name. + */ + capabilities?: { [key: string]: CapabilityResponseSchema[] }; + /** + * Payment handler registry keyed by reverse-domain name. + */ + paymentHandlers?: { [key: string]: PaymentHandlerResponseSchema[] }; + /** + * Service registry keyed by reverse-domain name. + */ + services?: { [key: string]: UcpOrderResponseSchemaService[] }; + /** + * Application-level status of the UCP operation. + */ + status: StatusEnum; + version: string; + [property: string]: any; +} + +/** + * Shared foundation for all UCP entities. + */ +export interface UcpOrderResponseSchemaService { + /** + * Entity-specific configuration. Structure defined by each entity's schema. + */ + config?: { [key: string]: any }; + /** + * Unique identifier for this entity instance. Used to disambiguate when multiple instances + * exist. + */ + id?: string; + /** + * URL to JSON Schema defining this entity's structure and payloads. + */ + schema?: string; + /** + * URL to human-readable specification document. + */ + spec?: string; + /** + * Entity version in YYYY-MM-DD format. + */ + version: string; + /** + * Endpoint URL for this transport binding. + */ + endpoint?: string; + /** + * Transport protocol for this service binding. + */ + transport: Transport; + [property: string]: any; +} + +/** + * Application-level status of the UCP operation. + */ +export type StatusEnum = "error"; + +/** + * Buyer-facing fulfillment expectation representing logical groupings of items (e.g., + * 'package'). Can be split, merged, or adjusted post-order to set buyer expectations for + * when/how items arrive. + */ +export interface Expectation { + /** + * Human-readable delivery description (e.g., 'Arrives in 5-8 business days'). + */ + description?: string; + /** + * Delivery destination address. + */ + destination: BillingAddressObject; + /** + * When this expectation can be fulfilled: 'now' or ISO 8601 timestamp for future date + * (backorder, pre-order). + */ + fulfillableOn?: string; + /** + * Expectation identifier. + */ + id: string; + /** + * Which line items and quantities are in this expectation. + */ + lineItems: ExpectationLineItem[]; + /** + * Delivery method type (shipping, pickup, digital). + */ + methodType: MethodType; + [property: string]: any; +} + +export interface ExpectationLineItem { + /** + * Line item ID reference. + */ + id: string; + /** + * Quantity of this item in this expectation. + */ + quantity: number; + [property: string]: any; +} + +/** + * Delivery method type (shipping, pickup, digital). + */ +export type MethodType = "shipping" | "pickup" | "digital"; + +/** + * Inventory availability hint for a fulfillment method type. + */ +export interface FulfillmentAvailableMethod { + /** + * Human-readable availability info (e.g., 'Available for pickup at Downtown Store today'). + */ + description?: string; + /** + * 'now' for immediate availability, or ISO 8601 date for future (preorders, transfers). + */ + fulfillableOn?: null | string; + /** + * Line items available for this fulfillment method. + */ + lineItemIds: string[]; + /** + * Fulfillment method type this availability applies to. + */ + type: TypeElement; + [property: string]: any; +} + +/** + * A destination for fulfillment. + * + * Shipping destination. + * + * The billing address associated with this payment method. + * + * Delivery destination address. + * + * Physical address of the location. + * + * A pickup location (retail store, locker, etc.). + */ +export interface FulfillmentDestination { + /** + * The country. Recommended to be in 2-letter ISO 3166-1 alpha-2 format, for example "US". + * For backward compatibility, a 3-letter ISO 3166-1 alpha-3 country code such as "SGP" or a + * full country name such as "Singapore" can also be used. + */ + addressCountry?: string; + /** + * The locality in which the street address is, and which is in the region. For example, + * Mountain View. + */ + addressLocality?: string; + /** + * The region in which the locality is, and which is in the country. Required for applicable + * countries (i.e. state in US, province in CA). For example, California or another + * appropriate first-level Administrative division. + */ + addressRegion?: string; + /** + * An address extension such as an apartment number, C/O or alternative name. + */ + extendedAddress?: string; + /** + * Optional. First name of the contact associated with the address. + */ + firstName?: string; + /** + * Optional. Last name of the contact associated with the address. + */ + lastName?: string; + /** + * Optional. Phone number of the contact associated with the address. + */ + phoneNumber?: string; + /** + * The postal code. For example, 94043. + */ + postalCode?: string; + /** + * The street address. + */ + streetAddress?: string; + /** + * ID specific to this shipping destination. + * + * Unique location identifier. + */ + id: string; + /** + * Physical address of the location. + */ + address?: BillingAddressObject; + /** + * Location name (e.g., store name). + */ + name?: string; + [property: string]: any; +} + +/** + * Append-only fulfillment event representing an actual shipment. References line items by + * ID. + */ +export interface FulfillmentEvent { + /** + * Carrier name (e.g., 'FedEx', 'USPS'). + */ + carrier?: string; + /** + * Human-readable description of the shipment status or delivery information (e.g., + * 'Delivered to front door', 'Out for delivery'). + */ + description?: string; + /** + * Fulfillment event identifier. + */ + id: string; + /** + * Which line items and quantities are fulfilled in this event. + */ + lineItems: FulfillmentEventLineItem[]; + /** + * RFC 3339 timestamp when this fulfillment event occurred. + */ + occurredAt: string; + /** + * Carrier tracking number (required if type != processing). + */ + trackingNumber?: string; + /** + * URL to track this shipment (required if type != processing). + */ + trackingUrl?: string; + /** + * Fulfillment event type. Common values include: processing (preparing to ship), shipped + * (handed to carrier), in_transit (in delivery network), delivered (received by buyer), + * failed_attempt (delivery attempt failed), canceled (fulfillment canceled), undeliverable + * (cannot be delivered), returned_to_sender (returned to merchant). + */ + type: string; + [property: string]: any; +} + +export interface FulfillmentEventLineItem { + /** + * Line item ID reference. + */ + id: string; + /** + * Quantity fulfilled in this event. + */ + quantity: number; + [property: string]: any; +} + +/** + * A merchant-generated package/group of line items with fulfillment options. + */ +export interface FulfillmentGroup { + /** + * Group identifier for referencing merchant-generated groups in updates. + */ + id: string; + /** + * Line item IDs included in this group/package. + */ + lineItemIds: string[]; + /** + * Available fulfillment options for this group. + */ + options?: OptionElement[]; + /** + * ID of the selected fulfillment option for this group. + */ + selectedOptionId?: null | string; + [property: string]: any; +} + +/** + * A fulfillment option within a group (e.g., Standard Shipping $5, Express $15). + */ +export interface OptionElement { + /** + * Carrier name (for shipping). + */ + carrier?: string; + /** + * Complete context for buyer decision (e.g., 'Arrives Dec 12-15 via FedEx'). + */ + description?: string; + /** + * Earliest fulfillment date. + */ + earliestFulfillmentTime?: string; + /** + * Unique fulfillment option identifier. + */ + id: string; + /** + * Latest fulfillment date. + */ + latestFulfillmentTime?: string; + /** + * Short label (e.g., 'Express Shipping', 'Curbside Pickup'). + */ + title: string; + /** + * Fulfillment option totals breakdown. + */ + totals: LineItemTotal[]; + [property: string]: any; +} + +/** + * A fulfillment method (shipping or pickup) with destinations and groups. + */ +export interface FulfillmentMethod { + /** + * Available destinations. For shipping: addresses. For pickup: retail locations. + */ + destinations?: FulfillmentDestinationElement[]; + /** + * Fulfillment groups for selecting options. Agent sets selected_option_id on groups to + * choose shipping method. + */ + groups?: GroupElement[]; + /** + * Unique fulfillment method identifier. + */ + id: string; + /** + * Line item IDs fulfilled via this method. + */ + lineItemIds: string[]; + /** + * ID of the selected destination. + */ + selectedDestinationId?: null | string; + /** + * Fulfillment method type. + */ + type: TypeElement; + [property: string]: any; +} + +/** + * A destination for fulfillment. + * + * Shipping destination. + * + * The billing address associated with this payment method. + * + * Delivery destination address. + * + * Physical address of the location. + * + * A pickup location (retail store, locker, etc.). + */ +export interface FulfillmentDestinationElement { + /** + * The country. Recommended to be in 2-letter ISO 3166-1 alpha-2 format, for example "US". + * For backward compatibility, a 3-letter ISO 3166-1 alpha-3 country code such as "SGP" or a + * full country name such as "Singapore" can also be used. + */ + addressCountry?: string; + /** + * The locality in which the street address is, and which is in the region. For example, + * Mountain View. + */ + addressLocality?: string; + /** + * The region in which the locality is, and which is in the country. Required for applicable + * countries (i.e. state in US, province in CA). For example, California or another + * appropriate first-level Administrative division. + */ + addressRegion?: string; + /** + * An address extension such as an apartment number, C/O or alternative name. + */ + extendedAddress?: string; + /** + * Optional. First name of the contact associated with the address. + */ + firstName?: string; + /** + * Optional. Last name of the contact associated with the address. + */ + lastName?: string; + /** + * Optional. Phone number of the contact associated with the address. + */ + phoneNumber?: string; + /** + * The postal code. For example, 94043. + */ + postalCode?: string; + /** + * The street address. + */ + streetAddress?: string; + /** + * ID specific to this shipping destination. + * + * Unique location identifier. + */ + id: string; + /** + * Physical address of the location. + */ + address?: BillingAddressObject; + /** + * Location name (e.g., store name). + */ + name?: string; + [property: string]: any; +} + +/** + * A merchant-generated package/group of line items with fulfillment options. + */ +export interface GroupElement { + /** + * Group identifier for referencing merchant-generated groups in updates. + */ + id: string; + /** + * Line item IDs included in this group/package. + */ + lineItemIds: string[]; + /** + * Available fulfillment options for this group. + */ + options?: OptionElement[]; + /** + * ID of the selected fulfillment option for this group. + */ + selectedOptionId?: null | string; + [property: string]: any; +} + +/** + * A fulfillment option within a group (e.g., Standard Shipping $5, Express $15). + */ +export interface FulfillmentOption { + /** + * Carrier name (for shipping). + */ + carrier?: string; + /** + * Complete context for buyer decision (e.g., 'Arrives Dec 12-15 via FedEx'). + */ + description?: string; + /** + * Earliest fulfillment date. + */ + earliestFulfillmentTime?: string; + /** + * Unique fulfillment option identifier. + */ + id: string; + /** + * Latest fulfillment date. + */ + latestFulfillmentTime?: string; + /** + * Short label (e.g., 'Express Shipping', 'Curbside Pickup'). + */ + title: string; + /** + * Fulfillment option totals breakdown. + */ + totals: LineItemTotal[]; + [property: string]: any; +} + +/** + * Container for fulfillment methods and availability. + */ +export interface Fulfillment { + /** + * Inventory availability hints. + */ + availableMethods?: AvailableMethodElement[]; + /** + * Fulfillment methods for cart items. + */ + methods?: MethodElement[]; + [property: string]: any; +} + +/** + * Inventory availability hint for a fulfillment method type. + */ +export interface AvailableMethodElement { + /** + * Human-readable availability info (e.g., 'Available for pickup at Downtown Store today'). + */ + description?: string; + /** + * 'now' for immediate availability, or ISO 8601 date for future (preorders, transfers). + */ + fulfillableOn?: null | string; + /** + * Line items available for this fulfillment method. + */ + lineItemIds: string[]; + /** + * Fulfillment method type this availability applies to. + */ + type: TypeElement; + [property: string]: any; +} + +/** + * A fulfillment method (shipping or pickup) with destinations and groups. + */ +export interface MethodElement { + /** + * Available destinations. For shipping: addresses. For pickup: retail locations. + */ + destinations?: FulfillmentDestinationElement[]; + /** + * Fulfillment groups for selecting options. Agent sets selected_option_id on groups to + * choose shipping method. + */ + groups?: GroupElement[]; + /** + * Unique fulfillment method identifier. + */ + id: string; + /** + * Line item IDs fulfilled via this method. + */ + lineItemIds: string[]; + /** + * ID of the selected destination. + */ + selectedDestinationId?: null | string; + /** + * Fulfillment method type. + */ + type: TypeElement; + [property: string]: any; +} + +export interface Item { + /** + * The product identifier, often the SKU, required to resolve the product details associated + * with this line item. Should be recognized by both the Platform, and the Business. + */ + id: string; + /** + * Product image URI. + */ + imageUrl?: string; + /** + * Unit price in ISO 4217 minor units. + */ + price: number; + /** + * Product title. + */ + title: string; + [property: string]: any; +} + +/** + * Line item object. Expected to use the currency of the parent object. + */ +export interface LineItem { + id: string; + item: ItemObject; + /** + * Parent line item identifier for any nested structures. + */ + parentId?: string; + /** + * Quantity of the item being purchased. + */ + quantity: number; + /** + * Line item totals breakdown. + */ + totals: LineItemTotal[]; + [property: string]: any; +} + +export interface Link { + /** + * Optional display text for the link. When provided, use this instead of generating from + * type. + */ + title?: string; + /** + * Type of link. Well-known values: `privacy_policy`, `terms_of_service`, `refund_policy`, + * `shipping_policy`, `faq`. Consumers SHOULD handle unknown values gracefully by displaying + * them using the `title` field or omitting the link. + */ + type: string; + /** + * The actual URL pointing to the content to be displayed. + */ + url: string; + [property: string]: any; +} + +/** + * Merchant's fulfillment configuration. + */ +export interface MerchantFulfillmentConfig { + /** + * Allowed method type combinations. + */ + allowsMethodCombinations?: Array; + /** + * Permits multiple destinations per method type. + */ + allowsMultiDestination?: MerchantFulfillmentConfigAllowsMultiDestination; + [property: string]: any; +} + +/** + * Permits multiple destinations per method type. + */ +export interface MerchantFulfillmentConfigAllowsMultiDestination { + /** + * Multiple pickup locations allowed. + */ + pickup?: boolean; + /** + * Multiple shipping destinations allowed. + */ + shipping?: boolean; +} + +export interface MessageError { + code: string; + /** + * Human-readable message. + */ + content: string; + /** + * Content format, default = plain. + */ + contentType?: ContentType; + /** + * RFC 9535 JSONPath to the component the message refers to (e.g., $.items[1]). + */ + path?: string; + /** + * Reflects the resource state and recommended action. 'recoverable': platform can resolve + * by modifying inputs and retrying via API. 'requires_buyer_input': merchant requires + * information their API doesn't support collecting programmatically (checkout incomplete). + * 'requires_buyer_review': buyer must authorize before order placement due to policy, + * regulatory, or entitlement rules. 'unrecoverable': no valid resource exists to act on, + * retry with new resource or inputs. Errors with 'requires_*' severity contribute to + * 'status: requires_escalation'. + */ + severity: Severity; + /** + * Message type discriminator. + */ + type: StatusEnum; + [property: string]: any; +} + +export interface MessageInfo { + /** + * Info code for programmatic handling. + */ + code?: string; + /** + * Human-readable message. + */ + content: string; + /** + * Content format, default = plain. + */ + contentType?: ContentType; + /** + * RFC 9535 JSONPath to the component the message refers to. + */ + path?: string; + /** + * Message type discriminator. + */ + type: MessageInfoType; + [property: string]: any; +} + +export type MessageInfoType = "info"; + +export interface MessageWarning { + /** + * Warning code. Machine-readable identifier for the warning type (e.g., final_sale, prop65, + * fulfillment_changed, age_restricted, etc.). + */ + code: string; + /** + * Human-readable warning message that MUST be displayed. + */ + content: string; + /** + * Content format, default = plain. + */ + contentType?: ContentType; + /** + * URL to a required visual element (e.g., warning symbol, energy class label). + */ + imageUrl?: string; + /** + * JSONPath (RFC 9535) to related field (e.g., $.line_items[0]). + */ + path?: string; + /** + * Rendering contract for this warning. 'notice' (default): platform MUST display, MAY + * dismiss. 'disclosure': platform MUST display in proximity to the path-referenced + * component, MUST NOT hide or auto-dismiss. See specification for full contract. + */ + presentation?: string; + /** + * Message type discriminator. + */ + type: MessageWarningType; + /** + * Reference URL for more information (e.g., regulatory site, registry entry, policy page). + */ + url?: string; + [property: string]: any; +} + +export type MessageWarningType = "warning"; + +/** + * Container for error, warning, or info messages. + */ +export interface Message { + /** + * Warning code. Machine-readable identifier for the warning type (e.g., final_sale, prop65, + * fulfillment_changed, age_restricted, etc.). + * + * Info code for programmatic handling. + */ + code?: string; + /** + * Human-readable message. + * + * Human-readable warning message that MUST be displayed. + */ + content: string; + /** + * Content format, default = plain. + */ + contentType?: ContentType; + /** + * RFC 9535 JSONPath to the component the message refers to (e.g., $.items[1]). + * + * JSONPath (RFC 9535) to related field (e.g., $.line_items[0]). + * + * RFC 9535 JSONPath to the component the message refers to. + */ + path?: string; + /** + * Reflects the resource state and recommended action. 'recoverable': platform can resolve + * by modifying inputs and retrying via API. 'requires_buyer_input': merchant requires + * information their API doesn't support collecting programmatically (checkout incomplete). + * 'requires_buyer_review': buyer must authorize before order placement due to policy, + * regulatory, or entitlement rules. 'unrecoverable': no valid resource exists to act on, + * retry with new resource or inputs. Errors with 'requires_*' severity contribute to + * 'status: requires_escalation'. + */ + severity?: Severity; + /** + * Message type discriminator. + */ + type: MessageType; + /** + * URL to a required visual element (e.g., warning symbol, energy class label). + */ + imageUrl?: string; + /** + * Rendering contract for this warning. 'notice' (default): platform MUST display, MAY + * dismiss. 'disclosure': platform MUST display in proximity to the path-referenced + * component, MUST NOT hide or auto-dismiss. See specification for full contract. + */ + presentation?: string; + /** + * Reference URL for more information (e.g., regulatory site, registry entry, policy page). + */ + url?: string; + [property: string]: any; +} + +/** + * Order details available at the time of checkout completion. + */ +export interface OrderConfirmation { + /** + * Unique order identifier. + */ + id: string; + /** + * Human-readable label for identifying the order. MUST only be provided by the business. + */ + label?: string; + /** + * Permalink to access the order on merchant site. + */ + permalinkUrl: string; + [property: string]: any; +} + +export interface OrderLineItem { + /** + * Line item identifier. + */ + id: string; + /** + * Product data (id, title, price, image_url). + */ + item: ItemObject; + /** + * Parent line item identifier for any nested structures. + */ + parentId?: string; + /** + * Quantity tracking for the line item. + */ + quantity: OrderLineItemQuantity; + /** + * Derived status: removed if quantity.total == 0, fulfilled if quantity.total > 0 and + * quantity.fulfilled == quantity.total, partial if quantity.total > 0 and + * quantity.fulfilled > 0, otherwise processing. + */ + status: OrderLineItemStatus; + /** + * Line item totals breakdown. + */ + totals: LineItemTotal[]; + [property: string]: any; +} + +/** + * Quantity tracking for the line item. + */ +export interface OrderLineItemQuantity { + /** + * Quantity fulfilled so far. + */ + fulfilled: number; + /** + * Quantity from the original checkout. + */ + original?: number; + /** + * Current total active quantity. May differ from original due to post-order modifications + * (e.g., returns or cancellations). + */ + total: number; + [property: string]: any; +} + +/** + * Derived status: removed if quantity.total == 0, fulfilled if quantity.total > 0 and + * quantity.fulfilled == quantity.total, partial if quantity.total > 0 and + * quantity.fulfilled > 0, otherwise processing. + */ +export type OrderLineItemStatus = "processing" | "partial" | "fulfilled" | "removed"; + +/** + * The base definition for any payment credential. Handlers define specific credential types. + */ +export interface PaymentCredential { + /** + * The credential type discriminator. Specific schemas will constrain this to a constant + * value. + */ + type: string; + [property: string]: any; +} + +/** + * Identity of a participant for token binding. The access_token uniquely identifies the + * participant who tokens should be bound to. + */ +export interface PaymentIdentity { + /** + * Unique identifier for this participant, obtained during onboarding with the tokenizer. + */ + accessToken: string; + [property: string]: any; +} + +/** + * The base definition for any payment instrument. It links the instrument to a specific + * payment handler. + */ +export interface PaymentInstrument { + /** + * The billing address associated with this payment method. + */ + billingAddress?: BillingAddressObject; + credential?: CredentialObject; + /** + * Display information for this payment instrument. Each payment instrument schema defines + * its specific display properties, as outlined by the payment handler. + */ + display?: { [key: string]: any }; + /** + * The unique identifier for the handler instance that produced this instrument. This + * corresponds to the 'id' field in the Payment Handler definition. + */ + handlerId: string; + /** + * A unique identifier for this instrument instance, assigned by the platform. + */ + id: string; + /** + * The broad category of the instrument (e.g., 'card', 'tokenized_card'). Specific schemas + * will constrain this to a constant value. + */ + type: string; + [property: string]: any; +} + +/** + * Platform's fulfillment configuration. + */ +export interface PlatformFulfillmentConfig { + /** + * Enables multiple groups per method. + */ + supportsMultiGroup?: boolean; + [property: string]: any; +} + +export interface PostalAddress { + /** + * The country. Recommended to be in 2-letter ISO 3166-1 alpha-2 format, for example "US". + * For backward compatibility, a 3-letter ISO 3166-1 alpha-3 country code such as "SGP" or a + * full country name such as "Singapore" can also be used. + */ + addressCountry?: string; + /** + * The locality in which the street address is, and which is in the region. For example, + * Mountain View. + */ + addressLocality?: string; + /** + * The region in which the locality is, and which is in the country. Required for applicable + * countries (i.e. state in US, province in CA). For example, California or another + * appropriate first-level Administrative division. + */ + addressRegion?: string; + /** + * An address extension such as an apartment number, C/O or alternative name. + */ + extendedAddress?: string; + /** + * Optional. First name of the contact associated with the address. + */ + firstName?: string; + /** + * Optional. Last name of the contact associated with the address. + */ + lastName?: string; + /** + * Optional. Phone number of the contact associated with the address. + */ + phoneNumber?: string; + /** + * The postal code. For example, 94043. + */ + postalCode?: string; + /** + * The street address. + */ + streetAddress?: string; + [property: string]: any; +} + +/** + * A pickup location (retail store, locker, etc.). + */ +export interface RetailLocation { + /** + * Physical address of the location. + */ + address?: BillingAddressObject; + /** + * Unique location identifier. + */ + id: string; + /** + * Location name (e.g., store name). + */ + name: string; + [property: string]: any; +} + +/** + * Shipping destination. + * + * The billing address associated with this payment method. + * + * Delivery destination address. + * + * Physical address of the location. + */ +export interface ShippingDestination { + /** + * The country. Recommended to be in 2-letter ISO 3166-1 alpha-2 format, for example "US". + * For backward compatibility, a 3-letter ISO 3166-1 alpha-3 country code such as "SGP" or a + * full country name such as "Singapore" can also be used. + */ + addressCountry?: string; + /** + * The locality in which the street address is, and which is in the region. For example, + * Mountain View. + */ + addressLocality?: string; + /** + * The region in which the locality is, and which is in the country. Required for applicable + * countries (i.e. state in US, province in CA). For example, California or another + * appropriate first-level Administrative division. + */ + addressRegion?: string; + /** + * An address extension such as an apartment number, C/O or alternative name. + */ + extendedAddress?: string; + /** + * Optional. First name of the contact associated with the address. + */ + firstName?: string; + /** + * Optional. Last name of the contact associated with the address. + */ + lastName?: string; + /** + * Optional. Phone number of the contact associated with the address. + */ + phoneNumber?: string; + /** + * The postal code. For example, 94043. + */ + postalCode?: string; + /** + * The street address. + */ + streetAddress?: string; + /** + * ID specific to this shipping destination. + */ + id: string; + [property: string]: any; +} + +/** + * Environment data provided by the platform to support authorization and abuse prevention. + * Values MUST NOT be buyer-asserted claims — platforms provide signals based on direct + * observation or independently verifiable third-party attestations. All signal keys MUST + * use reverse-domain naming to ensure provenance and prevent collisions when multiple + * extensions contribute to the shared namespace. + */ +export interface Signals { + /** + * Client's IP address (IPv4 or IPv6). + */ + devUcpBuyerIp?: string; + /** + * Client's HTTP User-Agent header or equivalent. + */ + devUcpUserAgent?: string; + [property: string]: any; +} + +/** + * Base token credential schema. Concrete payment handlers may extend this schema with + * additional fields and define their own constraints. + * + * The base definition for any payment credential. Handlers define specific credential types. + */ +export interface TokenCredential { + /** + * The credential type discriminator. Specific schemas will constrain this to a constant + * value. + * + * The specific type of token produced by the handler (e.g., 'stripe_token'). + */ + type: string; + /** + * The token value. + */ + token: string; + [property: string]: any; +} + +/** + * A cost breakdown entry with a category, amount, and optional display text. + */ +export interface Total { + amount: number; + /** + * Text to display against the amount. Should reflect appropriate method (e.g., 'Shipping', + * 'Delivery'). + */ + displayText?: string; + /** + * Cost category. Well-known values: subtotal, items_discount, discount, fulfillment, tax, + * fee, total. Businesses MAY use additional values. + */ + type: string; + [property: string]: any; +} + +/** + * Pricing breakdown provided by the business. MUST contain exactly one subtotal and one + * total entry. Detail types (tax, fee, discount, fulfillment) may appear multiple times for + * itemization. Platforms MUST render all entries in order using display_text and amount. + * + * A cost breakdown entry with a category, amount, and optional display text. + */ +export interface Totals { + amount: number; + /** + * Text to display against the amount. Should reflect appropriate method (e.g., 'Shipping', + * 'Delivery'). + */ + displayText?: string; + /** + * Cost category. Well-known values: subtotal, items_discount, discount, fulfillment, tax, + * fee, total. Businesses MAY use additional values. + */ + type: string; + /** + * Optional itemized breakdown. The parent entry is always rendered; lines are + * supplementary. Sum of line amounts MUST equal the parent entry amount. + */ + lines?: TotalLineObject[]; + [property: string]: any; +} + +/** + * Sub-line entry. Additional metadata MAY be included. + */ +export interface TotalLineObject { + amount: number; + /** + * Human-readable label for this sub-line. + */ + displayText: string; + [property: string]: any; +} + +/** + * Payment configuration containing handlers. + */ +export interface Payment { + /** + * The payment instruments available for this payment. Each instrument is associated with a + * specific handler via the handler_id field. Handlers can extend the base + * payment_instrument schema to add handler-specific fields. + */ + instruments?: PaymentSelectedPaymentInstrument[]; + [property: string]: any; +} + +/** + * Order schema with line items, buyer-facing fulfillment expectations, and event logs. + */ +export interface Order { + /** + * Post-order events (refunds, returns, credits, disputes, cancellations, etc.) that exist + * independently of fulfillment. + */ + adjustments?: AdjustmentElement[]; + /** + * Associated checkout ID for reconciliation. + */ + checkoutId: string; + /** + * ISO 4217 currency code. MUST match the currency from the originating checkout session. + */ + currency: string; + /** + * Fulfillment data: buyer expectations and what actually happened. + */ + fulfillment: FulfillmentObject; + /** + * Unique order identifier. + */ + id: string; + /** + * Human-readable label for identifying the order. MUST only be provided by the business. + */ + label?: string; + /** + * Line items representing what was purchased — can change post-order via edits or exchanges. + */ + lineItems: LineItemElement[]; + /** + * Business outcome messages (errors, warnings, informational). Present when the business + * needs to communicate status or issues to the platform. + */ + messages?: MessageElement[]; + /** + * Permalink to access the order on merchant site. + */ + permalinkUrl: string; + /** + * Different totals for the order. + */ + totals: CheckoutTotal[]; + ucp: UcpOrderResponseSchema; + [property: string]: any; +} + +/** + * Post-order event that exists independently of fulfillment. Typically represents money + * movements but can be any post-order change. Polymorphic type that can optionally + * reference line items. + */ +export interface AdjustmentElement { + /** + * Human-readable reason or description (e.g., 'Defective item', 'Customer requested'). + */ + description?: string; + /** + * Adjustment event identifier. + */ + id: string; + /** + * Which line items and quantities are affected (optional). + */ + lineItems?: AdjustmentLineItemObject[]; + /** + * RFC 3339 timestamp when this adjustment occurred. + */ + occurredAt: string; + /** + * Adjustment status. + */ + status: AdjustmentStatus; + /** + * Adjustment totals breakdown. Signed values - negative for money returned to buyer + * (refunds, credits), positive for additional charges (exchanges). + */ + totals?: LineItemTotal[]; + /** + * Type of adjustment (open string). Typically money-related like: refund, return, credit, + * price_adjustment, dispute, cancellation. Can be any value that makes sense for the + * merchant's business. + */ + type: string; + [property: string]: any; +} + +export interface AdjustmentLineItemObject { + /** + * Line item ID reference. + */ + id: string; + /** + * Signed quantity affected by this adjustment. Negative values represent reductions (e.g. + * returns); positive values represent additions (e.g. exchanges). + */ + quantity: number; + [property: string]: any; +} + +/** + * Fulfillment data: buyer expectations and what actually happened. + */ +export interface FulfillmentObject { + /** + * Append-only event log of actual shipments. Each event references line items by ID. + */ + events?: EventElement[]; + /** + * Buyer-facing groups representing when/how items will be delivered. Can be split, merged, + * or adjusted post-order. + */ + expectations?: ExpectationElement[]; + [property: string]: any; +} + +/** + * Append-only fulfillment event representing an actual shipment. References line items by + * ID. + */ +export interface EventElement { + /** + * Carrier name (e.g., 'FedEx', 'USPS'). + */ + carrier?: string; + /** + * Human-readable description of the shipment status or delivery information (e.g., + * 'Delivered to front door', 'Out for delivery'). + */ + description?: string; + /** + * Fulfillment event identifier. + */ + id: string; + /** + * Which line items and quantities are fulfilled in this event. + */ + lineItems: EventLineItem[]; + /** + * RFC 3339 timestamp when this fulfillment event occurred. + */ + occurredAt: string; + /** + * Carrier tracking number (required if type != processing). + */ + trackingNumber?: string; + /** + * URL to track this shipment (required if type != processing). + */ + trackingUrl?: string; + /** + * Fulfillment event type. Common values include: processing (preparing to ship), shipped + * (handed to carrier), in_transit (in delivery network), delivered (received by buyer), + * failed_attempt (delivery attempt failed), canceled (fulfillment canceled), undeliverable + * (cannot be delivered), returned_to_sender (returned to merchant). + */ + type: string; + [property: string]: any; +} + +export interface EventLineItem { + /** + * Line item ID reference. + */ + id: string; + /** + * Quantity fulfilled in this event. + */ + quantity: number; + [property: string]: any; +} + +/** + * Buyer-facing fulfillment expectation representing logical groupings of items (e.g., + * 'package'). Can be split, merged, or adjusted post-order to set buyer expectations for + * when/how items arrive. + */ +export interface ExpectationElement { + /** + * Human-readable delivery description (e.g., 'Arrives in 5-8 business days'). + */ + description?: string; + /** + * Delivery destination address. + */ + destination: BillingAddressObject; + /** + * When this expectation can be fulfilled: 'now' or ISO 8601 timestamp for future date + * (backorder, pre-order). + */ + fulfillableOn?: string; + /** + * Expectation identifier. + */ + id: string; + /** + * Which line items and quantities are in this expectation. + */ + lineItems: ExpectationLineItemObject[]; + /** + * Delivery method type (shipping, pickup, digital). + */ + methodType: MethodType; + [property: string]: any; +} + +export interface ExpectationLineItemObject { + /** + * Line item ID reference. + */ + id: string; + /** + * Quantity of this item in this expectation. + */ + quantity: number; + [property: string]: any; +} + +export interface LineItemElement { + /** + * Line item identifier. + */ + id: string; + /** + * Product data (id, title, price, image_url). + */ + item: ItemObject; + /** + * Parent line item identifier for any nested structures. + */ + parentId?: string; + /** + * Quantity tracking for the line item. + */ + quantity: LineItemQuantity; + /** + * Derived status: removed if quantity.total == 0, fulfilled if quantity.total > 0 and + * quantity.fulfilled == quantity.total, partial if quantity.total > 0 and + * quantity.fulfilled > 0, otherwise processing. + */ + status: OrderLineItemStatus; + /** + * Line item totals breakdown. + */ + totals: LineItemTotal[]; + [property: string]: any; +} + +/** + * Quantity tracking for the line item. + */ +export interface LineItemQuantity { + /** + * Quantity fulfilled so far. + */ + fulfilled: number; + /** + * Quantity from the original checkout. + */ + original?: number; + /** + * Current total active quantity. May differ from original due to post-order modifications + * (e.g., returns or cancellations). + */ + total: number; + [property: string]: any; +} + +/** + * UCP metadata for order responses. No payment handlers needed post-purchase. + * + * Base UCP metadata with shared properties for all schema types. + */ +export interface UcpOrderResponseSchema { + /** + * Capability registry keyed by reverse-domain name. + */ + capabilities?: { [key: string]: CapabilityResponseSchema[] }; + /** + * Payment handler registry keyed by reverse-domain name. + */ + paymentHandlers?: { [key: string]: PaymentHandlerResponseSchema[] }; + /** + * Service registry keyed by reverse-domain name. + */ + services?: { [key: string]: UcpOrderResponseSchemaService[] }; + /** + * Application-level status of the UCP operation. + */ + status?: UcpCheckoutResponseSchemaStatus; + version: string; + [property: string]: any; +} + +/** + * Checkout state after instrument selection. + * + * Generic error response when business logic prevents resource creation or failed to + * retrieve resource. Used when no valid resource can be established. + */ +export interface InstrumentsChangeResult { + /** + * Partial checkout update with payment instrument selection. + */ + checkout?: InstrumentsChangeCheckout; + /** + * UCP protocol metadata. Status MUST be 'error' for error response. + */ + ucp: InstrumentsChangeResultUcp; + /** + * URL for buyer handoff or session recovery. + */ + continueUrl?: string; + /** + * Array of messages describing why the operation failed. + */ + messages?: MessageElement[]; + [property: string]: any; +} + +/** + * Partial checkout update with payment instrument selection. + */ +export interface InstrumentsChangeCheckout { + payment?: InstrumentsChangePayment; + [property: string]: any; +} + +/** + * Payment instruments with selected instrument ID. + * + * Payment instruments from host. + */ +export interface InstrumentsChangePayment { + /** + * Available payment instruments. + */ + instruments?: PurpleSelectedPaymentInstrument[]; + /** + * ID of the selected payment instrument. + */ + selectedInstrumentId?: string; + [property: string]: any; +} + +/** + * A payment instrument with selection state. + * + * The base definition for any payment instrument. It links the instrument to a specific + * payment handler. + */ +export interface PurpleSelectedPaymentInstrument { + /** + * The billing address associated with this payment method. + */ + billingAddress?: BillingAddressObject; + credential?: CredentialObject; + /** + * Display information for this payment instrument. Each payment instrument schema defines + * its specific display properties, as outlined by the payment handler. + */ + display?: { [key: string]: any }; + /** + * The unique identifier for the handler instance that produced this instrument. This + * corresponds to the 'id' field in the Payment Handler definition. + */ + handlerId: string; + /** + * A unique identifier for this instrument instance, assigned by the platform. + */ + id: string; + /** + * The broad category of the instrument (e.g., 'card', 'tokenized_card'). Specific schemas + * will constrain this to a constant value. + */ + type: string; + /** + * Whether this instrument is selected by the user. + */ + selected?: boolean; + [property: string]: any; +} + +/** + * UCP metadata with status 'success'. Use for response branches that carry the expected + * payload. + * + * Base UCP metadata with shared properties for all schema types. + * + * UCP protocol metadata. Status MUST be 'error' for error response. + * + * UCP metadata with status 'error'. Use for response branches that carry error information. + */ +export interface InstrumentsChangeResultUcp { + /** + * Capability registry keyed by reverse-domain name. + */ + capabilities?: { [key: string]: CapabilityElement[] }; + /** + * Payment handler registry keyed by reverse-domain name. + */ + paymentHandlers?: { [key: string]: PaymentHandlerElement[] }; + /** + * Service registry keyed by reverse-domain name. + */ + services?: { [key: string]: PurpleService[] }; + /** + * Application-level status of the UCP operation. + */ + status: UcpCheckoutResponseSchemaStatus; + version: string; + [property: string]: any; +} + +/** + * Shared foundation for all UCP entities. + * + * Capability reference in responses. Only name/version required to confirm active + * capabilities. + */ +export interface CapabilityElement { + /** + * Entity-specific configuration. Structure defined by each entity's schema. + */ + config?: { [key: string]: any }; + /** + * Unique identifier for this entity instance. Used to disambiguate when multiple instances + * exist. + */ + id?: string; + /** + * URL to JSON Schema defining this entity's structure and payloads. + */ + schema?: string; + /** + * URL to human-readable specification document. + */ + spec?: string; + /** + * Entity version in YYYY-MM-DD format. + */ + version: string; + /** + * Parent capability(s) this extends. Present for extensions, absent for root capabilities. + * Use array for multi-parent extensions. + */ + extends?: string[] | string; + [property: string]: any; +} + +/** + * Shared foundation for all UCP entities. + * + * Handler reference in responses. May include full config state for runtime usage of the + * handler. + */ +export interface PaymentHandlerElement { + /** + * Entity-specific configuration. Structure defined by each entity's schema. + */ + config?: { [key: string]: any }; + /** + * Unique identifier for this entity instance. Used to disambiguate when multiple instances + * exist. + */ + id: string; + /** + * URL to JSON Schema defining this entity's structure and payloads. + */ + schema?: string; + /** + * URL to human-readable specification document. + */ + spec?: string; + /** + * Entity version in YYYY-MM-DD format. + */ + version: string; + /** + * Instrument types this handler supports, with optional constraints. When absent, every + * instrument should be considered available. + */ + availableInstruments?: PaymentHandlerAvailableInstrument[]; + [property: string]: any; +} + +/** + * An instrument type available from a payment handler with optional constraints. + */ +export interface PaymentHandlerAvailableInstrument { + /** + * Constraints on this instrument type. Structure depends on instrument type and active + * capabilities. + */ + constraints?: { [key: string]: any }; + /** + * The instrument type identifier (e.g., 'card', 'gift_card'). References an instrument + * schema's type constant. + */ + type: string; + [property: string]: any; +} + +/** + * Shared foundation for all UCP entities. + */ +export interface PurpleService { + /** + * Entity-specific configuration. Structure defined by each entity's schema. + */ + config?: { [key: string]: any }; + /** + * Unique identifier for this entity instance. Used to disambiguate when multiple instances + * exist. + */ + id?: string; + /** + * URL to JSON Schema defining this entity's structure and payloads. + */ + schema?: string; + /** + * URL to human-readable specification document. + */ + spec?: string; + /** + * Entity version in YYYY-MM-DD format. + */ + version: string; + /** + * Endpoint URL for this transport binding. + */ + endpoint?: string; + /** + * Transport protocol for this service binding. + */ + transport: Transport; + [property: string]: any; +} + +/** + * Checkout state with payment credential ready for completion. + * + * Generic error response when business logic prevents resource creation or failed to + * retrieve resource. Used when no valid resource can be established. + */ +export interface CredentialResult { + /** + * Partial checkout update with payment credential. + */ + checkout?: CredentialCheckout; + /** + * UCP protocol metadata. Status MUST be 'error' for error response. + */ + ucp: InstrumentsChangeResultUcp; + /** + * URL for buyer handoff or session recovery. + */ + continueUrl?: string; + /** + * Array of messages describing why the operation failed. + */ + messages?: MessageElement[]; + [property: string]: any; +} + +/** + * Partial checkout update with payment credential. + */ +export interface CredentialCheckout { + payment?: CredentialPayment; + [property: string]: any; +} + +/** + * Payment instruments from host. + */ +export interface CredentialPayment { + /** + * Available payment instruments. + */ + instruments?: PurpleSelectedPaymentInstrument[]; + [property: string]: any; +} diff --git a/protocol/languages/typescript/src/index.d.ts b/protocol/languages/typescript/src/index.d.ts new file mode 100644 index 00000000..2ee8e784 --- /dev/null +++ b/protocol/languages/typescript/src/index.d.ts @@ -0,0 +1,24 @@ +/* +MIT License + +Copyright 2023 - Present, Shopify Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ + +export type * from './generated/Models'; diff --git a/protocol/scripts/generate_models.sh b/protocol/scripts/generate_models.sh index f714d580..f1f39a2a 100755 --- a/protocol/scripts/generate_models.sh +++ b/protocol/scripts/generate_models.sh @@ -294,7 +294,13 @@ case "$LANG" in prepend_license "typescript" "${OUTPUT}" - echo "Generated ${OUTPUT}" + # API Extractor consumers require dependency entry points to resolve to + # declaration files. The generated models are types-only, so the TypeScript + # source is also valid declaration output. + DECLARATION_OUTPUT="${OUTPUT%.ts}.d.ts" + cp "${OUTPUT}" "${DECLARATION_OUTPUT}" + + echo "Generated ${OUTPUT} and ${DECLARATION_OUTPUT}" ;; *)