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 2715347a..d336810b 100644 --- a/dev.yml +++ b/dev.yml @@ -276,6 +276,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 6665ac63..2a66d64a 100644 --- a/platforms/react-native/README.md +++ b/platforms/react-native/README.md @@ -621,11 +621,6 @@ 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. - ## Identity & customer accounts Buyer-aware checkout experience reduces friction and increases conversion. 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/jest.config.js b/platforms/react-native/jest.config.js index 9d239e6c..43d649fa 100644 --- a/platforms/react-native/jest.config.js +++ b/platforms/react-native/jest.config.js @@ -1,7 +1,7 @@ module.exports = { preset: 'react-native', modulePathIgnorePatterns: ['modules/@shopify/checkout-kit-react-native/lib'], - modulePaths: ['/sample/node_modules'], + modulePaths: ['/node_modules', '/sample/node_modules'], setupFiles: ['/jest.setup.ts'], transform: { '\\.[jt]sx?$': 'babel-jest', diff --git a/platforms/react-native/metro.config.js b/platforms/react-native/metro.config.js index 9f26a893..566ec1af 100644 --- a/platforms/react-native/metro.config.js +++ b/platforms/react-native/metro.config.js @@ -3,6 +3,7 @@ const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config'); const root = path.resolve(__dirname); const sample = path.resolve(root, 'sample'); +const protocol = path.resolve(root, '../../protocol/languages/typescript'); /** * Metro configuration @@ -13,7 +14,7 @@ const sample = path.resolve(root, 'sample'); const config = mergeConfig(getDefaultConfig(__dirname), { projectRoot: sample, - watchFolders: [root], + watchFolders: [root, protocol], resolver: { resolveRequest: (context, moduleName, platform) => { @@ -46,6 +47,8 @@ const config = mergeConfig(getDefaultConfig(__dirname), { 'modules', '@shopify/checkout-kit-react-native', ), + '@shopify/checkout-kit-protocol': protocol, + '@babel/runtime': path.resolve(root, 'node_modules', '@babel/runtime'), }, }, 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..c595c09b 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 + # (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 b0ac6416..45e48551 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/CustomCheckoutListener.java b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListener.java index ed50527e..b4878961 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 @@ -7,7 +7,6 @@ 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; @@ -19,16 +18,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 @@ -42,7 +44,7 @@ public void invokeGeolocationCallback(boolean allow) { } public void release() { - dispatchCallback = null; + dispatch.release(); geolocationCallback = null; geolocationOrigin = null; } @@ -63,20 +65,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); } @@ -92,9 +95,7 @@ public void onGeolocationPermissionsHidePrompt() { @Override public void onCheckoutFailed(CheckoutException checkoutError) { - Callback dispatch = dispatchCallback; - if (dispatch == null) { - release(); + if (dispatch.isReleased()) { return; } try { @@ -108,9 +109,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/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..28315e76 --- /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,34 @@ +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..3f6fa7fc --- /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,60 @@ +package com.shopify.reactnative.checkoutkit + +import android.util.Log +import com.shopify.checkoutkit.CheckoutProtocol +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +private const val TAG = "ShopifyCheckoutKit" + +fun interface DispatchCallback { + fun invoke(json: String) +} + +@Serializable +internal data class DispatchEnvelope

( + val type: String, + val payload: P, +) + +/** + * Bridges native CheckoutProtocol notifications to the React Native onDispatch + * event stream. Payloads are emitted in protocol wire casing; JS performs the + * schema-aware conversion to the public camelCase shape with QuickType. + */ +object ProtocolRelay { + + private val json: Json = Json { ignoreUnknownKeys = true } + + @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 envelopeJson = json.encodeToString(DispatchEnvelope(type, payload)) + dispatch.invoke(envelopeJson) + } catch (e: Exception) { + Log.e(TAG, "Error dispatching protocol event \"$type\"", e) + } + } +} 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 4810aea4..f49ca6d0 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 @@ -2,8 +2,6 @@ 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; @@ -13,7 +11,9 @@ 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; @@ -21,8 +21,6 @@ public class ShopifyCheckoutKitModule extends NativeShopifyCheckoutKitSpec { public static Configuration checkoutConfig = new Configuration(); - private final ReactApplicationContext reactContext; - private CheckoutKitDialog checkoutSheet; private CustomCheckoutListener checkoutListener; @@ -30,8 +28,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; @@ -59,16 +55,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/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..f1e8ef14 --- /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,162 @@ +package com.shopify.reactnative.checkoutkit + +import android.os.Looper +import android.util.Log +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +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 +import org.robolectric.shadows.ShadowLog + +@RunWith(RobolectricTestRunner::class) +class ProtocolRelayTest { + + @Test + fun `envelope encodes type and wire-case payload`() { + val payload = SnakePayload(continueUrl = "https://example.com", lineItems = emptyList()) + val envelope = DispatchEnvelope(type = "ec.start", payload = payload) + + val json = Json.encodeToString(envelope) + + val parsed = Json.parseToJsonElement(json).jsonObject + assertThat(parsed["type"]?.jsonPrimitive?.content).isEqualTo("ec.start") + + val payloadObj = parsed["payload"]!!.jsonObject + assertThat(payloadObj["continue_url"]?.jsonPrimitive?.content).isEqualTo("https://example.com") + assertThat(payloadObj).containsKey("line_items") + assertThat(payloadObj).doesNotContainKey("continueUrl") + assertThat(payloadObj).doesNotContainKey("lineItems") + } + + @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["line_items"]!!.jsonArray + assertThat(lineItems).hasSize(1) + val firstItem = lineItems[0].jsonObject["item"]!!.jsonObject + assertThat(firstItem["image_url"]?.jsonPrimitive?.content).isEqualTo("https://example.com/image.png") + val paymentHandlers = payload["ucp"]!!.jsonObject["payment_handlers"]!!.jsonObject + assertThat(paymentHandlers).containsKey("com.example.loyalty_gold") + } + + @Test + fun `relay logs dispatch failures`() { + val failure = RuntimeException("boom") + ShadowLog.clear() + val client = ProtocolRelay.makeClient( + listOf("ec.start"), + DispatchCallback { throw failure }, + ) + + client.process(ecStartNotificationFixture) + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + + val logs = ShadowLog.getLogsForTag("ShopifyCheckoutKit") + .filter { it.msg == "Error dispatching protocol event \"ec.start\"" } + assertThat(logs).hasSize(1) + assertThat(logs.single().type).isEqualTo(Log.ERROR) + assertThat(logs.single().throwable).isSameAs(failure) + } + + @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": { + "com.example.loyalty_gold": [] + } + }, + "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 b56f01de..e7c63348 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,6 +4,7 @@ ```ts +import { Checkout } from '@shopify/checkout-kit-protocol'; import type { PropsWithChildren } from 'react'; import { default as React_2 } from 'react'; @@ -41,17 +42,32 @@ export interface AcceleratedCheckoutConfiguration { // @public export enum AcceleratedCheckoutWallet { // (undocumented) - applePay = 'applePay', + applePay = "applePay", // (undocumented) - shopPay = 'shopPay', + 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) - email = 'email', + email = "email", // (undocumented) - phone = 'phone', + phone = "phone" } // @public (undocumented) @@ -104,78 +120,88 @@ 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) -export class CheckoutClientError extends GenericErrorWithCode {} +export class CheckoutClientError extends GenericErrorWithCode { +} // @public (undocumented) export enum CheckoutErrorCode { // (undocumented) - cartCompleted = 'cart_completed', + cartCompleted = "cart_completed", // (undocumented) - cartExpired = 'cart_expired', + cartExpired = "cart_expired", // (undocumented) - clientError = 'client_error', + clientError = "client_error", // (undocumented) - httpError = 'http_error', + httpError = "http_error", // (undocumented) - invalidCart = 'invalid_cart', + invalidCart = "invalid_cart", // (undocumented) - receivingBridgeEventError = 'error_receiving_message', + receivingBridgeEventError = "error_receiving_message", // (undocumented) - renderProcessGone = 'render_process_gone', + renderProcessGone = "render_process_gone", // (undocumented) - sendingBridgeEventError = 'error_sending_message', + sendingBridgeEventError = "error_sending_message", // (undocumented) - storefrontPasswordRequired = 'storefront_password_required', + storefrontPasswordRequired = "storefront_password_required", // (undocumented) - unknown = 'unknown', + unknown = "unknown" } // @public (undocumented) -export type CheckoutException = -| CheckoutClientError -| CheckoutExpiredError -| CheckoutHTTPError -| ConfigurationError -| GenericError -| InternalError; +export type CheckoutException = CheckoutClientError | CheckoutExpiredError | CheckoutHTTPError | ConfigurationError | GenericError | InternalError; // @public (undocumented) -export class CheckoutExpiredError extends GenericErrorWithCode {} +export class CheckoutExpiredError extends GenericErrorWithCode { +} // Warning: (ae-forgotten-export) The symbol "GenericNetworkError" needs to be exported by the entry point index.d.ts // // @public (undocumented) -export class CheckoutHTTPError extends GenericNetworkError {} +export class CheckoutHTTPError extends GenericNetworkError { +} // @public (undocumented) export enum CheckoutNativeErrorType { // (undocumented) - CheckoutClientError = 'CheckoutClientError', + CheckoutClientError = "CheckoutClientError", // (undocumented) - CheckoutExpiredError = 'CheckoutExpiredError', + CheckoutExpiredError = "CheckoutExpiredError", // (undocumented) - CheckoutHTTPError = 'CheckoutHTTPError', + CheckoutHTTPError = "CheckoutHTTPError", // (undocumented) - ConfigurationError = 'ConfigurationError', + ConfigurationError = "ConfigurationError", // (undocumented) - InternalError = 'InternalError', + InternalError = "InternalError", + // (undocumented) + UnknownError = "UnknownError" +} + +// @public (undocumented) +export const CheckoutProtocol: { + readonly start: "ec.start"; +}; + +// @public (undocumented) +export interface CheckoutProtocolPayloads { // (undocumented) - UnknownError = 'UnknownError', + 'ec.start': Checkout; } // @public (undocumented) export enum ColorScheme { // (undocumented) - automatic = 'automatic', + automatic = "automatic", // (undocumented) - dark = 'dark', + dark = "dark", // (undocumented) - light = 'light', + light = "light", // (undocumented) - web = 'web_default', + web = "web_default" } // Warning: (ae-forgotten-export) The symbol "CommonConfiguration" needs to be exported by the entry point index.d.ts @@ -201,7 +227,8 @@ export type Configuration = CommonConfiguration & { ); // @public (undocumented) -export class ConfigurationError extends GenericErrorWithCode {} +export class ConfigurationError extends GenericErrorWithCode { +} // @public export class DispatchEventParityError extends Error { @@ -216,12 +243,7 @@ export interface Features { // @public (undocumented) export class GenericError { // Warning: (ae-forgotten-export) The symbol "CheckoutNativeError" needs to be exported by the entry point index.d.ts - constructor(exception?: CheckoutNativeError) { - this.code = getCheckoutErrorCode(exception?.code); - this.message = exception?.message; - this.name = this.constructor.name; - this.statusCode = exception?.statusCode; - } + constructor(exception?: CheckoutNativeError); // (undocumented) code: CheckoutErrorCode; // (undocumented) @@ -240,16 +262,20 @@ export interface GeolocationRequestEvent { // @public (undocumented) export class InternalError { - constructor(exception: CheckoutNativeError) { - this.code = getCheckoutErrorCode(exception.code); - this.message = exception.message; - } + constructor(exception: CheckoutNativeError); // (undocumented) code: CheckoutErrorCode; // (undocumented) 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); @@ -257,8 +283,8 @@ export class LifecycleEventParseError extends Error { // @public export enum LogLevel { - debug = 'debug', - error = 'error', + debug = "debug", + error = "error" } // @public @@ -268,6 +294,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 +328,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 +345,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:125: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:126: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:139: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/Package.swift b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/Package.swift new file mode 100644 index 00000000..4a65cb79 --- /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: "RNShopifyCheckoutKitProtocolRelay", + platforms: [.iOS(.v13), .macOS(.v10_15)], + products: [ + .library(name: "RNShopifyCheckoutKitProtocolRelay", targets: ["RNShopifyCheckoutKitProtocolRelay"]) + ], + dependencies: [ + .package(path: "../../../../../../protocol/languages/swift") + ], + targets: [ + .target( + name: "RNShopifyCheckoutKitProtocolRelay", + dependencies: [ + .product(name: "ShopifyCheckoutProtocol", package: "swift") + ], + path: ".", + sources: ["ProtocolRelay.swift"] + ), + .testTarget( + name: "RNShopifyCheckoutKitProtocolRelayTests", + dependencies: ["RNShopifyCheckoutKitProtocolRelay"], + 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..a41d11bf --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ProtocolRelay.swift @@ -0,0 +1,53 @@ +import Foundation +#if COCOAPODS + import ShopifyCheckoutKit + + extension CheckoutProtocol.Client: @retroactive CheckoutCommunicationProtocol {} +#else + import ShopifyCheckoutProtocol +#endif + +struct DispatchEnvelope: Encodable { + let type: String + let payload: Payload +} + +// Bridges native CheckoutProtocol notifications to the React Native onDispatch +// event stream. Payloads are emitted in protocol wire casing; JS performs the +// schema-aware conversion to the public camelCase shape with QuickType. +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 +) { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + guard + let data = try? encoder.encode(DispatchEnvelope(type: type, payload: payload)), + let json = String(data: data, encoding: .utf8) + 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 66008195..15dc1aef 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 @@ -1,6 +1,7 @@ #import #import #import +#import // Registers the Swift module class (ShopifyCheckoutKit.swift) with the RN // runtime under the name 'RCTShopifyCheckoutKit', extending the codegen @@ -17,7 +18,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 1f64ec2d..b77b7fa2 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 @@ -25,13 +25,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 } @@ -89,7 +82,6 @@ class RCTShopifyCheckoutKit: NSObject { @objc func dismiss() { DispatchQueue.main.async { - self.pendingDispatchCallback = nil self.checkoutSheet?.dismiss(animated: true) self.checkoutSheet = nil } @@ -99,17 +91,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 } } @@ -279,12 +284,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() @@ -320,18 +322,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 @@ -343,7 +340,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/ProtocolRelayTests.swift b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/Tests/ProtocolRelayTests.swift new file mode 100644 index 00000000..d7b99bde --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/Tests/ProtocolRelayTests.swift @@ -0,0 +1,114 @@ +import Foundation +@testable import RNShopifyCheckoutKitProtocolRelay +import ShopifyCheckoutProtocol +import Testing + +@Suite("Protocol Relay Tests") +struct ProtocolRelayTests { + @Test func envelopeEncodesTypeAndWireCasePayload() throws { + let payload = SnakePayload(continueURL: "https://example.com", lineItems: []) + let envelope = DispatchEnvelope(type: "ec.start", payload: payload) + let data = try JSONEncoder().encode(envelope) + let json = try #require(String(data: data, encoding: .utf8)) + + 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["continue_url"] as? String == "https://example.com") + #expect(payloadDict["line_items"] as? [Any] != nil) + #expect(payloadDict["continueUrl"] == nil) + #expect(payloadDict["lineItems"] == 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["line_items"] as? [[String: Any]]) + #expect(lineItems.count == 1) + let firstItem = try #require(lineItems.first?["item"] as? [String: Any]) + #expect(firstItem["image_url"] as? String == "https://example.com/image.png") + let ucp = try #require(payload["ucp"] as? [String: Any]) + let paymentHandlers = try #require(ucp["payment_handlers"] as? [String: Any]) + #expect(paymentHandlers["com.example.loyalty_gold"] != nil) + } + + @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": { + "com.example.loyalty_gold": [] + } + }, + "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 1140e41d..3f4aec9c 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..484fc27a 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 @@ -6,55 +6,87 @@ "android/src/main/AndroidManifestNew.xml", "android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListener.java", "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/ProtocolRelayTest.kt", "ios/AcceleratedCheckoutButtons.swift", "ios/AcceleratedCheckoutButtons+Extensions.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/ProtocolRelayTests.swift", "lib/commonjs/components/AcceleratedCheckoutButtons.js", "lib/commonjs/components/AcceleratedCheckoutButtons.js.map", + "lib/commonjs/configuration.js", + "lib/commonjs/configuration.js.map", "lib/commonjs/context.js", "lib/commonjs/context.js.map", "lib/commonjs/dispatch-events.js", "lib/commonjs/dispatch-events.js.map", - "lib/commonjs/errors.d.js", - "lib/commonjs/errors.d.js.map", + "lib/commonjs/enums.js", + "lib/commonjs/enums.js.map", + "lib/commonjs/errors.js", + "lib/commonjs/errors.js.map", "lib/commonjs/index.d.js", "lib/commonjs/index.d.js.map", "lib/commonjs/index.js", "lib/commonjs/index.js.map", + "lib/commonjs/present-dispatcher.js", + "lib/commonjs/present-dispatcher.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", "lib/commonjs/specs/RCTAcceleratedCheckoutButtonsNativeComponent.js.map", "lib/module/components/AcceleratedCheckoutButtons.js", "lib/module/components/AcceleratedCheckoutButtons.js.map", + "lib/module/configuration.js", + "lib/module/configuration.js.map", "lib/module/context.js", "lib/module/context.js.map", "lib/module/dispatch-events.js", "lib/module/dispatch-events.js.map", - "lib/module/errors.d.js", - "lib/module/errors.d.js.map", + "lib/module/enums.js", + "lib/module/enums.js.map", + "lib/module/errors.js", + "lib/module/errors.js.map", "lib/module/index.d.js", "lib/module/index.d.js.map", "lib/module/index.js", "lib/module/index.js.map", + "lib/module/present-dispatcher.js", + "lib/module/present-dispatcher.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", "lib/module/specs/RCTAcceleratedCheckoutButtonsNativeComponent.js.map", "lib/typescript/src/components/AcceleratedCheckoutButtons.d.ts", "lib/typescript/src/components/AcceleratedCheckoutButtons.d.ts.map", + "lib/typescript/src/configuration.d.ts", + "lib/typescript/src/configuration.d.ts.map", "lib/typescript/src/context.d.ts", "lib/typescript/src/context.d.ts.map", "lib/typescript/src/dispatch-events.d.ts", "lib/typescript/src/dispatch-events.d.ts.map", + "lib/typescript/src/enums.d.ts", + "lib/typescript/src/enums.d.ts.map", + "lib/typescript/src/errors.d.ts", + "lib/typescript/src/errors.d.ts.map", "lib/typescript/src/index.d.ts", "lib/typescript/src/index.d.ts.map", + "lib/typescript/src/present-dispatcher.d.ts", + "lib/typescript/src/present-dispatcher.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", @@ -63,11 +95,15 @@ "package.json", "RNShopifyCheckoutKit.podspec", "src/components/AcceleratedCheckoutButtons.tsx", + "src/configuration.ts", "src/context.tsx", "src/dispatch-events.ts", - "src/errors.d.ts", + "src/enums.ts", + "src/errors.ts", "src/index.d.ts", "src/index.ts", + "src/present-dispatcher.ts", + "src/protocol.ts", "src/specs/NativeShopifyCheckoutKit.ts", "src/specs/RCTAcceleratedCheckoutButtonsNativeComponent.ts" ] diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/scripts/prepare-api-extract.mjs b/platforms/react-native/modules/@shopify/checkout-kit-react-native/scripts/prepare-api-extract.mjs index 1836cf12..864c9e80 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/scripts/prepare-api-extract.mjs +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/scripts/prepare-api-extract.mjs @@ -3,8 +3,8 @@ // can analyze it. // // The module source uses hand-written ambient declaration files (for example, -// `src/index.d.ts` and `src/errors.d.ts`) that runtime sources import via paths -// like `'./index.d'`. tsc preserves those literals when emitting `.d.ts` for the runtime +// `src/index.d.ts`) that runtime sources import via paths like `'./index.d'`. +// tsc preserves those literals when emitting `.d.ts` for the runtime // modules, but the declaration sources themselves are NOT copied into the output // tree. The result is that `lib/typescript/src/index.d.ts` imports from // `'./index.d'`, which TypeScript module resolution resolves back to the same file @@ -14,7 +14,7 @@ // a non-colliding name (e.g. `src/_types/index.d.ts`) and rewrites the imports in // the compiled outputs to point at the relocated files. -import {readFileSync, writeFileSync, copyFileSync, mkdirSync, readdirSync, statSync} from 'node:fs'; +import {readFileSync, writeFileSync, mkdirSync, readdirSync, statSync} from 'node:fs'; import {dirname, join, relative} from 'node:path'; import {fileURLToPath} from 'node:url'; @@ -45,7 +45,8 @@ function relocateDeclarations() { for (const name of DECLARATION_BASENAMES) { const source = join(SRC_DIR, `${name}.d.ts`); const target = join(RELOCATED_DIR, `${name}.d.ts`); - copyFileSync(source, target); + const contents = readFileSync(source, 'utf8').replace(/(['"])\.\//g, '$1../'); + writeFileSync(target, contents); } } diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/configuration.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/configuration.ts new file mode 100644 index 00000000..9734c1e9 --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/configuration.ts @@ -0,0 +1,55 @@ +import {ColorScheme, LogLevel} from './enums'; +import type {Configuration} from './index.d'; +import type RNShopifyCheckoutKit from './specs/NativeShopifyCheckoutKit'; + +// TurboModule codegen doesn't support TypeScript string literal unions or +// enums — spec types collapse to plain `string`. These sets are used by the +// coercion helpers below to narrow the string back to the consumer-facing +// enum, falling back to a safe default if native returns an unknown value. +const colorSchemeValues: ReadonlySet = new Set( + Object.values(ColorScheme), +); +const logLevelValues: ReadonlySet = new Set(Object.values(LogLevel)); + +type NativeConfigurationResult = ReturnType< + typeof RNShopifyCheckoutKit.getConfig +>; + +/** + * Coerces a native Configuration result into the consumer-facing + * Configuration type. + * + * The TurboModule codegen spec can only express primitive types — string + * literal unions and TypeScript enums collapse to plain `string` at the + * bridge boundary. On the JS side consumers expect the typed `ColorScheme` + * and `LogLevel` enums, so we coerce those two fields here. The rest of + * the payload passes through unchanged. + */ +export function coerceConfigurationResult( + raw: NativeConfigurationResult, +): Configuration { + return { + ...raw, + logLevel: coerceLogLevel(raw.logLevel), + colorScheme: coerceColorScheme(raw.colorScheme), + } as Configuration; +} + +/** + * Narrows a raw string from the native bridge to the ColorScheme enum. + * Falls back to `automatic` if the native side returns an unrecognised + * value (e.g. future SDK version adds a new scheme). + */ +function coerceColorScheme(value: string): ColorScheme { + return colorSchemeValues.has(value) + ? (value as ColorScheme) + : ColorScheme.automatic; +} + +/** + * Narrows a raw string from the native bridge to the LogLevel enum. + * Falls back to `error` (the safest default) on unrecognised values. + */ +function coerceLogLevel(value: string): LogLevel { + return logLevelValues.has(value) ? (value as LogLevel) : LogLevel.error; +} 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 9d9e56c4..679ec0e9 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 @@ -2,6 +2,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; @@ -9,7 +10,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; } @@ -58,9 +63,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/enums.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/enums.ts new file mode 100644 index 00000000..05a2926a --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/enums.ts @@ -0,0 +1,35 @@ +export enum ColorScheme { + automatic = 'automatic', + light = 'light', + dark = 'dark', + web = 'web_default', +} + +/** + * Log level for Checkout Kit. + * Controls the verbosity of logs emitted by the native SDK. + * @defaults to error + */ +export enum LogLevel { + /** + * Show debug logs. + */ + debug = 'debug', + /** + * Show only error logs. + */ + error = 'error', +} + +/** + * Available wallet types for accelerated checkout + */ +export enum AcceleratedCheckoutWallet { + shopPay = 'shopPay', + applePay = 'applePay', +} + +export enum ApplePayContactField { + email = 'email', + phone = 'phone', +} diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/errors.d.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/errors.ts similarity index 71% rename from platforms/react-native/modules/@shopify/checkout-kit-react-native/src/errors.d.ts rename to platforms/react-native/modules/@shopify/checkout-kit-react-native/src/errors.ts index 1a7083d2..d715b39f 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/errors.d.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/errors.ts @@ -21,26 +21,24 @@ export enum CheckoutNativeErrorType { } function getCheckoutErrorCode(code: string | undefined): CheckoutErrorCode { - const codeKey = Object.keys(CheckoutErrorCode).find( - key => CheckoutErrorCode[key as keyof typeof CheckoutErrorCode] === code, - ); - - return codeKey ? CheckoutErrorCode[codeKey] : CheckoutErrorCode.unknown; + return Object.values(CheckoutErrorCode).includes(code as CheckoutErrorCode) + ? (code as CheckoutErrorCode) + : CheckoutErrorCode.unknown; } type BridgeError = { __typename: CheckoutNativeErrorType; code: CheckoutErrorCode; message: string; + statusCode?: number; }; -export type CheckoutNativeError = - | BridgeError - | (BridgeError & {statusCode: number}); +export type CheckoutNativeError = BridgeError; class GenericErrorWithCode { message: string; code: CheckoutErrorCode; + name: string; constructor(exception: CheckoutNativeError) { this.code = getCheckoutErrorCode(exception.code); @@ -53,10 +51,11 @@ class GenericNetworkError { code: CheckoutErrorCode; message: string; statusCode: number; + name: string; constructor(exception: CheckoutNativeError) { this.code = getCheckoutErrorCode(exception.code); - this.statusCode = exception.statusCode; + this.statusCode = exception.statusCode as number; this.message = exception.message; this.name = this.constructor.name; } @@ -98,3 +97,22 @@ export type CheckoutException = | ConfigurationError | GenericError | InternalError; + +export function parseCheckoutError( + exception: CheckoutNativeError, +): CheckoutException { + switch (exception?.__typename) { + case CheckoutNativeErrorType.InternalError: + return new InternalError(exception); + case CheckoutNativeErrorType.ConfigurationError: + return new ConfigurationError(exception); + case CheckoutNativeErrorType.CheckoutClientError: + return new CheckoutClientError(exception); + case CheckoutNativeErrorType.CheckoutHTTPError: + return new CheckoutHTTPError(exception); + case CheckoutNativeErrorType.CheckoutExpiredError: + return new CheckoutExpiredError(exception); + default: + return new GenericError(exception); + } +} 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 c9ec716e..53619e85 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 @@ -1,4 +1,21 @@ import type {CheckoutException} from './errors'; +import type {ProtocolHandlers} from './protocol'; +import type { + ApplePayContactField, + ColorScheme, + LogLevel, +} from './enums'; +export { + AcceleratedCheckoutWallet, + ApplePayContactField, + ColorScheme, + LogLevel, +} from './enums'; +export type { + Checkout, + CheckoutProtocolPayloads, + ProtocolHandlers, +} from './protocol'; export type Maybe = T | undefined; @@ -14,29 +31,6 @@ export interface Features { handleGeolocationRequests: boolean; } -export enum ColorScheme { - automatic = 'automatic', - light = 'light', - dark = 'dark', - web = 'web_default', -} - -/** - * Log level for Checkout Kit. - * Controls the verbosity of logs emitted by the native SDK. - * @defaults to error - */ -export enum LogLevel { - /** - * Show debug logs. - */ - debug = 'debug', - /** - * Show only error logs. - */ - error = 'error', -} - export interface IosColors { /** * A HEX color value for customizing the color of the progress bar. @@ -157,7 +151,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. @@ -190,19 +184,6 @@ export interface PresentCallbacks { onGeolocationRequest?: (event: GeolocationRequestEvent) => void; } -/** - * Available wallet types for accelerated checkout - */ -export enum AcceleratedCheckoutWallet { - shopPay = 'shopPay', - applePay = 'applePay', -} - -export enum ApplePayContactField { - email = 'email', - phone = 'phone', -} - /** * Configuration for AcceleratedCheckouts */ @@ -273,8 +254,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 3a93c395..9054fff7 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 @@ -2,23 +2,29 @@ import {PermissionsAndroid, Platform} from 'react-native'; import type {PermissionStatus} from 'react-native'; import RNShopifyCheckoutKit from './specs/NativeShopifyCheckoutKit'; import {ShopifyCheckoutProvider, useShopifyCheckout} from './context'; -import {ApplePayContactField, ColorScheme, LogLevel} from './index.d'; +import {ApplePayContactField, ColorScheme, LogLevel} from './enums'; import { DispatchEventParityError, - isSdkLifecycleEventType, verifyDispatchEventParity, - type SdkLifecycleEventType, } from './dispatch-events'; +import {coerceConfigurationResult} from './configuration'; +import { + createPresentDispatcher, + LifecycleEventParseError, +} from './present-dispatcher'; import type { AcceleratedCheckoutConfiguration, + AndroidAutomaticColors, + AndroidColors, Configuration, Features, GeolocationRequestEvent, + IosColors, PresentCallbacks, ShopifyCheckoutKit, } from './index.d'; -import {AcceleratedCheckoutWallet} from './index.d'; -import type {CheckoutException, CheckoutNativeError} from './errors.d'; +import {AcceleratedCheckoutWallet} from './enums'; +import type {CheckoutException} from './errors'; import { CheckoutExpiredError, CheckoutClientError, @@ -27,8 +33,8 @@ import { InternalError, CheckoutNativeErrorType, GenericError, -} from './errors.d'; -import {CheckoutErrorCode} from './errors.d'; +} from './errors'; +import {CheckoutErrorCode} from './errors'; import { ApplePayLabel, ApplePayStyle, @@ -37,23 +43,22 @@ import type { AcceleratedCheckoutButtonsProps, RenderStateChangeEvent, } from './components/AcceleratedCheckoutButtons'; +import {CheckoutProtocol} from './protocol'; +import type { + Checkout, + CheckoutProtocolPayloads, + ProtocolHandlers, +} from './protocol'; const defaultFeatures: Features = { handleGeolocationRequests: true, }; -// TurboModule codegen doesn't support TypeScript string literal unions or -// enums — spec types collapse to plain `string`. These sets are used by the -// coercion helpers below to narrow the string back to the consumer-facing -// enum, falling back to a safe default if native returns an unknown value. -const colorSchemeValues: ReadonlySet = new Set( - Object.values(ColorScheme), -); -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 — @@ -95,6 +100,7 @@ class ShopifyCheckout implements ShopifyCheckoutKit { * Dismisses the currently displayed checkout sheet */ public dismiss(): void { + this.releaseDispatchSubscription(); RNShopifyCheckoutKit.dismiss(); } @@ -102,13 +108,42 @@ 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, subscribedMethods} = createPresentDispatcher({ + callbacks, + protocol, + handleDefaultGeolocationRequests: this.featureEnabled( + 'handleGeolocationRequests', + ), + handleDefaultGeolocationRequest: () => + this.handleDefaultGeolocationRequest(), + respondToGeolocationRequest: allow => + this.respondToGeolocationRequest(allow), + }); + + if (dispatcher) { + this.dispatchSubscription = RNShopifyCheckoutKit.onDispatch( + envelopeJson => { + const result = dispatcher(envelopeJson); + if (result.terminal) { + this.releaseDispatchSubscription(); + } + }, + ); + } + + RNShopifyCheckoutKit.present(checkoutUrl, subscribedMethods); } /** @@ -116,7 +151,7 @@ class ShopifyCheckout implements ShopifyCheckoutKit { * @returns The current Configuration */ public getConfig(): Configuration { - return this.coerceConfigurationResult(RNShopifyCheckoutKit.getConfig()); + return coerceConfigurationResult(RNShopifyCheckoutKit.getConfig()); } /** @@ -137,7 +172,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 @@ -265,6 +302,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 @@ -276,115 +318,6 @@ 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. - */ - private buildDispatcher( - callbacks: PresentCallbacks | undefined, - ): ((envelopeJson: string) => void) | null { - const needsDefaultGeolocation = - Platform.OS === 'android' && - this.featureEnabled('handleGeolocationRequests'); - - if (!callbacks && !needsDefaultGeolocation) { - return null; - } - - return (envelopeJson: string) => { - let envelope: unknown; - try { - envelope = JSON.parse(envelopeJson); - } catch { - logParseError('envelope is not valid JSON', envelopeJson); - return; - } - - if (!isPlainObject(envelope) || typeof envelope.type !== 'string') { - logParseError( - 'envelope is missing a string `type` discriminator', - envelopeJson, - ); - return; - } - - const {type, payload} = envelope; - - if (isSdkLifecycleEventType(type)) { - this.routeSdkLifecycleEvent( - type, - payload, - envelopeJson, - callbacks, - needsDefaultGeolocation, - ); - 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. - // 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.', - ); - }; - } - - /** - * Routes a validated SDK lifecycle envelope to the matching user - * callback (or the default Android geolocation handler). Payload - * shapes are validated per case before invoking consumer code so a - * native-side regression surfaces as a `LifecycleEventParseError` - * with the offending raw envelope attached. - */ - private routeSdkLifecycleEvent( - type: SdkLifecycleEventType, - payload: unknown, - envelopeJson: string, - callbacks: PresentCallbacks | undefined, - needsDefaultGeolocation: boolean, - ): void { - switch (type) { - case 'close': - callbacks?.onClose?.(); - return; - case 'fail': { - const failPayload = validateFailPayload(payload); - if (failPayload == null) { - logParseError('`fail` envelope payload is malformed', envelopeJson); - return; - } - callbacks?.onFail?.(this.parseCheckoutError(failPayload)); - return; - } - case 'geolocationRequest': { - const geoPayload = validateGeolocationRequestPayload(payload); - if (geoPayload == null) { - logParseError( - '`geolocationRequest` envelope payload is malformed', - envelopeJson, - ); - return; - } - if (callbacks?.onGeolocationRequest) { - callbacks.onGeolocationRequest({ - ...geoPayload, - respond: allow => this.respondToGeolocationRequest(allow), - }); - } else if (needsDefaultGeolocation) { - this.handleDefaultGeolocationRequest(); - } - return; - } - } - } - /** * Default Android geolocation handler — requests platform permissions * and forwards the resolved grant state back to the native SDK. @@ -415,121 +348,6 @@ class ShopifyCheckout implements ShopifyCheckoutKit { return status === 'granted'; } - /** - * Coerces a native Configuration result into the consumer-facing - * Configuration type. - * - * The TurboModule codegen spec can only express primitive types — string - * literal unions and TypeScript enums collapse to plain `string` at the - * bridge boundary. On the JS side consumers expect the typed `ColorScheme` - * and `LogLevel` enums, so we coerce those two fields here. The rest of - * the rest of the payload passes through unchanged. - */ - private coerceConfigurationResult( - raw: ReturnType, - ): Configuration { - return { - ...raw, - logLevel: this.coerceLogLevel(raw.logLevel), - colorScheme: this.coerceColorScheme(raw.colorScheme), - } as Configuration; - } - - /** - * Narrows a raw string from the native bridge to the ColorScheme enum. - * Falls back to `automatic` if the native side returns an unrecognised - * value (e.g. future SDK version adds a new scheme). - */ - private coerceColorScheme(value: string): ColorScheme { - return colorSchemeValues.has(value) - ? (value as ColorScheme) - : ColorScheme.automatic; - } - - /** - * Narrows a raw string from the native bridge to the LogLevel enum. - * Falls back to `error` (the safest default) on unrecognised values. - */ - private coerceLogLevel(value: string): LogLevel { - return logLevelValues.has(value) ? (value as LogLevel) : LogLevel.error; - } - - /** - * Converts native checkout errors into appropriate error class instances - * @param exception The native error to parse - * @returns Appropriate CheckoutException instance - */ - private parseCheckoutError( - exception: CheckoutNativeError, - ): CheckoutException { - switch (exception?.__typename) { - case CheckoutNativeErrorType.InternalError: - return new InternalError(exception); - case CheckoutNativeErrorType.ConfigurationError: - return new ConfigurationError(exception); - case CheckoutNativeErrorType.CheckoutClientError: - return new CheckoutClientError(exception); - case CheckoutNativeErrorType.CheckoutHTTPError: - return new CheckoutHTTPError(exception); - case CheckoutNativeErrorType.CheckoutExpiredError: - return new CheckoutExpiredError(exception); - default: - return new GenericError(exception); - } - } -} - -export class LifecycleEventParseError extends Error { - constructor(message?: string, options?: ErrorOptions) { - super(message, options); - this.name = 'LifecycleEventParseError'; - - if (Error.captureStackTrace) { - Error.captureStackTrace(this, LifecycleEventParseError); - } - } -} - -// --- internal helpers --- - -type GeolocationRequestPayload = Pick; - -function isPlainObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -/** - * Narrow validator for `fail` envelope payloads. Only confirms the - * shape the JS dispatcher relies on — full coercion happens later in - * `parseCheckoutError`. Returns `null` on shape mismatch so the caller - * can log a `LifecycleEventParseError` instead of crashing user code. - */ -function validateFailPayload(payload: unknown): CheckoutNativeError | null { - if (!isPlainObject(payload)) return null; - if (typeof payload.__typename !== 'string') return null; - if (typeof payload.message !== 'string') return null; - if (typeof payload.code !== 'string') return null; - if ('statusCode' in payload && typeof payload.statusCode !== 'number') { - return null; - } - return payload as unknown as CheckoutNativeError; -} - -function validateGeolocationRequestPayload( - payload: unknown, -): GeolocationRequestPayload | null { - if (!isPlainObject(payload)) return null; - if (typeof payload.origin !== 'string') return null; - return {origin: payload.origin}; -} - -function logParseError(detail: string, raw: string): void { - const err = new LifecycleEventParseError( - `Failed to handle present() dispatcher envelope: ${detail}`, - {cause: detail}, - ); - // eslint-disable-next-line no-console - console.error(err, raw); } // API @@ -538,8 +356,10 @@ export { ApplePayContactField, ApplePayLabel, ApplePayStyle, + CheckoutProtocol, ColorScheme, DispatchEventParityError, + LifecycleEventParseError, LogLevel, ShopifyCheckout, ShopifyCheckoutProvider, @@ -562,11 +382,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/present-dispatcher.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/present-dispatcher.ts new file mode 100644 index 00000000..c492bf9d --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/present-dispatcher.ts @@ -0,0 +1,262 @@ +import {Platform} from 'react-native'; +import { + isSdkLifecycleEventType, + type SdkLifecycleEventType, +} from './dispatch-events'; +import {parseCheckoutError} from './errors'; +import type {CheckoutNativeError} from './errors'; +import {decodeProtocolPayload} from './protocol'; +import type {CheckoutProtocolPayloads, ProtocolHandlers} from './protocol'; +import type {GeolocationRequestEvent, PresentCallbacks} from './index.d'; + +export class LifecycleEventParseError extends Error { + constructor(message?: string, options?: ErrorOptions) { + super(message, options); + this.name = 'LifecycleEventParseError'; + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, LifecycleEventParseError); + } + } +} + +export interface PresentDispatchResult { + terminal: boolean; +} + +type PresentDispatcher = (envelopeJson: string) => PresentDispatchResult; + +interface CreatePresentDispatcherOptions { + callbacks?: PresentCallbacks; + protocol?: ProtocolHandlers; + handleDefaultGeolocationRequests: boolean; + handleDefaultGeolocationRequest: () => void | Promise; + respondToGeolocationRequest: (allow: boolean) => void; +} + +interface PresentDispatcherHandle { + dispatcher: PresentDispatcher | null; + subscribedMethods: string[]; +} + +type GeolocationRequestPayload = Pick; + +export function createPresentDispatcher({ + callbacks, + protocol, + handleDefaultGeolocationRequests, + handleDefaultGeolocationRequest, + respondToGeolocationRequest, +}: CreatePresentDispatcherOptions): PresentDispatcherHandle { + const subscribedMethods = getSubscribedProtocolMethods(protocol); + const needsDefaultGeolocation = + Platform.OS === 'android' && handleDefaultGeolocationRequests; + + if (!callbacks && !needsDefaultGeolocation && subscribedMethods.length === 0) { + return {dispatcher: null, subscribedMethods}; + } + + return { + subscribedMethods, + dispatcher: envelopeJson => + dispatchEnvelope(envelopeJson, { + callbacks, + protocol, + needsDefaultGeolocation, + handleDefaultGeolocationRequest, + respondToGeolocationRequest, + }), + }; +} + +function getSubscribedProtocolMethods(protocol?: ProtocolHandlers): string[] { + return Object.entries(protocol ?? {}) + .filter(([, handler]) => typeof handler === 'function') + .map(([method]) => method); +} + +function dispatchEnvelope( + envelopeJson: string, + options: Omit< + CreatePresentDispatcherOptions, + 'handleDefaultGeolocationRequests' + > & {needsDefaultGeolocation: boolean}, +): PresentDispatchResult { + let envelope: unknown; + try { + envelope = JSON.parse(envelopeJson); + } catch { + logParseError('envelope is not valid JSON', envelopeJson); + return {terminal: false}; + } + + if (!isPlainObject(envelope) || typeof envelope.type !== 'string') { + logParseError( + 'envelope is missing a string `type` discriminator', + envelopeJson, + ); + return {terminal: false}; + } + + const {type, payload} = envelope; + + if (isSdkLifecycleEventType(type)) { + return routeSdkLifecycleEvent(type, payload, envelopeJson, options); + } + + // 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 = + options.protocol == null + ? undefined + : ( + options.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 {terminal: false}; + } + + let decodedPayload: + | CheckoutProtocolPayloads[keyof CheckoutProtocolPayloads] + | undefined; + try { + decodedPayload = decodeProtocolPayload(type, payload); + } catch (error) { + logParseError( + `protocol envelope "${type}" payload failed schema conversion: ${String(error)}`, + envelopeJson, + ); + return {terminal: false}; + } + + if (decodedPayload == null) { + logParseError( + `protocol envelope "${type}" has no registered decoder`, + envelopeJson, + ); + return {terminal: false}; + } + + protocolHandler(decodedPayload); + return {terminal: false}; + } + + // Loud default. The parity check at construction time should have + // 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}". ` + + '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.', + ); + + return {terminal: false}; +} + +/** + * Routes a validated SDK lifecycle envelope to the matching user + * callback (or the default Android geolocation handler). Payload + * shapes are validated per case before invoking consumer code so a + * native-side regression surfaces as a `LifecycleEventParseError` + * with the offending raw envelope attached. + */ +function routeSdkLifecycleEvent( + type: SdkLifecycleEventType, + payload: unknown, + envelopeJson: string, + { + callbacks, + needsDefaultGeolocation, + handleDefaultGeolocationRequest, + respondToGeolocationRequest, + }: Omit & { + needsDefaultGeolocation: boolean; + }, +): PresentDispatchResult { + switch (type) { + case 'close': + callbacks?.onClose?.(); + return {terminal: true}; + case 'fail': { + const failPayload = validateFailPayload(payload); + if (failPayload == null) { + logParseError('`fail` envelope payload is malformed', envelopeJson); + return {terminal: true}; + } + callbacks?.onFail?.(parseCheckoutError(failPayload)); + return {terminal: true}; + } + case 'geolocationRequest': { + const geoPayload = validateGeolocationRequestPayload(payload); + if (geoPayload == null) { + logParseError( + '`geolocationRequest` envelope payload is malformed', + envelopeJson, + ); + return {terminal: false}; + } + if (callbacks?.onGeolocationRequest) { + callbacks.onGeolocationRequest({ + ...geoPayload, + respond: allow => respondToGeolocationRequest(allow), + }); + } else if (needsDefaultGeolocation) { + handleDefaultGeolocationRequest(); + } + return {terminal: false}; + } + } +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Narrow validator for `fail` envelope payloads. Only confirms the + * shape the JS dispatcher relies on — full coercion happens later in + * `parseCheckoutError`. Returns `null` on shape mismatch so the caller + * can log a `LifecycleEventParseError` instead of crashing user code. + */ +function validateFailPayload(payload: unknown): CheckoutNativeError | null { + if (!isPlainObject(payload)) return null; + if (typeof payload.__typename !== 'string') return null; + if (typeof payload.message !== 'string') return null; + if (typeof payload.code !== 'string') return null; + if ('statusCode' in payload && typeof payload.statusCode !== 'number') { + return null; + } + return payload as unknown as CheckoutNativeError; +} + +function validateGeolocationRequestPayload( + payload: unknown, +): GeolocationRequestPayload | null { + if (!isPlainObject(payload)) return null; + if (typeof payload.origin !== 'string') return null; + return {origin: payload.origin}; +} + +function logParseError(detail: string, raw: string): void { + const err = new LifecycleEventParseError( + `Failed to handle present() dispatcher envelope: ${detail}`, + {cause: detail}, + ); + // eslint-disable-next-line no-console + console.error(err, raw); +} 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..85757906 --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/protocol.ts @@ -0,0 +1,61 @@ +import {Convert, 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; +}>; + +type ProtocolPayloadDecoder = ( + payload: unknown, +) => CheckoutProtocolPayloads[K]; + +// Keep this map exhaustive for CheckoutProtocolPayloads. When new protocol +// methods are added, TypeScript fails until their QuickType decoder is wired in. +const protocolPayloadDecoders = { + [CheckoutProtocol.start]: decodeWith(Convert.toCheckout), +} satisfies { + [K in keyof CheckoutProtocolPayloads]: ProtocolPayloadDecoder; +}; + +export function decodeProtocolPayload( + method: K, + payload: unknown, +): CheckoutProtocolPayloads[K]; +export function decodeProtocolPayload( + method: string, + payload: unknown, +): CheckoutProtocolPayloads[keyof CheckoutProtocolPayloads] | undefined; +export function decodeProtocolPayload( + method: string, + payload: unknown, +): CheckoutProtocolPayloads[keyof CheckoutProtocolPayloads] | undefined { + const decoder = decoderFor(method); + return decoder?.(payload); +} + +function decodeWith(converter: (json: string) => T): (payload: unknown) => T { + return payload => converter(JSON.stringify(payload)); +} + +function decoderFor( + method: string, +): + | ((payload: unknown) => CheckoutProtocolPayloads[keyof CheckoutProtocolPayloads]) + | undefined { + return protocolPayloadDecoders[ + method as keyof typeof protocolPayloadDecoders + ] as + | ((payload: unknown) => CheckoutProtocolPayloads[keyof CheckoutProtocolPayloads]) + | undefined; +} 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 488a6533..f82d912e 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 @@ -1,4 +1,4 @@ -import type {TurboModule} from 'react-native'; +import type {CodegenTypes, TurboModule} from 'react-native'; import {TurboModuleRegistry} from 'react-native'; type IosColorsSpec = { @@ -47,9 +47,11 @@ type ConfigurationResultSpec = { }; export interface Spec extends TurboModule { + readonly onDispatch: CodegenTypes.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..9b46fe1a 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,117 @@ describe('ShopifyCheckoutKit', () => { }); }); + describe('protocol handlers', () => { + const wireStartPayload = { + id: 'chk_123', + currency: 'USD', + line_items: [], + links: [], + status: 'incomplete', + totals: [], + ucp: { + version: '2026-04-08', + payment_handlers: { + loyalty_gold: [], + }, + }, + }; + + const decodedStartPayload = { + id: 'chk_123', + currency: 'USD', + lineItems: [], + links: [], + status: 'incomplete', + totals: [], + ucp: { + version: '2026-04-08', + status: undefined, + capabilities: undefined, + services: undefined, + paymentHandlers: { + loyalty_gold: [], + }, + }, + buyer: undefined, + context: undefined, + continueUrl: undefined, + expiresAt: undefined, + messages: undefined, + order: undefined, + payment: undefined, + signals: undefined, + }; + + 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: wireStartPayload}), + ); + expect(onStart).toHaveBeenCalledTimes(1); + expect(onStart).toHaveBeenCalledWith(decodedStartPayload); + 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 +539,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 +664,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..6d86a0ef --- /dev/null +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/protocol.test.ts @@ -0,0 +1,95 @@ +import { + CheckoutProtocol, + type Checkout, + type ProtocolHandlers, +} from '../src'; +import {decodeProtocolPayload} from '../src/protocol'; + +describe('CheckoutProtocol', () => { + describe('runtime values', () => { + it('exposes ec.start as the literal method string', () => { + expect(CheckoutProtocol.start).toBe('ec.start'); + }); + }); + + describe('wire payload decoding', () => { + it('returns undefined for methods without a registered payload decoder', () => { + expect(decodeProtocolPayload('ec.unknown', {})).toBeUndefined(); + }); + + it('converts schema fields to camelCase while preserving dynamic map keys', () => { + const decoded = decodeProtocolPayload(CheckoutProtocol.start, { + id: 'checkout-123', + currency: 'USD', + status: 'incomplete', + line_items: [], + totals: [], + links: [], + ucp: { + version: '2026-04-08', + payment_handlers: { + loyalty_gold: [ + { + id: 'handler-1', + version: '2026-04-08', + available_instruments: [ + { + type: 'card', + constraints: { + merchant_defined_key: true, + }, + }, + ], + }, + ], + 'com.example.loyalty_gold': [], + }, + }, + }); + + expect(decoded?.lineItems).toEqual([]); + expect(decoded?.ucp.paymentHandlers).toHaveProperty('loyalty_gold'); + expect( + Object.prototype.hasOwnProperty.call( + decoded?.ucp.paymentHandlers, + 'com.example.loyalty_gold', + ), + ).toBe(true); + const loyaltyHandlers = decoded?.ucp.paymentHandlers.loyalty_gold; + expect(loyaltyHandlers).toBeDefined(); + const loyaltyHandler = loyaltyHandlers?.[0]; + expect(loyaltyHandler?.availableInstruments?.[0]?.constraints).toEqual({ + merchant_defined_key: true, + }); + expect(loyaltyHandler).not.toHaveProperty('available_instruments'); + }); + }); + + 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..5dfaca5a 100644 --- a/platforms/react-native/pnpm-lock.yaml +++ b/platforms/react-native/pnpm-lock.yaml @@ -97,6 +97,10 @@ importers: version: 5.9.2 ../../protocol/languages/typescript: + dependencies: + '@babel/runtime': + specifier: ^7.25.0 + version: 7.28.3 devDependencies: typescript: specifier: ^5.9.2 @@ -104,6 +108,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 +121,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 @@ -1017,7 +1021,7 @@ packages: engines: {node: '>=6.9.0'} '@babel/template@7.28.6': - resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==, tarball: https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz} engines: {node: '>=6.9.0'} '@babel/traverse@7.28.3': @@ -1025,7 +1029,7 @@ packages: engines: {node: '>=6.9.0'} '@babel/traverse@7.29.0': - resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==, tarball: https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz} engines: {node: '>=6.9.0'} '@babel/types@7.28.2': @@ -1047,13 +1051,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 +1268,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 +1286,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 +1544,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 +1714,105 @@ 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] + libc: [glibc] '@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] + libc: [musl] '@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] + libc: [glibc] '@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] + libc: [glibc] '@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] + libc: [musl] '@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] + libc: [glibc] '@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] + libc: [glibc] '@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] + libc: [musl] '@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 +2748,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 +4539,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 +4626,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 +6567,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 faa0b40c..6270d487 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: e4ff5e146e7c4fc3fcee327b4f9d51f862ff6354 + RNShopifyCheckoutKit: 554996990cd493b49bc64e8f4a42bea4266e5a2d RNVectorIcons: be4d047a76ad307ffe54732208fb0498fcb8477f ShopifyCheckoutKit: 86b4e0976e98b17dc0a1de0399ec5a0a4f8171b5 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 diff --git a/platforms/react-native/sample/ios/ReactNativeTests/ShopifyCheckoutKitTests.swift b/platforms/react-native/sample/ios/ReactNativeTests/ShopifyCheckoutKitTests.swift index bbc17b33..5d675c42 100644 --- a/platforms/react-native/sample/ios/ReactNativeTests/ShopifyCheckoutKitTests.swift +++ b/platforms/react-native/sample/ios/ReactNativeTests/ShopifyCheckoutKitTests.swift @@ -280,24 +280,44 @@ 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() } wait(for: [presentAttemptCompleted], timeout: 1) } - // TODO: re-enable terminal-event tests (checkoutDidComplete, checkoutDidCancel, checkoutDidFail) - // once the iOS CheckoutDelegate lands upstream — parallels Android's - // DefaultCheckoutListener.onCheckoutCanceled / onCheckoutFailed. + func testCheckoutDidCancelDismissesCheckoutSheetFromRCTWrapper() { + let dismissCompleted = expectation(description: "checkout sheet dismissed") + let checkoutSheet = DismissTrackingViewController() + shopifyCheckoutKit.checkoutSheet = checkoutSheet + + shopifyCheckoutKit.checkoutDidCancel() + + DispatchQueue.main.async { + XCTAssertTrue(checkoutSheet.dismissCalled) + XCTAssertTrue(checkoutSheet.dismissAnimated) + XCTAssertNil(self.shopifyCheckoutKit.checkoutSheet) + dismissCompleted.fulfill() + } + + wait(for: [dismissCompleted], timeout: 1) + } +} + +private final class DismissTrackingViewController: UIViewController { + var dismissCalled = false + var dismissAnimated = false + + override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { + dismissCalled = true + dismissAnimated = flag + completion?() + } } diff --git a/platforms/react-native/sample/src/screens/CartScreen.tsx b/platforms/react-native/sample/src/screens/CartScreen.tsx index 638f8d29..7f95ee1f 100644 --- a/platforms/react-native/sample/src/screens/CartScreen.tsx +++ b/platforms/react-native/sample/src/screens/CartScreen.tsx @@ -17,6 +17,7 @@ import { AcceleratedCheckoutButtons, ApplePayLabel, AcceleratedCheckoutWallet, + CheckoutProtocol, } from '@shopify/checkout-kit-react-native'; import {useConfig} from '../context/Config'; import useShopify from '../hooks/useShopify'; @@ -69,10 +70,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/protocol/languages/typescript/package.json b/protocol/languages/typescript/package.json index 35c5eab6..feb5b05c 100644 --- a/protocol/languages/typescript/package.json +++ b/protocol/languages/typescript/package.json @@ -4,7 +4,9 @@ "private": true, "license": "MIT", "description": "Generated TypeScript models for the Shopify Checkout Kit protocol.", - "types": "src/index.ts", + "main": "src/index.ts", + "react-native": "src/index.ts", + "types": "src/index.d.ts", "scripts": { "codegen": "../../scripts/generate_models.sh --lang typescript", "typecheck": "tsc --noEmit" @@ -12,6 +14,9 @@ "files": [ "src" ], + "dependencies": { + "@babel/runtime": "^7.25.0" + }, "devDependencies": { "typescript": "^5.9.2" } 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..bd76bf94 --- /dev/null +++ b/protocol/languages/typescript/src/generated/Models.d.ts @@ -0,0 +1,2920 @@ +/** + * 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; +} +export declare class Convert { + static toCheckout(json: string): Checkout; + static checkoutToJson(value: Checkout): string; + static toPaymentAccountInfo(json: string): PaymentAccountInfo; + static paymentAccountInfoToJson(value: PaymentAccountInfo): string; + static toAdjustment(json: string): Adjustment; + static adjustmentToJson(value: Adjustment): string; + static toAmount(json: string): number; + static amountToJson(value: number): string; + static toAvailablePaymentInstrument(json: string): AvailablePaymentInstrument; + static availablePaymentInstrumentToJson(value: AvailablePaymentInstrument): string; + static toBinding(json: string): TokenBinding; + static bindingToJson(value: TokenBinding): string; + static toBusinessFulfillmentConfig(json: string): BusinessFulfillmentConfig; + static businessFulfillmentConfigToJson(value: BusinessFulfillmentConfig): string; + static toBuyer(json: string): Buyer; + static buyerToJson(value: Buyer): string; + static toCardCredential(json: string): CardCredential; + static cardCredentialToJson(value: CardCredential): string; + static toCardPaymentInstrument(json: string): CardPaymentInstrument; + static cardPaymentInstrumentToJson(value: CardPaymentInstrument): string; + static toContext(json: string): Context; + static contextToJson(value: Context): string; + static toErrorCode(json: string): string; + static errorCodeToJson(value: string): string; + static toErrorResponse(json: string): ErrorResponse; + static errorResponseToJson(value: ErrorResponse): string; + static toExpectation(json: string): Expectation; + static expectationToJson(value: Expectation): string; + static toFulfillmentAvailableMethod(json: string): FulfillmentAvailableMethod; + static fulfillmentAvailableMethodToJson(value: FulfillmentAvailableMethod): string; + static toFulfillmentDestination(json: string): FulfillmentDestination; + static fulfillmentDestinationToJson(value: FulfillmentDestination): string; + static toFulfillmentEvent(json: string): FulfillmentEvent; + static fulfillmentEventToJson(value: FulfillmentEvent): string; + static toFulfillmentGroup(json: string): FulfillmentGroup; + static fulfillmentGroupToJson(value: FulfillmentGroup): string; + static toFulfillmentMethod(json: string): FulfillmentMethod; + static fulfillmentMethodToJson(value: FulfillmentMethod): string; + static toFulfillmentOption(json: string): FulfillmentOption; + static fulfillmentOptionToJson(value: FulfillmentOption): string; + static toFulfillment(json: string): Fulfillment; + static fulfillmentToJson(value: Fulfillment): string; + static toItem(json: string): Item; + static itemToJson(value: Item): string; + static toLineItem(json: string): LineItem; + static lineItemToJson(value: LineItem): string; + static toLink(json: string): Link; + static linkToJson(value: Link): string; + static toMerchantFulfillmentConfig(json: string): MerchantFulfillmentConfig; + static merchantFulfillmentConfigToJson(value: MerchantFulfillmentConfig): string; + static toMessageError(json: string): MessageError; + static messageErrorToJson(value: MessageError): string; + static toMessageInfo(json: string): MessageInfo; + static messageInfoToJson(value: MessageInfo): string; + static toMessageWarning(json: string): MessageWarning; + static messageWarningToJson(value: MessageWarning): string; + static toMessage(json: string): Message; + static messageToJson(value: Message): string; + static toOrderConfirmation(json: string): OrderConfirmation; + static orderConfirmationToJson(value: OrderConfirmation): string; + static toOrderLineItem(json: string): OrderLineItem; + static orderLineItemToJson(value: OrderLineItem): string; + static toPaymentCredential(json: string): PaymentCredential; + static paymentCredentialToJson(value: PaymentCredential): string; + static toPaymentIdentity(json: string): PaymentIdentity; + static paymentIdentityToJson(value: PaymentIdentity): string; + static toPaymentInstrument(json: string): PaymentInstrument; + static paymentInstrumentToJson(value: PaymentInstrument): string; + static toPlatformFulfillmentConfig(json: string): PlatformFulfillmentConfig; + static platformFulfillmentConfigToJson(value: PlatformFulfillmentConfig): string; + static toPostalAddress(json: string): PostalAddress; + static postalAddressToJson(value: PostalAddress): string; + static toRetailLocation(json: string): RetailLocation; + static retailLocationToJson(value: RetailLocation): string; + static toReverseDomainName(json: string): string; + static reverseDomainNameToJson(value: string): string; + static toShippingDestination(json: string): ShippingDestination; + static shippingDestinationToJson(value: ShippingDestination): string; + static toSignals(json: string): Signals; + static signalsToJson(value: Signals): string; + static toSignedAmount(json: string): number; + static signedAmountToJson(value: number): string; + static toTokenCredential(json: string): TokenCredential; + static tokenCredentialToJson(value: TokenCredential): string; + static toTotal(json: string): Total; + static totalToJson(value: Total): string; + static toTotals(json: string): Totals[]; + static totalsToJson(value: Totals[]): string; + static toPayment(json: string): Payment; + static paymentToJson(value: Payment): string; + static toOrder(json: string): Order; + static orderToJson(value: Order): string; + static toInstrumentsChangeResult(json: string): InstrumentsChangeResult; + static instrumentsChangeResultToJson(value: InstrumentsChangeResult): string; + static toCredentialResult(json: string): CredentialResult; + static credentialResultToJson(value: CredentialResult): string; +} diff --git a/protocol/languages/typescript/src/generated/Models.ts b/protocol/languages/typescript/src/generated/Models.ts index 6abcb558..2957cc37 100644 --- a/protocol/languages/typescript/src/generated/Models.ts +++ b/protocol/languages/typescript/src/generated/Models.ts @@ -1,3 +1,59 @@ +// To parse this data: +// +// import { Convert, Checkout, PaymentAccountInfo, Adjustment, AvailablePaymentInstrument, TokenBinding, BusinessFulfillmentConfig, Buyer, CardCredential, CardPaymentInstrument, Context, ErrorResponse, Expectation, FulfillmentAvailableMethod, FulfillmentDestination, FulfillmentEvent, FulfillmentGroup, FulfillmentMethod, FulfillmentOption, Fulfillment, Item, LineItem, Link, MerchantFulfillmentConfig, MessageError, MessageInfo, MessageWarning, Message, OrderConfirmation, OrderLineItem, PaymentCredential, PaymentIdentity, PaymentInstrument, PlatformFulfillmentConfig, PostalAddress, RetailLocation, ShippingDestination, Signals, TokenCredential, Total, Payment, Order, InstrumentsChangeResult, CredentialResult } from "./file"; +// +// const checkout = Convert.toCheckout(json); +// const paymentAccountInfo = Convert.toPaymentAccountInfo(json); +// const adjustment = Convert.toAdjustment(json); +// const amount = Convert.toAmount(json); +// const availablePaymentInstrument = Convert.toAvailablePaymentInstrument(json); +// const binding = Convert.toBinding(json); +// const businessFulfillmentConfig = Convert.toBusinessFulfillmentConfig(json); +// const buyer = Convert.toBuyer(json); +// const cardCredential = Convert.toCardCredential(json); +// const cardPaymentInstrument = Convert.toCardPaymentInstrument(json); +// const context = Convert.toContext(json); +// const errorCode = Convert.toErrorCode(json); +// const errorResponse = Convert.toErrorResponse(json); +// const expectation = Convert.toExpectation(json); +// const fulfillmentAvailableMethod = Convert.toFulfillmentAvailableMethod(json); +// const fulfillmentDestination = Convert.toFulfillmentDestination(json); +// const fulfillmentEvent = Convert.toFulfillmentEvent(json); +// const fulfillmentGroup = Convert.toFulfillmentGroup(json); +// const fulfillmentMethod = Convert.toFulfillmentMethod(json); +// const fulfillmentOption = Convert.toFulfillmentOption(json); +// const fulfillment = Convert.toFulfillment(json); +// const item = Convert.toItem(json); +// const lineItem = Convert.toLineItem(json); +// const link = Convert.toLink(json); +// const merchantFulfillmentConfig = Convert.toMerchantFulfillmentConfig(json); +// const messageError = Convert.toMessageError(json); +// const messageInfo = Convert.toMessageInfo(json); +// const messageWarning = Convert.toMessageWarning(json); +// const message = Convert.toMessage(json); +// const orderConfirmation = Convert.toOrderConfirmation(json); +// const orderLineItem = Convert.toOrderLineItem(json); +// const paymentCredential = Convert.toPaymentCredential(json); +// const paymentIdentity = Convert.toPaymentIdentity(json); +// const paymentInstrument = Convert.toPaymentInstrument(json); +// const platformFulfillmentConfig = Convert.toPlatformFulfillmentConfig(json); +// const postalAddress = Convert.toPostalAddress(json); +// const retailLocation = Convert.toRetailLocation(json); +// const reverseDomainName = Convert.toReverseDomainName(json); +// const shippingDestination = Convert.toShippingDestination(json); +// const signals = Convert.toSignals(json); +// const signedAmount = Convert.toSignedAmount(json); +// const tokenCredential = Convert.toTokenCredential(json); +// const total = Convert.toTotal(json); +// const totals = Convert.toTotals(json); +// const payment = Convert.toPayment(json); +// const order = Convert.toOrder(json); +// const instrumentsChangeResult = Convert.toInstrumentsChangeResult(json); +// const credentialResult = Convert.toCredentialResult(json); +// +// These functions will throw an error if the JSON doesn't +// match the expected interface, even if the JSON is valid. + /** * Unit price in ISO 4217 minor units. * @@ -2892,3 +2948,1282 @@ export interface CredentialPayment { instruments?: PurpleSelectedPaymentInstrument[]; [property: string]: any; } + +// Converts JSON strings to/from your types +// and asserts the results of JSON.parse at runtime +export class Convert { + public static toCheckout(json: string): Checkout { + return cast(JSON.parse(json), r("Checkout")); + } + + public static checkoutToJson(value: Checkout): string { + return JSON.stringify(uncast(value, r("Checkout")), null, 2); + } + + public static toPaymentAccountInfo(json: string): PaymentAccountInfo { + return cast(JSON.parse(json), r("PaymentAccountInfo")); + } + + public static paymentAccountInfoToJson(value: PaymentAccountInfo): string { + return JSON.stringify(uncast(value, r("PaymentAccountInfo")), null, 2); + } + + public static toAdjustment(json: string): Adjustment { + return cast(JSON.parse(json), r("Adjustment")); + } + + public static adjustmentToJson(value: Adjustment): string { + return JSON.stringify(uncast(value, r("Adjustment")), null, 2); + } + + public static toAmount(json: string): number { + return cast(JSON.parse(json), 0); + } + + public static amountToJson(value: number): string { + return JSON.stringify(uncast(value, 0), null, 2); + } + + public static toAvailablePaymentInstrument(json: string): AvailablePaymentInstrument { + return cast(JSON.parse(json), r("AvailablePaymentInstrument")); + } + + public static availablePaymentInstrumentToJson(value: AvailablePaymentInstrument): string { + return JSON.stringify(uncast(value, r("AvailablePaymentInstrument")), null, 2); + } + + public static toBinding(json: string): TokenBinding { + return cast(JSON.parse(json), r("TokenBinding")); + } + + public static bindingToJson(value: TokenBinding): string { + return JSON.stringify(uncast(value, r("TokenBinding")), null, 2); + } + + public static toBusinessFulfillmentConfig(json: string): BusinessFulfillmentConfig { + return cast(JSON.parse(json), r("BusinessFulfillmentConfig")); + } + + public static businessFulfillmentConfigToJson(value: BusinessFulfillmentConfig): string { + return JSON.stringify(uncast(value, r("BusinessFulfillmentConfig")), null, 2); + } + + public static toBuyer(json: string): Buyer { + return cast(JSON.parse(json), r("Buyer")); + } + + public static buyerToJson(value: Buyer): string { + return JSON.stringify(uncast(value, r("Buyer")), null, 2); + } + + public static toCardCredential(json: string): CardCredential { + return cast(JSON.parse(json), r("CardCredential")); + } + + public static cardCredentialToJson(value: CardCredential): string { + return JSON.stringify(uncast(value, r("CardCredential")), null, 2); + } + + public static toCardPaymentInstrument(json: string): CardPaymentInstrument { + return cast(JSON.parse(json), r("CardPaymentInstrument")); + } + + public static cardPaymentInstrumentToJson(value: CardPaymentInstrument): string { + return JSON.stringify(uncast(value, r("CardPaymentInstrument")), null, 2); + } + + public static toContext(json: string): Context { + return cast(JSON.parse(json), r("Context")); + } + + public static contextToJson(value: Context): string { + return JSON.stringify(uncast(value, r("Context")), null, 2); + } + + public static toErrorCode(json: string): string { + return cast(JSON.parse(json), ""); + } + + public static errorCodeToJson(value: string): string { + return JSON.stringify(uncast(value, ""), null, 2); + } + + public static toErrorResponse(json: string): ErrorResponse { + return cast(JSON.parse(json), r("ErrorResponse")); + } + + public static errorResponseToJson(value: ErrorResponse): string { + return JSON.stringify(uncast(value, r("ErrorResponse")), null, 2); + } + + public static toExpectation(json: string): Expectation { + return cast(JSON.parse(json), r("Expectation")); + } + + public static expectationToJson(value: Expectation): string { + return JSON.stringify(uncast(value, r("Expectation")), null, 2); + } + + public static toFulfillmentAvailableMethod(json: string): FulfillmentAvailableMethod { + return cast(JSON.parse(json), r("FulfillmentAvailableMethod")); + } + + public static fulfillmentAvailableMethodToJson(value: FulfillmentAvailableMethod): string { + return JSON.stringify(uncast(value, r("FulfillmentAvailableMethod")), null, 2); + } + + public static toFulfillmentDestination(json: string): FulfillmentDestination { + return cast(JSON.parse(json), r("FulfillmentDestination")); + } + + public static fulfillmentDestinationToJson(value: FulfillmentDestination): string { + return JSON.stringify(uncast(value, r("FulfillmentDestination")), null, 2); + } + + public static toFulfillmentEvent(json: string): FulfillmentEvent { + return cast(JSON.parse(json), r("FulfillmentEvent")); + } + + public static fulfillmentEventToJson(value: FulfillmentEvent): string { + return JSON.stringify(uncast(value, r("FulfillmentEvent")), null, 2); + } + + public static toFulfillmentGroup(json: string): FulfillmentGroup { + return cast(JSON.parse(json), r("FulfillmentGroup")); + } + + public static fulfillmentGroupToJson(value: FulfillmentGroup): string { + return JSON.stringify(uncast(value, r("FulfillmentGroup")), null, 2); + } + + public static toFulfillmentMethod(json: string): FulfillmentMethod { + return cast(JSON.parse(json), r("FulfillmentMethod")); + } + + public static fulfillmentMethodToJson(value: FulfillmentMethod): string { + return JSON.stringify(uncast(value, r("FulfillmentMethod")), null, 2); + } + + public static toFulfillmentOption(json: string): FulfillmentOption { + return cast(JSON.parse(json), r("FulfillmentOption")); + } + + public static fulfillmentOptionToJson(value: FulfillmentOption): string { + return JSON.stringify(uncast(value, r("FulfillmentOption")), null, 2); + } + + public static toFulfillment(json: string): Fulfillment { + return cast(JSON.parse(json), r("Fulfillment")); + } + + public static fulfillmentToJson(value: Fulfillment): string { + return JSON.stringify(uncast(value, r("Fulfillment")), null, 2); + } + + public static toItem(json: string): Item { + return cast(JSON.parse(json), r("Item")); + } + + public static itemToJson(value: Item): string { + return JSON.stringify(uncast(value, r("Item")), null, 2); + } + + public static toLineItem(json: string): LineItem { + return cast(JSON.parse(json), r("LineItem")); + } + + public static lineItemToJson(value: LineItem): string { + return JSON.stringify(uncast(value, r("LineItem")), null, 2); + } + + public static toLink(json: string): Link { + return cast(JSON.parse(json), r("Link")); + } + + public static linkToJson(value: Link): string { + return JSON.stringify(uncast(value, r("Link")), null, 2); + } + + public static toMerchantFulfillmentConfig(json: string): MerchantFulfillmentConfig { + return cast(JSON.parse(json), r("MerchantFulfillmentConfig")); + } + + public static merchantFulfillmentConfigToJson(value: MerchantFulfillmentConfig): string { + return JSON.stringify(uncast(value, r("MerchantFulfillmentConfig")), null, 2); + } + + public static toMessageError(json: string): MessageError { + return cast(JSON.parse(json), r("MessageError")); + } + + public static messageErrorToJson(value: MessageError): string { + return JSON.stringify(uncast(value, r("MessageError")), null, 2); + } + + public static toMessageInfo(json: string): MessageInfo { + return cast(JSON.parse(json), r("MessageInfo")); + } + + public static messageInfoToJson(value: MessageInfo): string { + return JSON.stringify(uncast(value, r("MessageInfo")), null, 2); + } + + public static toMessageWarning(json: string): MessageWarning { + return cast(JSON.parse(json), r("MessageWarning")); + } + + public static messageWarningToJson(value: MessageWarning): string { + return JSON.stringify(uncast(value, r("MessageWarning")), null, 2); + } + + public static toMessage(json: string): Message { + return cast(JSON.parse(json), r("Message")); + } + + public static messageToJson(value: Message): string { + return JSON.stringify(uncast(value, r("Message")), null, 2); + } + + public static toOrderConfirmation(json: string): OrderConfirmation { + return cast(JSON.parse(json), r("OrderConfirmation")); + } + + public static orderConfirmationToJson(value: OrderConfirmation): string { + return JSON.stringify(uncast(value, r("OrderConfirmation")), null, 2); + } + + public static toOrderLineItem(json: string): OrderLineItem { + return cast(JSON.parse(json), r("OrderLineItem")); + } + + public static orderLineItemToJson(value: OrderLineItem): string { + return JSON.stringify(uncast(value, r("OrderLineItem")), null, 2); + } + + public static toPaymentCredential(json: string): PaymentCredential { + return cast(JSON.parse(json), r("PaymentCredential")); + } + + public static paymentCredentialToJson(value: PaymentCredential): string { + return JSON.stringify(uncast(value, r("PaymentCredential")), null, 2); + } + + public static toPaymentIdentity(json: string): PaymentIdentity { + return cast(JSON.parse(json), r("PaymentIdentity")); + } + + public static paymentIdentityToJson(value: PaymentIdentity): string { + return JSON.stringify(uncast(value, r("PaymentIdentity")), null, 2); + } + + public static toPaymentInstrument(json: string): PaymentInstrument { + return cast(JSON.parse(json), r("PaymentInstrument")); + } + + public static paymentInstrumentToJson(value: PaymentInstrument): string { + return JSON.stringify(uncast(value, r("PaymentInstrument")), null, 2); + } + + public static toPlatformFulfillmentConfig(json: string): PlatformFulfillmentConfig { + return cast(JSON.parse(json), r("PlatformFulfillmentConfig")); + } + + public static platformFulfillmentConfigToJson(value: PlatformFulfillmentConfig): string { + return JSON.stringify(uncast(value, r("PlatformFulfillmentConfig")), null, 2); + } + + public static toPostalAddress(json: string): PostalAddress { + return cast(JSON.parse(json), r("PostalAddress")); + } + + public static postalAddressToJson(value: PostalAddress): string { + return JSON.stringify(uncast(value, r("PostalAddress")), null, 2); + } + + public static toRetailLocation(json: string): RetailLocation { + return cast(JSON.parse(json), r("RetailLocation")); + } + + public static retailLocationToJson(value: RetailLocation): string { + return JSON.stringify(uncast(value, r("RetailLocation")), null, 2); + } + + public static toReverseDomainName(json: string): string { + return cast(JSON.parse(json), ""); + } + + public static reverseDomainNameToJson(value: string): string { + return JSON.stringify(uncast(value, ""), null, 2); + } + + public static toShippingDestination(json: string): ShippingDestination { + return cast(JSON.parse(json), r("ShippingDestination")); + } + + public static shippingDestinationToJson(value: ShippingDestination): string { + return JSON.stringify(uncast(value, r("ShippingDestination")), null, 2); + } + + public static toSignals(json: string): Signals { + return cast(JSON.parse(json), r("Signals")); + } + + public static signalsToJson(value: Signals): string { + return JSON.stringify(uncast(value, r("Signals")), null, 2); + } + + public static toSignedAmount(json: string): number { + return cast(JSON.parse(json), 0); + } + + public static signedAmountToJson(value: number): string { + return JSON.stringify(uncast(value, 0), null, 2); + } + + public static toTokenCredential(json: string): TokenCredential { + return cast(JSON.parse(json), r("TokenCredential")); + } + + public static tokenCredentialToJson(value: TokenCredential): string { + return JSON.stringify(uncast(value, r("TokenCredential")), null, 2); + } + + public static toTotal(json: string): Total { + return cast(JSON.parse(json), r("Total")); + } + + public static totalToJson(value: Total): string { + return JSON.stringify(uncast(value, r("Total")), null, 2); + } + + public static toTotals(json: string): Totals[] { + return cast(JSON.parse(json), a(r("Totals"))); + } + + public static totalsToJson(value: Totals[]): string { + return JSON.stringify(uncast(value, a(r("Totals"))), null, 2); + } + + public static toPayment(json: string): Payment { + return cast(JSON.parse(json), r("Payment")); + } + + public static paymentToJson(value: Payment): string { + return JSON.stringify(uncast(value, r("Payment")), null, 2); + } + + public static toOrder(json: string): Order { + return cast(JSON.parse(json), r("Order")); + } + + public static orderToJson(value: Order): string { + return JSON.stringify(uncast(value, r("Order")), null, 2); + } + + public static toInstrumentsChangeResult(json: string): InstrumentsChangeResult { + return cast(JSON.parse(json), r("InstrumentsChangeResult")); + } + + public static instrumentsChangeResultToJson(value: InstrumentsChangeResult): string { + return JSON.stringify(uncast(value, r("InstrumentsChangeResult")), null, 2); + } + + public static toCredentialResult(json: string): CredentialResult { + return cast(JSON.parse(json), r("CredentialResult")); + } + + public static credentialResultToJson(value: CredentialResult): string { + return JSON.stringify(uncast(value, r("CredentialResult")), null, 2); + } +} + +function invalidValue(typ: any, val: any, key: any, parent: any = ''): never { + const prettyTyp = prettyTypeName(typ); + const parentText = parent ? ` on ${parent}` : ''; + const keyText = key ? ` for key "${key}"` : ''; + throw Error(`Invalid value${keyText}${parentText}. Expected ${prettyTyp} but got ${JSON.stringify(val)}`); +} + +function prettyTypeName(typ: any): string { + if (Array.isArray(typ)) { + if (typ.length === 2 && typ[0] === undefined) { + return `an optional ${prettyTypeName(typ[1])}`; + } else { + return `one of [${typ.map(a => { return prettyTypeName(a); }).join(", ")}]`; + } + } else if (typeof typ === "object" && typ.literal !== undefined) { + return typ.literal; + } else { + return typeof typ; + } +} + +function jsonToJSProps(typ: any): any { + if (typ.jsonToJS === undefined) { + const map: any = {}; + typ.props.forEach((p: any) => map[p.json] = { key: p.js, typ: p.typ }); + typ.jsonToJS = map; + } + return typ.jsonToJS; +} + +function jsToJSONProps(typ: any): any { + if (typ.jsToJSON === undefined) { + const map: any = {}; + typ.props.forEach((p: any) => map[p.js] = { key: p.json, typ: p.typ }); + typ.jsToJSON = map; + } + return typ.jsToJSON; +} + +function transform(val: any, typ: any, getProps: any, key: any = '', parent: any = ''): any { + function transformPrimitive(typ: string, val: any): any { + if (typeof typ === typeof val) return val; + return invalidValue(typ, val, key, parent); + } + + function transformUnion(typs: any[], val: any): any { + // val must validate against one typ in typs + const l = typs.length; + for (let i = 0; i < l; i++) { + const typ = typs[i]; + try { + return transform(val, typ, getProps); + } catch (_) {} + } + return invalidValue(typs, val, key, parent); + } + + function transformEnum(cases: string[], val: any): any { + if (cases.indexOf(val) !== -1) return val; + return invalidValue(cases.map(a => { return l(a); }), val, key, parent); + } + + function transformArray(typ: any, val: any): any { + // val must be an array with no invalid elements + if (!Array.isArray(val)) return invalidValue(l("array"), val, key, parent); + return val.map(el => transform(el, typ, getProps)); + } + + function transformDate(val: any): any { + if (val === null) { + return null; + } + const d = new Date(val); + if (isNaN(d.valueOf())) { + return invalidValue(l("Date"), val, key, parent); + } + return d; + } + + function transformObject(props: { [k: string]: any }, additional: any, val: any): any { + if (val === null || typeof val !== "object" || Array.isArray(val)) { + return invalidValue(l(ref || "object"), val, key, parent); + } + const result: any = {}; + Object.getOwnPropertyNames(props).forEach(key => { + const prop = props[key]; + const v = Object.prototype.hasOwnProperty.call(val, key) ? val[key] : undefined; + result[prop.key] = transform(v, prop.typ, getProps, key, ref); + }); + Object.getOwnPropertyNames(val).forEach(key => { + if (!Object.prototype.hasOwnProperty.call(props, key)) { + result[key] = transform(val[key], additional, getProps, key, ref); + } + }); + return result; + } + + if (typ === "any") return val; + if (typ === null) { + if (val === null) return val; + return invalidValue(typ, val, key, parent); + } + if (typ === false) return invalidValue(typ, val, key, parent); + let ref: any = undefined; + while (typeof typ === "object" && typ.ref !== undefined) { + ref = typ.ref; + typ = typeMap[typ.ref]; + } + if (Array.isArray(typ)) return transformEnum(typ, val); + if (typeof typ === "object") { + return typ.hasOwnProperty("unionMembers") ? transformUnion(typ.unionMembers, val) + : typ.hasOwnProperty("arrayItems") ? transformArray(typ.arrayItems, val) + : typ.hasOwnProperty("props") ? transformObject(getProps(typ), typ.additional, val) + : invalidValue(typ, val, key, parent); + } + // Numbers can be parsed by Date but shouldn't be. + if (typ === Date && typeof val !== "number") return transformDate(val); + return transformPrimitive(typ, val); +} + +function cast(val: any, typ: any): T { + return transform(val, typ, jsonToJSProps); +} + +function uncast(val: T, typ: any): any { + return transform(val, typ, jsToJSONProps); +} + +function l(typ: any) { + return { literal: typ }; +} + +function a(typ: any) { + return { arrayItems: typ }; +} + +function u(...typs: any[]) { + return { unionMembers: typs }; +} + +function o(props: any[], additional: any) { + return { props, additional }; +} + +function m(additional: any) { + return { props: [], additional }; +} + +function r(name: string) { + return { ref: name }; +} + +const typeMap: any = { + "Checkout": o([ + { json: "buyer", js: "buyer", typ: u(undefined, r("BuyerObject")) }, + { json: "context", js: "context", typ: u(undefined, r("ContextObject")) }, + { json: "continue_url", js: "continueUrl", typ: u(undefined, "") }, + { json: "currency", js: "currency", typ: "" }, + { json: "expires_at", js: "expiresAt", typ: u(undefined, "") }, + { json: "id", js: "id", typ: "" }, + { json: "line_items", js: "lineItems", typ: a(r("CheckoutLineItem")) }, + { json: "links", js: "links", typ: a(r("LinkElement")) }, + { json: "messages", js: "messages", typ: u(undefined, a(r("MessageElement"))) }, + { json: "order", js: "order", typ: u(undefined, r("OrderObject")) }, + { json: "payment", js: "payment", typ: u(undefined, r("PaymentObject")) }, + { json: "signals", js: "signals", typ: u(undefined, r("SignalsObject")) }, + { json: "status", js: "status", typ: r("CheckoutStatus") }, + { json: "totals", js: "totals", typ: a(r("CheckoutTotal")) }, + { json: "ucp", js: "ucp", typ: r("UcpCheckoutResponseSchema") }, + ], "any"), + "BuyerObject": o([ + { json: "email", js: "email", typ: u(undefined, "") }, + { json: "first_name", js: "firstName", typ: u(undefined, "") }, + { json: "last_name", js: "lastName", typ: u(undefined, "") }, + { json: "phone_number", js: "phoneNumber", typ: u(undefined, "") }, + ], "any"), + "ContextObject": o([ + { json: "address_country", js: "addressCountry", typ: u(undefined, "") }, + { json: "address_region", js: "addressRegion", typ: u(undefined, "") }, + { json: "currency", js: "currency", typ: u(undefined, "") }, + { json: "eligibility", js: "eligibility", typ: u(undefined, a("")) }, + { json: "intent", js: "intent", typ: u(undefined, "") }, + { json: "language", js: "language", typ: u(undefined, "") }, + { json: "postal_code", js: "postalCode", typ: u(undefined, "") }, + ], "any"), + "CheckoutLineItem": o([ + { json: "id", js: "id", typ: "" }, + { json: "item", js: "item", typ: r("ItemObject") }, + { json: "parent_id", js: "parentId", typ: u(undefined, "") }, + { json: "quantity", js: "quantity", typ: 0 }, + { json: "totals", js: "totals", typ: a(r("LineItemTotal")) }, + ], "any"), + "ItemObject": o([ + { json: "id", js: "id", typ: "" }, + { json: "image_url", js: "imageUrl", typ: u(undefined, "") }, + { json: "price", js: "price", typ: 0 }, + { json: "title", js: "title", typ: "" }, + ], "any"), + "LineItemTotal": o([ + { json: "amount", js: "amount", typ: 0 }, + { json: "display_text", js: "displayText", typ: u(undefined, "") }, + { json: "type", js: "type", typ: "" }, + ], "any"), + "LinkElement": o([ + { json: "title", js: "title", typ: u(undefined, "") }, + { json: "type", js: "type", typ: "" }, + { json: "url", js: "url", typ: "" }, + ], "any"), + "MessageElement": o([ + { json: "code", js: "code", typ: u(undefined, "") }, + { json: "content", js: "content", typ: "" }, + { json: "content_type", js: "contentType", typ: u(undefined, r("ContentType")) }, + { json: "path", js: "path", typ: u(undefined, "") }, + { json: "severity", js: "severity", typ: u(undefined, r("Severity")) }, + { json: "type", js: "type", typ: r("MessageType") }, + { json: "image_url", js: "imageUrl", typ: u(undefined, "") }, + { json: "presentation", js: "presentation", typ: u(undefined, "") }, + { json: "url", js: "url", typ: u(undefined, "") }, + ], "any"), + "OrderObject": o([ + { json: "id", js: "id", typ: "" }, + { json: "label", js: "label", typ: u(undefined, "") }, + { json: "permalink_url", js: "permalinkUrl", typ: "" }, + ], "any"), + "PaymentObject": o([ + { json: "instruments", js: "instruments", typ: u(undefined, a(r("PaymentSelectedPaymentInstrument"))) }, + ], "any"), + "PaymentSelectedPaymentInstrument": o([ + { json: "billing_address", js: "billingAddress", typ: u(undefined, r("BillingAddressObject")) }, + { json: "credential", js: "credential", typ: u(undefined, r("CredentialObject")) }, + { json: "display", js: "display", typ: u(undefined, m("any")) }, + { json: "handler_id", js: "handlerId", typ: "" }, + { json: "id", js: "id", typ: "" }, + { json: "type", js: "type", typ: "" }, + { json: "selected", js: "selected", typ: u(undefined, true) }, + ], "any"), + "BillingAddressObject": o([ + { json: "address_country", js: "addressCountry", typ: u(undefined, "") }, + { json: "address_locality", js: "addressLocality", typ: u(undefined, "") }, + { json: "address_region", js: "addressRegion", typ: u(undefined, "") }, + { json: "extended_address", js: "extendedAddress", typ: u(undefined, "") }, + { json: "first_name", js: "firstName", typ: u(undefined, "") }, + { json: "last_name", js: "lastName", typ: u(undefined, "") }, + { json: "phone_number", js: "phoneNumber", typ: u(undefined, "") }, + { json: "postal_code", js: "postalCode", typ: u(undefined, "") }, + { json: "street_address", js: "streetAddress", typ: u(undefined, "") }, + ], "any"), + "CredentialObject": o([ + { json: "type", js: "type", typ: "" }, + ], "any"), + "SignalsObject": o([ + { json: "dev.ucp.buyer_ip", js: "devUcpBuyerIp", typ: u(undefined, "") }, + { json: "dev.ucp.user_agent", js: "devUcpUserAgent", typ: u(undefined, "") }, + ], "any"), + "CheckoutTotal": o([ + { json: "amount", js: "amount", typ: 0 }, + { json: "display_text", js: "displayText", typ: u(undefined, "") }, + { json: "type", js: "type", typ: "" }, + { json: "lines", js: "lines", typ: u(undefined, a(r("TotalLine"))) }, + ], "any"), + "TotalLine": o([ + { json: "amount", js: "amount", typ: 0 }, + { json: "display_text", js: "displayText", typ: "" }, + ], "any"), + "UcpCheckoutResponseSchema": o([ + { json: "capabilities", js: "capabilities", typ: u(undefined, m(a(r("CapabilityResponseSchema")))) }, + { json: "payment_handlers", js: "paymentHandlers", typ: m(a(r("PaymentHandlerResponseSchema"))) }, + { json: "services", js: "services", typ: u(undefined, m(a(r("ServiceResponseSchema")))) }, + { json: "status", js: "status", typ: u(undefined, r("UcpCheckoutResponseSchemaStatus")) }, + { json: "version", js: "version", typ: "" }, + ], "any"), + "CapabilityResponseSchema": o([ + { json: "config", js: "config", typ: u(undefined, m("any")) }, + { json: "id", js: "id", typ: u(undefined, "") }, + { json: "schema", js: "schema", typ: u(undefined, "") }, + { json: "spec", js: "spec", typ: u(undefined, "") }, + { json: "version", js: "version", typ: "" }, + { json: "extends", js: "extends", typ: u(undefined, u(a(""), "")) }, + ], "any"), + "PaymentHandlerResponseSchema": o([ + { json: "config", js: "config", typ: u(undefined, m("any")) }, + { json: "id", js: "id", typ: "" }, + { json: "schema", js: "schema", typ: u(undefined, "") }, + { json: "spec", js: "spec", typ: u(undefined, "") }, + { json: "version", js: "version", typ: "" }, + { json: "available_instruments", js: "availableInstruments", typ: u(undefined, a(r("PaymentHandlerResponseSchemaAvailableInstrument"))) }, + ], "any"), + "PaymentHandlerResponseSchemaAvailableInstrument": o([ + { json: "constraints", js: "constraints", typ: u(undefined, m("any")) }, + { json: "type", js: "type", typ: "" }, + ], "any"), + "ServiceResponseSchema": o([ + { json: "config", js: "config", typ: u(undefined, r("EmbeddedTransportConfig")) }, + { json: "id", js: "id", typ: u(undefined, "") }, + { json: "schema", js: "schema", typ: u(undefined, "") }, + { json: "spec", js: "spec", typ: u(undefined, "") }, + { json: "version", js: "version", typ: "" }, + { json: "endpoint", js: "endpoint", typ: u(undefined, "") }, + { json: "transport", js: "transport", typ: r("Transport") }, + ], "any"), + "EmbeddedTransportConfig": o([ + { json: "color_scheme", js: "colorScheme", typ: u(undefined, a(r("EmbeddedColorScheme"))) }, + { json: "delegate", js: "delegate", typ: u(undefined, a("")) }, + ], "any"), + "PaymentAccountInfo": o([ + { json: "payment_account_reference", js: "paymentAccountReference", typ: u(undefined, "") }, + ], "any"), + "Adjustment": o([ + { json: "description", js: "description", typ: u(undefined, "") }, + { json: "id", js: "id", typ: "" }, + { json: "line_items", js: "lineItems", typ: u(undefined, a(r("AdjustmentLineItem"))) }, + { json: "occurred_at", js: "occurredAt", typ: "" }, + { json: "status", js: "status", typ: r("AdjustmentStatus") }, + { json: "totals", js: "totals", typ: u(undefined, a(r("LineItemTotal"))) }, + { json: "type", js: "type", typ: "" }, + ], "any"), + "AdjustmentLineItem": o([ + { json: "id", js: "id", typ: "" }, + { json: "quantity", js: "quantity", typ: 0 }, + ], "any"), + "AvailablePaymentInstrument": o([ + { json: "constraints", js: "constraints", typ: u(undefined, m("any")) }, + { json: "type", js: "type", typ: "" }, + ], "any"), + "TokenBinding": o([ + { json: "checkout_id", js: "checkoutId", typ: "" }, + { json: "identity", js: "identity", typ: u(undefined, r("IdentityObject")) }, + ], "any"), + "IdentityObject": o([ + { json: "access_token", js: "accessToken", typ: "" }, + ], "any"), + "BusinessFulfillmentConfig": o([ + { json: "allows_method_combinations", js: "allowsMethodCombinations", typ: u(undefined, a(a(r("TypeElement")))) }, + { json: "allows_multi_destination", js: "allowsMultiDestination", typ: u(undefined, r("BusinessFulfillmentConfigAllowsMultiDestination")) }, + ], "any"), + "BusinessFulfillmentConfigAllowsMultiDestination": o([ + { json: "pickup", js: "pickup", typ: u(undefined, true) }, + { json: "shipping", js: "shipping", typ: u(undefined, true) }, + ], false), + "Buyer": o([ + { json: "email", js: "email", typ: u(undefined, "") }, + { json: "first_name", js: "firstName", typ: u(undefined, "") }, + { json: "last_name", js: "lastName", typ: u(undefined, "") }, + { json: "phone_number", js: "phoneNumber", typ: u(undefined, "") }, + ], "any"), + "CardCredential": o([ + { json: "type", js: "type", typ: r("TypeEnum") }, + { json: "card_number_type", js: "cardNumberType", typ: r("CardNumberType") }, + { json: "cryptogram", js: "cryptogram", typ: u(undefined, "") }, + { json: "cvc", js: "cvc", typ: u(undefined, "") }, + { json: "eci_value", js: "eciValue", typ: u(undefined, "") }, + { json: "expiry_month", js: "expiryMonth", typ: u(undefined, 0) }, + { json: "expiry_year", js: "expiryYear", typ: u(undefined, 0) }, + { json: "name", js: "name", typ: u(undefined, "") }, + { json: "number", js: "number", typ: u(undefined, "") }, + ], "any"), + "CardPaymentInstrument": o([ + { json: "billing_address", js: "billingAddress", typ: u(undefined, r("BillingAddressObject")) }, + { json: "credential", js: "credential", typ: u(undefined, r("CredentialObject")) }, + { json: "display", js: "display", typ: u(undefined, r("Display")) }, + { json: "handler_id", js: "handlerId", typ: "" }, + { json: "id", js: "id", typ: "" }, + { json: "type", js: "type", typ: r("TypeEnum") }, + ], "any"), + "Display": o([ + { json: "brand", js: "brand", typ: u(undefined, "") }, + { json: "card_art", js: "cardArt", typ: u(undefined, "") }, + { json: "description", js: "description", typ: u(undefined, "") }, + { json: "expiry_month", js: "expiryMonth", typ: u(undefined, 0) }, + { json: "expiry_year", js: "expiryYear", typ: u(undefined, 0) }, + { json: "last_digits", js: "lastDigits", typ: u(undefined, "") }, + ], "any"), + "Context": o([ + { json: "address_country", js: "addressCountry", typ: u(undefined, "") }, + { json: "address_region", js: "addressRegion", typ: u(undefined, "") }, + { json: "currency", js: "currency", typ: u(undefined, "") }, + { json: "eligibility", js: "eligibility", typ: u(undefined, a("")) }, + { json: "intent", js: "intent", typ: u(undefined, "") }, + { json: "language", js: "language", typ: u(undefined, "") }, + { json: "postal_code", js: "postalCode", typ: u(undefined, "") }, + ], "any"), + "ErrorResponse": o([ + { json: "continue_url", js: "continueUrl", typ: u(undefined, "") }, + { json: "messages", js: "messages", typ: a(r("MessageElement")) }, + { json: "ucp", js: "ucp", typ: r("ErrorResponseUcp") }, + ], false), + "ErrorResponseUcp": o([ + { json: "capabilities", js: "capabilities", typ: u(undefined, m(a(r("CapabilityResponseSchema")))) }, + { json: "payment_handlers", js: "paymentHandlers", typ: u(undefined, m(a(r("PaymentHandlerResponseSchema")))) }, + { json: "services", js: "services", typ: u(undefined, m(a(r("UcpOrderResponseSchemaService")))) }, + { json: "status", js: "status", typ: r("StatusEnum") }, + { json: "version", js: "version", typ: "" }, + ], "any"), + "UcpOrderResponseSchemaService": o([ + { json: "config", js: "config", typ: u(undefined, m("any")) }, + { json: "id", js: "id", typ: u(undefined, "") }, + { json: "schema", js: "schema", typ: u(undefined, "") }, + { json: "spec", js: "spec", typ: u(undefined, "") }, + { json: "version", js: "version", typ: "" }, + { json: "endpoint", js: "endpoint", typ: u(undefined, "") }, + { json: "transport", js: "transport", typ: r("Transport") }, + ], "any"), + "Expectation": o([ + { json: "description", js: "description", typ: u(undefined, "") }, + { json: "destination", js: "destination", typ: r("BillingAddressObject") }, + { json: "fulfillable_on", js: "fulfillableOn", typ: u(undefined, "") }, + { json: "id", js: "id", typ: "" }, + { json: "line_items", js: "lineItems", typ: a(r("ExpectationLineItem")) }, + { json: "method_type", js: "methodType", typ: r("MethodType") }, + ], "any"), + "ExpectationLineItem": o([ + { json: "id", js: "id", typ: "" }, + { json: "quantity", js: "quantity", typ: 0 }, + ], "any"), + "FulfillmentAvailableMethod": o([ + { json: "description", js: "description", typ: u(undefined, "") }, + { json: "fulfillable_on", js: "fulfillableOn", typ: u(undefined, u(null, "")) }, + { json: "line_item_ids", js: "lineItemIds", typ: a("") }, + { json: "type", js: "type", typ: r("TypeElement") }, + ], "any"), + "FulfillmentDestination": o([ + { json: "address_country", js: "addressCountry", typ: u(undefined, "") }, + { json: "address_locality", js: "addressLocality", typ: u(undefined, "") }, + { json: "address_region", js: "addressRegion", typ: u(undefined, "") }, + { json: "extended_address", js: "extendedAddress", typ: u(undefined, "") }, + { json: "first_name", js: "firstName", typ: u(undefined, "") }, + { json: "last_name", js: "lastName", typ: u(undefined, "") }, + { json: "phone_number", js: "phoneNumber", typ: u(undefined, "") }, + { json: "postal_code", js: "postalCode", typ: u(undefined, "") }, + { json: "street_address", js: "streetAddress", typ: u(undefined, "") }, + { json: "id", js: "id", typ: "" }, + { json: "address", js: "address", typ: u(undefined, r("BillingAddressObject")) }, + { json: "name", js: "name", typ: u(undefined, "") }, + ], "any"), + "FulfillmentEvent": o([ + { json: "carrier", js: "carrier", typ: u(undefined, "") }, + { json: "description", js: "description", typ: u(undefined, "") }, + { json: "id", js: "id", typ: "" }, + { json: "line_items", js: "lineItems", typ: a(r("FulfillmentEventLineItem")) }, + { json: "occurred_at", js: "occurredAt", typ: "" }, + { json: "tracking_number", js: "trackingNumber", typ: u(undefined, "") }, + { json: "tracking_url", js: "trackingUrl", typ: u(undefined, "") }, + { json: "type", js: "type", typ: "" }, + ], "any"), + "FulfillmentEventLineItem": o([ + { json: "id", js: "id", typ: "" }, + { json: "quantity", js: "quantity", typ: 0 }, + ], "any"), + "FulfillmentGroup": o([ + { json: "id", js: "id", typ: "" }, + { json: "line_item_ids", js: "lineItemIds", typ: a("") }, + { json: "options", js: "options", typ: u(undefined, a(r("OptionElement"))) }, + { json: "selected_option_id", js: "selectedOptionId", typ: u(undefined, u(null, "")) }, + ], "any"), + "OptionElement": o([ + { json: "carrier", js: "carrier", typ: u(undefined, "") }, + { json: "description", js: "description", typ: u(undefined, "") }, + { json: "earliest_fulfillment_time", js: "earliestFulfillmentTime", typ: u(undefined, "") }, + { json: "id", js: "id", typ: "" }, + { json: "latest_fulfillment_time", js: "latestFulfillmentTime", typ: u(undefined, "") }, + { json: "title", js: "title", typ: "" }, + { json: "totals", js: "totals", typ: a(r("LineItemTotal")) }, + ], "any"), + "FulfillmentMethod": o([ + { json: "destinations", js: "destinations", typ: u(undefined, a(r("FulfillmentDestinationElement"))) }, + { json: "groups", js: "groups", typ: u(undefined, a(r("GroupElement"))) }, + { json: "id", js: "id", typ: "" }, + { json: "line_item_ids", js: "lineItemIds", typ: a("") }, + { json: "selected_destination_id", js: "selectedDestinationId", typ: u(undefined, u(null, "")) }, + { json: "type", js: "type", typ: r("TypeElement") }, + ], "any"), + "FulfillmentDestinationElement": o([ + { json: "address_country", js: "addressCountry", typ: u(undefined, "") }, + { json: "address_locality", js: "addressLocality", typ: u(undefined, "") }, + { json: "address_region", js: "addressRegion", typ: u(undefined, "") }, + { json: "extended_address", js: "extendedAddress", typ: u(undefined, "") }, + { json: "first_name", js: "firstName", typ: u(undefined, "") }, + { json: "last_name", js: "lastName", typ: u(undefined, "") }, + { json: "phone_number", js: "phoneNumber", typ: u(undefined, "") }, + { json: "postal_code", js: "postalCode", typ: u(undefined, "") }, + { json: "street_address", js: "streetAddress", typ: u(undefined, "") }, + { json: "id", js: "id", typ: "" }, + { json: "address", js: "address", typ: u(undefined, r("BillingAddressObject")) }, + { json: "name", js: "name", typ: u(undefined, "") }, + ], "any"), + "GroupElement": o([ + { json: "id", js: "id", typ: "" }, + { json: "line_item_ids", js: "lineItemIds", typ: a("") }, + { json: "options", js: "options", typ: u(undefined, a(r("OptionElement"))) }, + { json: "selected_option_id", js: "selectedOptionId", typ: u(undefined, u(null, "")) }, + ], "any"), + "FulfillmentOption": o([ + { json: "carrier", js: "carrier", typ: u(undefined, "") }, + { json: "description", js: "description", typ: u(undefined, "") }, + { json: "earliest_fulfillment_time", js: "earliestFulfillmentTime", typ: u(undefined, "") }, + { json: "id", js: "id", typ: "" }, + { json: "latest_fulfillment_time", js: "latestFulfillmentTime", typ: u(undefined, "") }, + { json: "title", js: "title", typ: "" }, + { json: "totals", js: "totals", typ: a(r("LineItemTotal")) }, + ], "any"), + "Fulfillment": o([ + { json: "available_methods", js: "availableMethods", typ: u(undefined, a(r("AvailableMethodElement"))) }, + { json: "methods", js: "methods", typ: u(undefined, a(r("MethodElement"))) }, + ], "any"), + "AvailableMethodElement": o([ + { json: "description", js: "description", typ: u(undefined, "") }, + { json: "fulfillable_on", js: "fulfillableOn", typ: u(undefined, u(null, "")) }, + { json: "line_item_ids", js: "lineItemIds", typ: a("") }, + { json: "type", js: "type", typ: r("TypeElement") }, + ], "any"), + "MethodElement": o([ + { json: "destinations", js: "destinations", typ: u(undefined, a(r("FulfillmentDestinationElement"))) }, + { json: "groups", js: "groups", typ: u(undefined, a(r("GroupElement"))) }, + { json: "id", js: "id", typ: "" }, + { json: "line_item_ids", js: "lineItemIds", typ: a("") }, + { json: "selected_destination_id", js: "selectedDestinationId", typ: u(undefined, u(null, "")) }, + { json: "type", js: "type", typ: r("TypeElement") }, + ], "any"), + "Item": o([ + { json: "id", js: "id", typ: "" }, + { json: "image_url", js: "imageUrl", typ: u(undefined, "") }, + { json: "price", js: "price", typ: 0 }, + { json: "title", js: "title", typ: "" }, + ], "any"), + "LineItem": o([ + { json: "id", js: "id", typ: "" }, + { json: "item", js: "item", typ: r("ItemObject") }, + { json: "parent_id", js: "parentId", typ: u(undefined, "") }, + { json: "quantity", js: "quantity", typ: 0 }, + { json: "totals", js: "totals", typ: a(r("LineItemTotal")) }, + ], "any"), + "Link": o([ + { json: "title", js: "title", typ: u(undefined, "") }, + { json: "type", js: "type", typ: "" }, + { json: "url", js: "url", typ: "" }, + ], "any"), + "MerchantFulfillmentConfig": o([ + { json: "allows_method_combinations", js: "allowsMethodCombinations", typ: u(undefined, a(a(r("TypeElement")))) }, + { json: "allows_multi_destination", js: "allowsMultiDestination", typ: u(undefined, r("MerchantFulfillmentConfigAllowsMultiDestination")) }, + ], "any"), + "MerchantFulfillmentConfigAllowsMultiDestination": o([ + { json: "pickup", js: "pickup", typ: u(undefined, true) }, + { json: "shipping", js: "shipping", typ: u(undefined, true) }, + ], false), + "MessageError": o([ + { json: "code", js: "code", typ: "" }, + { json: "content", js: "content", typ: "" }, + { json: "content_type", js: "contentType", typ: u(undefined, r("ContentType")) }, + { json: "path", js: "path", typ: u(undefined, "") }, + { json: "severity", js: "severity", typ: r("Severity") }, + { json: "type", js: "type", typ: r("StatusEnum") }, + ], "any"), + "MessageInfo": o([ + { json: "code", js: "code", typ: u(undefined, "") }, + { json: "content", js: "content", typ: "" }, + { json: "content_type", js: "contentType", typ: u(undefined, r("ContentType")) }, + { json: "path", js: "path", typ: u(undefined, "") }, + { json: "type", js: "type", typ: r("MessageInfoType") }, + ], "any"), + "MessageWarning": o([ + { json: "code", js: "code", typ: "" }, + { json: "content", js: "content", typ: "" }, + { json: "content_type", js: "contentType", typ: u(undefined, r("ContentType")) }, + { json: "image_url", js: "imageUrl", typ: u(undefined, "") }, + { json: "path", js: "path", typ: u(undefined, "") }, + { json: "presentation", js: "presentation", typ: u(undefined, "") }, + { json: "type", js: "type", typ: r("MessageWarningType") }, + { json: "url", js: "url", typ: u(undefined, "") }, + ], "any"), + "Message": o([ + { json: "code", js: "code", typ: u(undefined, "") }, + { json: "content", js: "content", typ: "" }, + { json: "content_type", js: "contentType", typ: u(undefined, r("ContentType")) }, + { json: "path", js: "path", typ: u(undefined, "") }, + { json: "severity", js: "severity", typ: u(undefined, r("Severity")) }, + { json: "type", js: "type", typ: r("MessageType") }, + { json: "image_url", js: "imageUrl", typ: u(undefined, "") }, + { json: "presentation", js: "presentation", typ: u(undefined, "") }, + { json: "url", js: "url", typ: u(undefined, "") }, + ], "any"), + "OrderConfirmation": o([ + { json: "id", js: "id", typ: "" }, + { json: "label", js: "label", typ: u(undefined, "") }, + { json: "permalink_url", js: "permalinkUrl", typ: "" }, + ], "any"), + "OrderLineItem": o([ + { json: "id", js: "id", typ: "" }, + { json: "item", js: "item", typ: r("ItemObject") }, + { json: "parent_id", js: "parentId", typ: u(undefined, "") }, + { json: "quantity", js: "quantity", typ: r("OrderLineItemQuantity") }, + { json: "status", js: "status", typ: r("OrderLineItemStatus") }, + { json: "totals", js: "totals", typ: a(r("LineItemTotal")) }, + ], "any"), + "OrderLineItemQuantity": o([ + { json: "fulfilled", js: "fulfilled", typ: 0 }, + { json: "original", js: "original", typ: u(undefined, 0) }, + { json: "total", js: "total", typ: 0 }, + ], "any"), + "PaymentCredential": o([ + { json: "type", js: "type", typ: "" }, + ], "any"), + "PaymentIdentity": o([ + { json: "access_token", js: "accessToken", typ: "" }, + ], "any"), + "PaymentInstrument": o([ + { json: "billing_address", js: "billingAddress", typ: u(undefined, r("BillingAddressObject")) }, + { json: "credential", js: "credential", typ: u(undefined, r("CredentialObject")) }, + { json: "display", js: "display", typ: u(undefined, m("any")) }, + { json: "handler_id", js: "handlerId", typ: "" }, + { json: "id", js: "id", typ: "" }, + { json: "type", js: "type", typ: "" }, + ], "any"), + "PlatformFulfillmentConfig": o([ + { json: "supports_multi_group", js: "supportsMultiGroup", typ: u(undefined, true) }, + ], "any"), + "PostalAddress": o([ + { json: "address_country", js: "addressCountry", typ: u(undefined, "") }, + { json: "address_locality", js: "addressLocality", typ: u(undefined, "") }, + { json: "address_region", js: "addressRegion", typ: u(undefined, "") }, + { json: "extended_address", js: "extendedAddress", typ: u(undefined, "") }, + { json: "first_name", js: "firstName", typ: u(undefined, "") }, + { json: "last_name", js: "lastName", typ: u(undefined, "") }, + { json: "phone_number", js: "phoneNumber", typ: u(undefined, "") }, + { json: "postal_code", js: "postalCode", typ: u(undefined, "") }, + { json: "street_address", js: "streetAddress", typ: u(undefined, "") }, + ], "any"), + "RetailLocation": o([ + { json: "address", js: "address", typ: u(undefined, r("BillingAddressObject")) }, + { json: "id", js: "id", typ: "" }, + { json: "name", js: "name", typ: "" }, + ], "any"), + "ShippingDestination": o([ + { json: "address_country", js: "addressCountry", typ: u(undefined, "") }, + { json: "address_locality", js: "addressLocality", typ: u(undefined, "") }, + { json: "address_region", js: "addressRegion", typ: u(undefined, "") }, + { json: "extended_address", js: "extendedAddress", typ: u(undefined, "") }, + { json: "first_name", js: "firstName", typ: u(undefined, "") }, + { json: "last_name", js: "lastName", typ: u(undefined, "") }, + { json: "phone_number", js: "phoneNumber", typ: u(undefined, "") }, + { json: "postal_code", js: "postalCode", typ: u(undefined, "") }, + { json: "street_address", js: "streetAddress", typ: u(undefined, "") }, + { json: "id", js: "id", typ: "" }, + ], "any"), + "Signals": o([ + { json: "dev.ucp.buyer_ip", js: "devUcpBuyerIp", typ: u(undefined, "") }, + { json: "dev.ucp.user_agent", js: "devUcpUserAgent", typ: u(undefined, "") }, + ], "any"), + "TokenCredential": o([ + { json: "type", js: "type", typ: "" }, + { json: "token", js: "token", typ: "" }, + ], "any"), + "Total": o([ + { json: "amount", js: "amount", typ: 0 }, + { json: "display_text", js: "displayText", typ: u(undefined, "") }, + { json: "type", js: "type", typ: "" }, + ], "any"), + "Totals": o([ + { json: "amount", js: "amount", typ: 0 }, + { json: "display_text", js: "displayText", typ: u(undefined, "") }, + { json: "type", js: "type", typ: "" }, + { json: "lines", js: "lines", typ: u(undefined, a(r("TotalLineObject"))) }, + ], "any"), + "TotalLineObject": o([ + { json: "amount", js: "amount", typ: 0 }, + { json: "display_text", js: "displayText", typ: "" }, + ], "any"), + "Payment": o([ + { json: "instruments", js: "instruments", typ: u(undefined, a(r("PaymentSelectedPaymentInstrument"))) }, + ], "any"), + "Order": o([ + { json: "adjustments", js: "adjustments", typ: u(undefined, a(r("AdjustmentElement"))) }, + { json: "checkout_id", js: "checkoutId", typ: "" }, + { json: "currency", js: "currency", typ: "" }, + { json: "fulfillment", js: "fulfillment", typ: r("FulfillmentObject") }, + { json: "id", js: "id", typ: "" }, + { json: "label", js: "label", typ: u(undefined, "") }, + { json: "line_items", js: "lineItems", typ: a(r("LineItemElement")) }, + { json: "messages", js: "messages", typ: u(undefined, a(r("MessageElement"))) }, + { json: "permalink_url", js: "permalinkUrl", typ: "" }, + { json: "totals", js: "totals", typ: a(r("CheckoutTotal")) }, + { json: "ucp", js: "ucp", typ: r("UcpOrderResponseSchema") }, + ], "any"), + "AdjustmentElement": o([ + { json: "description", js: "description", typ: u(undefined, "") }, + { json: "id", js: "id", typ: "" }, + { json: "line_items", js: "lineItems", typ: u(undefined, a(r("AdjustmentLineItemObject"))) }, + { json: "occurred_at", js: "occurredAt", typ: "" }, + { json: "status", js: "status", typ: r("AdjustmentStatus") }, + { json: "totals", js: "totals", typ: u(undefined, a(r("LineItemTotal"))) }, + { json: "type", js: "type", typ: "" }, + ], "any"), + "AdjustmentLineItemObject": o([ + { json: "id", js: "id", typ: "" }, + { json: "quantity", js: "quantity", typ: 0 }, + ], "any"), + "FulfillmentObject": o([ + { json: "events", js: "events", typ: u(undefined, a(r("EventElement"))) }, + { json: "expectations", js: "expectations", typ: u(undefined, a(r("ExpectationElement"))) }, + ], "any"), + "EventElement": o([ + { json: "carrier", js: "carrier", typ: u(undefined, "") }, + { json: "description", js: "description", typ: u(undefined, "") }, + { json: "id", js: "id", typ: "" }, + { json: "line_items", js: "lineItems", typ: a(r("EventLineItem")) }, + { json: "occurred_at", js: "occurredAt", typ: "" }, + { json: "tracking_number", js: "trackingNumber", typ: u(undefined, "") }, + { json: "tracking_url", js: "trackingUrl", typ: u(undefined, "") }, + { json: "type", js: "type", typ: "" }, + ], "any"), + "EventLineItem": o([ + { json: "id", js: "id", typ: "" }, + { json: "quantity", js: "quantity", typ: 0 }, + ], "any"), + "ExpectationElement": o([ + { json: "description", js: "description", typ: u(undefined, "") }, + { json: "destination", js: "destination", typ: r("BillingAddressObject") }, + { json: "fulfillable_on", js: "fulfillableOn", typ: u(undefined, "") }, + { json: "id", js: "id", typ: "" }, + { json: "line_items", js: "lineItems", typ: a(r("ExpectationLineItemObject")) }, + { json: "method_type", js: "methodType", typ: r("MethodType") }, + ], "any"), + "ExpectationLineItemObject": o([ + { json: "id", js: "id", typ: "" }, + { json: "quantity", js: "quantity", typ: 0 }, + ], "any"), + "LineItemElement": o([ + { json: "id", js: "id", typ: "" }, + { json: "item", js: "item", typ: r("ItemObject") }, + { json: "parent_id", js: "parentId", typ: u(undefined, "") }, + { json: "quantity", js: "quantity", typ: r("LineItemQuantity") }, + { json: "status", js: "status", typ: r("OrderLineItemStatus") }, + { json: "totals", js: "totals", typ: a(r("LineItemTotal")) }, + ], "any"), + "LineItemQuantity": o([ + { json: "fulfilled", js: "fulfilled", typ: 0 }, + { json: "original", js: "original", typ: u(undefined, 0) }, + { json: "total", js: "total", typ: 0 }, + ], "any"), + "UcpOrderResponseSchema": o([ + { json: "capabilities", js: "capabilities", typ: u(undefined, m(a(r("CapabilityResponseSchema")))) }, + { json: "payment_handlers", js: "paymentHandlers", typ: u(undefined, m(a(r("PaymentHandlerResponseSchema")))) }, + { json: "services", js: "services", typ: u(undefined, m(a(r("UcpOrderResponseSchemaService")))) }, + { json: "status", js: "status", typ: u(undefined, r("UcpCheckoutResponseSchemaStatus")) }, + { json: "version", js: "version", typ: "" }, + ], "any"), + "InstrumentsChangeResult": o([ + { json: "checkout", js: "checkout", typ: u(undefined, r("InstrumentsChangeCheckout")) }, + { json: "ucp", js: "ucp", typ: r("InstrumentsChangeResultUcp") }, + { json: "continue_url", js: "continueUrl", typ: u(undefined, "") }, + { json: "messages", js: "messages", typ: u(undefined, a(r("MessageElement"))) }, + ], "any"), + "InstrumentsChangeCheckout": o([ + { json: "payment", js: "payment", typ: u(undefined, r("InstrumentsChangePayment")) }, + ], "any"), + "InstrumentsChangePayment": o([ + { json: "instruments", js: "instruments", typ: u(undefined, a(r("PurpleSelectedPaymentInstrument"))) }, + { json: "selected_instrument_id", js: "selectedInstrumentId", typ: u(undefined, "") }, + ], "any"), + "PurpleSelectedPaymentInstrument": o([ + { json: "billing_address", js: "billingAddress", typ: u(undefined, r("BillingAddressObject")) }, + { json: "credential", js: "credential", typ: u(undefined, r("CredentialObject")) }, + { json: "display", js: "display", typ: u(undefined, m("any")) }, + { json: "handler_id", js: "handlerId", typ: "" }, + { json: "id", js: "id", typ: "" }, + { json: "type", js: "type", typ: "" }, + { json: "selected", js: "selected", typ: u(undefined, true) }, + ], "any"), + "InstrumentsChangeResultUcp": o([ + { json: "capabilities", js: "capabilities", typ: u(undefined, m(a(r("CapabilityElement")))) }, + { json: "payment_handlers", js: "paymentHandlers", typ: u(undefined, m(a(r("PaymentHandlerElement")))) }, + { json: "services", js: "services", typ: u(undefined, m(a(r("PurpleService")))) }, + { json: "status", js: "status", typ: r("UcpCheckoutResponseSchemaStatus") }, + { json: "version", js: "version", typ: "" }, + ], "any"), + "CapabilityElement": o([ + { json: "config", js: "config", typ: u(undefined, m("any")) }, + { json: "id", js: "id", typ: u(undefined, "") }, + { json: "schema", js: "schema", typ: u(undefined, "") }, + { json: "spec", js: "spec", typ: u(undefined, "") }, + { json: "version", js: "version", typ: "" }, + { json: "extends", js: "extends", typ: u(undefined, u(a(""), "")) }, + ], "any"), + "PaymentHandlerElement": o([ + { json: "config", js: "config", typ: u(undefined, m("any")) }, + { json: "id", js: "id", typ: "" }, + { json: "schema", js: "schema", typ: u(undefined, "") }, + { json: "spec", js: "spec", typ: u(undefined, "") }, + { json: "version", js: "version", typ: "" }, + { json: "available_instruments", js: "availableInstruments", typ: u(undefined, a(r("PaymentHandlerAvailableInstrument"))) }, + ], "any"), + "PaymentHandlerAvailableInstrument": o([ + { json: "constraints", js: "constraints", typ: u(undefined, m("any")) }, + { json: "type", js: "type", typ: "" }, + ], "any"), + "PurpleService": o([ + { json: "config", js: "config", typ: u(undefined, m("any")) }, + { json: "id", js: "id", typ: u(undefined, "") }, + { json: "schema", js: "schema", typ: u(undefined, "") }, + { json: "spec", js: "spec", typ: u(undefined, "") }, + { json: "version", js: "version", typ: "" }, + { json: "endpoint", js: "endpoint", typ: u(undefined, "") }, + { json: "transport", js: "transport", typ: r("Transport") }, + ], "any"), + "CredentialResult": o([ + { json: "checkout", js: "checkout", typ: u(undefined, r("CredentialCheckout")) }, + { json: "ucp", js: "ucp", typ: r("InstrumentsChangeResultUcp") }, + { json: "continue_url", js: "continueUrl", typ: u(undefined, "") }, + { json: "messages", js: "messages", typ: u(undefined, a(r("MessageElement"))) }, + ], "any"), + "CredentialCheckout": o([ + { json: "payment", js: "payment", typ: u(undefined, r("CredentialPayment")) }, + ], "any"), + "CredentialPayment": o([ + { json: "instruments", js: "instruments", typ: u(undefined, a(r("PurpleSelectedPaymentInstrument"))) }, + ], "any"), + "ContentType": [ + "markdown", + "plain", + ], + "Severity": [ + "recoverable", + "requires_buyer_input", + "requires_buyer_review", + "unrecoverable", + ], + "MessageType": [ + "error", + "info", + "warning", + ], + "CheckoutStatus": [ + "canceled", + "complete_in_progress", + "completed", + "incomplete", + "ready_for_complete", + "requires_escalation", + ], + "EmbeddedColorScheme": [ + "dark", + "light", + ], + "Transport": [ + "a2a", + "embedded", + "mcp", + "rest", + ], + "UcpCheckoutResponseSchemaStatus": [ + "error", + "success", + ], + "AdjustmentStatus": [ + "completed", + "failed", + "pending", + ], + "TypeElement": [ + "pickup", + "shipping", + ], + "CardNumberType": [ + "dpan", + "fpan", + "network_token", + ], + "TypeEnum": [ + "card", + ], + "StatusEnum": [ + "error", + ], + "MethodType": [ + "digital", + "pickup", + "shipping", + ], + "MessageInfoType": [ + "info", + ], + "MessageWarningType": [ + "warning", + ], + "OrderLineItemStatus": [ + "fulfilled", + "partial", + "processing", + "removed", + ], +}; diff --git a/protocol/languages/typescript/src/index.d.ts b/protocol/languages/typescript/src/index.d.ts new file mode 100644 index 00000000..5bb7fb3a --- /dev/null +++ b/protocol/languages/typescript/src/index.d.ts @@ -0,0 +1 @@ +export * from './generated/Models'; diff --git a/protocol/languages/typescript/src/index.ts b/protocol/languages/typescript/src/index.ts index 34cfa43a..5bb7fb3a 100644 --- a/protocol/languages/typescript/src/index.ts +++ b/protocol/languages/typescript/src/index.ts @@ -1 +1 @@ -export type * from './generated/Models'; +export * from './generated/Models'; diff --git a/protocol/scripts/generate_models.sh b/protocol/scripts/generate_models.sh index 4668656e..f1757794 100755 --- a/protocol/scripts/generate_models.sh +++ b/protocol/scripts/generate_models.sh @@ -157,7 +157,6 @@ case "$LANG" in quicktype \ --lang ts \ --src-lang schema \ - --just-types \ --prefer-unions \ --nice-property-names \ --acronym-style camel \ @@ -179,7 +178,32 @@ case "$LANG" in "${OUTPUT}" - echo "Generated ${OUTPUT}" + # API Extractor consumers require dependency entry points to resolve to + # declaration files. Runtime converter output is not valid declaration syntax, + # so emit declarations from the generated TypeScript source. + DECLARATION_OUTPUT="${OUTPUT%.ts}.d.ts" + TSC_BIN="${REPO_ROOT}/platforms/react-native/node_modules/typescript/bin/tsc" + if [[ -f "${TSC_BIN}" ]]; then + node "${TSC_BIN}" \ + --declaration \ + --emitDeclarationOnly \ + --noEmit false \ + --rootDir "${REPO_ROOT}/protocol/languages/typescript/src" \ + --declarationDir "${REPO_ROOT}/protocol/languages/typescript/src" \ + --pretty false \ + "${OUTPUT}" + else + tsc \ + --declaration \ + --emitDeclarationOnly \ + --noEmit false \ + --rootDir "${REPO_ROOT}/protocol/languages/typescript/src" \ + --declarationDir "${REPO_ROOT}/protocol/languages/typescript/src" \ + --pretty false \ + "${OUTPUT}" + fi + + echo "Generated ${OUTPUT} and ${DECLARATION_OUTPUT}" ;; *)