Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -128,4 +128,25 @@ extension ShopifyCheckoutViewController: CheckoutDelegate {
OSLogger.shared.debug("[EmbeddedCheckout] Checkout failed: \(error.localizedDescription)")
dismiss(animated: true)
}

func checkoutDidStartSubmit(event: CheckoutSubmitStart) {
// Respond with a test payment token after 1 second to simulate payment processing
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
let paymentToken = PaymentTokenInput(
token: "tok_test_123",
tokenType: "card",
tokenProvider: "delegated"
)

let response = CheckoutSubmitStartResponsePayload(payment: paymentToken)

OSLogger.shared.debug("[EmbeddedCheckout] Attempting to respond with test payment token")
do {
try event.respondWith(payload: response)
OSLogger.shared.debug("[EmbeddedCheckout] Successfully sent response")
} catch {
OSLogger.shared.error("[EmbeddedCheckout] Failed to respond to submit start: \(error)")
}
}
}
}
10 changes: 10 additions & 0 deletions Sources/ShopifyCheckoutSheetKit/CheckoutDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ public protocol CheckoutDelegate: AnyObject {

/// Tells the delegate that the checkout is requesting card change intent (e.g., for native card picker)
func checkoutDidRequestCardChange(event: CheckoutCardChangeRequested)

/// Tells the delegate that the buyer has attempted to submit the checkout.
///
/// This event is only emitted when native payment delegation is configured for the authenticated app.
/// When triggered, you can provide payment tokens, update cart data, or handle custom submission logic.
func checkoutDidStartSubmit(event: CheckoutSubmitStart)
}

extension CheckoutDelegate {
Expand Down Expand Up @@ -84,6 +90,10 @@ extension CheckoutDelegate {
/// No-op by default
}

public func checkoutDidStartSubmit(event _: CheckoutSubmitStart) {
/// No-op by default
}

private func handleUrl(_ url: URL) {
if UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url)
Expand Down
46 changes: 46 additions & 0 deletions Sources/ShopifyCheckoutSheetKit/CheckoutSubmitStart.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
MIT License

Copyright 2023 - Present, Shopify Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/

import Foundation
import WebKit

public final class CheckoutSubmitStart: BaseRPCRequest<CheckoutSubmitStartParams, CheckoutSubmitStartResponsePayload> {
override public static var method: String { "checkout.submitStart" }
}

public struct CheckoutSubmitStartParams: Codable {
public let cart: Cart
public let checkout: Checkout
}

public struct CheckoutSubmitStartResponsePayload: Codable {
public let payment: PaymentTokenInput?
public let cart: CartInput?
public let errors: [ResponseError]?

public init(payment: PaymentTokenInput? = nil, cart: CartInput? = nil, errors: [ResponseError]? = nil) {
self.payment = payment
self.cart = cart
self.errors = errors
}
}
7 changes: 7 additions & 0 deletions Sources/ShopifyCheckoutSheetKit/CheckoutWebView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ protocol CheckoutWebViewDelegate: AnyObject {
func checkoutViewDidToggleModal(modalVisible: Bool)
func checkoutViewDidStartAddressChange(event: CheckoutAddressChangeStart)
func checkoutViewDidRequestCardChange(event: CheckoutCardChangeRequested)
func checkoutViewDidStartSubmit(event: CheckoutSubmitStart)
}

private let deprecatedReasonHeader = "x-shopify-api-deprecated-reason"
Expand Down Expand Up @@ -298,6 +299,12 @@ extension CheckoutWebView: WKScriptMessageHandler {
)
viewDelegate.checkoutViewDidRequestCardChange(event: cardRequest)

case let submitRequest as CheckoutSubmitStart:
OSLogger.shared.info(
"Checkout submit start event received"
)
viewDelegate.checkoutViewDidStartSubmit(event: submitRequest)

case let errorRequest as CheckoutErrorRequest:
handleCheckoutError(errorRequest)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,10 @@ extension CheckoutWebViewController: CheckoutWebViewDelegate {
delegate?.checkoutDidRequestCardChange(event: event)
}

func checkoutViewDidStartSubmit(event: CheckoutSubmitStart) {
delegate?.checkoutDidStartSubmit(event: event)
}

private func isRecoverableError() -> Bool {
/// Reuse of multipass tokens will cause 422 errors. A new token must be generated
return !CheckoutURL(from: checkoutURL).isMultipassURL()
Expand Down
3 changes: 2 additions & 1 deletion Sources/ShopifyCheckoutSheetKit/RPCRequestRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ enum RPCRequestRegistry {
CheckoutCompleteRequest.self,
CheckoutErrorRequest.self,
CheckoutModalToggledRequest.self,
CheckoutStartRequest.self
CheckoutStartRequest.self,
CheckoutSubmitStart.self
]

/// Find the request type for a given method name
Expand Down
27 changes: 27 additions & 0 deletions Sources/ShopifyCheckoutSheetKit/Types.swift
Original file line number Diff line number Diff line change
Expand Up @@ -454,3 +454,30 @@ public struct ResponseError: Codable {
self.fieldTarget = fieldTarget
}
}

// MARK: - Payment Token Types

/// Payment token input structure for checkout submission.
public struct PaymentTokenInput: Codable {
public let token: String
public let tokenType: String
public let tokenProvider: String

public init(token: String, tokenType: String, tokenProvider: String) {
self.token = token
self.tokenType = tokenType
self.tokenProvider = tokenProvider
}
}

// MARK: - Checkout Session

/// Checkout session information.
public struct Checkout: Codable {
/// The checkout session identifier
public let id: String

public init(id: String) {
self.id = id
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,49 @@
import XCTest

class CheckoutAddressChangeStartTests: XCTestCase {
// MARK: - Response Tests

func testRespondWithSendsJavaScriptToWebView() throws {
let mockWebView = MockWebView()
let params = CheckoutAddressChangeStartParams(
addressType: "shipping",
cart: createTestCart()
)
let request = CheckoutAddressChangeStart(id: "test-id-789", params: params)
request.webview = mockWebView

let payload = CheckoutAddressChangeStartResponsePayload(
cart: CartInput(
delivery: CartDeliveryInput(
addresses: [
CartSelectableAddressInput(
address: CartDeliveryAddressInput(countryCode: "US")
)
]
)
)
)

let expectation = expectation(description: "JavaScript executed")
mockWebView.evaluateJavaScriptExpectation = expectation

try request.respondWith(payload: payload)

waitForExpectations(timeout: 2.0)

// Verify the JavaScript was executed and contains the expected JSON-RPC response
XCTAssertNotNil(mockWebView.capturedJavaScript, "JavaScript should have been executed")

let capturedJS = mockWebView.capturedJavaScript ?? ""

// Verify the response contains expected JSON-RPC fields
XCTAssertTrue(capturedJS.contains("window.postMessage"), "Should call window.postMessage")
XCTAssertTrue(capturedJS.contains("\"jsonrpc\":\"2.0\""), "Should include JSON-RPC version")
XCTAssertTrue(capturedJS.contains("\"id\":\"test-id-789\""), "Should include request ID")
XCTAssertTrue(capturedJS.contains("\"result\""), "Should include result field")
XCTAssertTrue(capturedJS.contains("\"cart\""), "Should include cart in result")
}

// MARK: - Validation Tests

func testValidateAcceptsValid2CharacterCountryCode() throws {
Expand Down
57 changes: 57 additions & 0 deletions Tests/ShopifyCheckoutSheetKitTests/CheckoutBridgeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -302,4 +302,61 @@ class CheckoutBridgeTests: XCTestCase {
XCTAssertEqual("gid://shopify/Order/test-order-123", completeRequest.params.orderConfirmation.order.id)
XCTAssertEqual("gid://shopify/Cart/test-cart-123", completeRequest.params.cart.id)
}

func testDecodeSupportsCheckoutSubmitStart() throws {
let mock = WKScriptMessageMock(
body: """
{
"jsonrpc": "2.0",
"method": "checkout.submitStart",
"id": "submit-123",
"params": {
"cart": {
"id": "gid://shopify/Cart/test-cart-456",
"lines": [],
"cost": {
"subtotalAmount": {
"amount": "100.00",
"currencyCode": "USD"
},
"totalAmount": {
"amount": "100.00",
"currencyCode": "USD"
}
},
"buyerIdentity": {
"email": "[email protected]",
"phone": null,
"customer": null,
"countryCode": "US"
},
"deliveryGroups": [],
"discountCodes": [],
"appliedGiftCards": [],
"discountAllocations": [],
"delivery": {
"addresses": []
}
},
"checkout": {
"id": "checkout-session-789"
}
}
}
""",
webView: mockWebView
)

let result = try CheckoutBridge.decode(mock)

guard let submitRequest = result as? CheckoutSubmitStart else {
XCTFail("Expected CheckoutSubmitStart, got \(result)")
return
}

XCTAssertEqual("submit-123", submitRequest.id)
XCTAssertEqual("gid://shopify/Cart/test-cart-456", submitRequest.params.cart.id)
XCTAssertEqual("checkout-session-789", submitRequest.params.checkout.id)
XCTAssertEqual("[email protected]", submitRequest.params.cart.buyerIdentity.email)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/*
MIT License

Copyright 2023 - Present, Shopify Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/

@testable import ShopifyCheckoutSheetKit
import XCTest

class CheckoutSubmitStartTests: XCTestCase {
// MARK: - Response Tests

func testRespondWithSendsJavaScriptToWebView() throws {
let mockWebView = MockWebView()
let params = CheckoutSubmitStartParams(
cart: createTestCart(),
checkout: Checkout(id: "test-checkout-123")
)
let request = CheckoutSubmitStart(id: "test-id-456", params: params)
request.webview = mockWebView

let payload = CheckoutSubmitStartResponsePayload(payment: nil, cart: nil, errors: nil)

let expectation = expectation(description: "JavaScript executed")
mockWebView.evaluateJavaScriptExpectation = expectation

try request.respondWith(payload: payload)

waitForExpectations(timeout: 2.0)

// Verify the JavaScript was executed and contains the expected JSON-RPC response
XCTAssertNotNil(mockWebView.capturedJavaScript, "JavaScript should have been executed")

let capturedJS = mockWebView.capturedJavaScript ?? ""

// Verify the response contains expected JSON-RPC fields
XCTAssertTrue(capturedJS.contains("window.postMessage"), "Should call window.postMessage")
XCTAssertTrue(capturedJS.contains("\"jsonrpc\":\"2.0\""), "Should include JSON-RPC version")
XCTAssertTrue(capturedJS.contains("\"id\":\"test-id-456\""), "Should include request ID")
XCTAssertTrue(capturedJS.contains("\"result\""), "Should include result field")
}

// MARK: - Decoding Tests

func testDecodesCheckoutSessionId() throws {
let json = """
{
"cart": {
"id": "gid://shopify/Cart/test-cart-789",
"lines": [],
"cost": {
"subtotalAmount": {"amount": "75.00", "currencyCode": "CAD"},
"totalAmount": {"amount": "75.00", "currencyCode": "CAD"}
},
"buyerIdentity": {},
"deliveryGroups": [],
"discountCodes": [],
"appliedGiftCards": [],
"discountAllocations": [],
"delivery": {"addresses": []}
},
"checkout": {
"id": "checkout-session-123"
}
}
"""

let data = json.data(using: .utf8)!
let params = try JSONDecoder().decode(CheckoutSubmitStartParams.self, from: data)

XCTAssertEqual(params.checkout.id, "checkout-session-123")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,8 @@ class MockCheckoutWebViewDelegate: CheckoutWebViewDelegate {
func checkoutViewDidRequestCardChange(event _: CheckoutCardChangeRequested) {
// No-op for tests unless explicitly asserted
}

func checkoutViewDidStartSubmit(event _: CheckoutSubmitStart) {
// No-op for tests unless explicitly asserted
}
}
7 changes: 4 additions & 3 deletions Tests/ShopifyCheckoutSheetKitTests/Mocks/MockWebView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,13 @@ import WebKit
import XCTest

class MockWebView: CheckoutWebView {
var expectedScript = ""

var evaluateJavaScriptExpectation: XCTestExpectation?

var capturedJavaScript: String?

override func evaluateJavaScript(_ javaScriptString: String) async throws -> Any {
if javaScriptString == expectedScript {
capturedJavaScript = javaScriptString
if !javaScriptString.isEmpty {
evaluateJavaScriptExpectation?.fulfill()
}
return true
Expand Down
Loading