From 359aacce2ccc6436f043e0ee323c9dec73dbf5ea Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Thu, 8 Jul 2021 14:47:41 +1000 Subject: [PATCH 01/81] Add swiftlint warning directive --- Sources/Afterpay/Checkout/CheckoutV2ViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Afterpay/Checkout/CheckoutV2ViewController.swift b/Sources/Afterpay/Checkout/CheckoutV2ViewController.swift index 72d32260..c361dcc2 100644 --- a/Sources/Afterpay/Checkout/CheckoutV2ViewController.swift +++ b/Sources/Afterpay/Checkout/CheckoutV2ViewController.swift @@ -11,7 +11,7 @@ import os.log import UIKit import WebKit -// swiftlint:disable:next colon +// swiftlint:disable:next colon type_body_length final class CheckoutV2ViewController: UIViewController, UIAdaptivePresentationControllerDelegate, From 20051c41bc9b2caccf8eb4601db31351fd3f9eb2 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Tue, 13 Jul 2021 14:44:34 +1000 Subject: [PATCH 02/81] Add V3 implementation to Example app --- Example/Example.xcodeproj/project.pbxproj | 8 +- .../Example/Purchase/CartViewController.swift | 3 +- .../Purchase/PurchaseFlowController.swift | 50 ++++++- .../Purchase/PurchaseLogicController.swift | 10 +- .../SingleUseCardResultViewController.swift | 139 ++++++++++++++++++ Example/Example/Shared/APIClient.swift | 7 +- 6 files changed, 208 insertions(+), 9 deletions(-) create mode 100644 Example/Example/Purchase/SingleUseCardResultViewController.swift diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index e4e1e248..478d8e79 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -13,8 +13,8 @@ 157C65AD25D0F19900115149 /* Result+Fold.swift in Sources */ = {isa = PBXBuildFile; fileRef = 157C65AC25D0F19900115149 /* Result+Fold.swift */; }; 5539D82C25EDE97E0088BC97 /* ControlCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5539D82B25EDE97E0088BC97 /* ControlCell.swift */; }; 5539D83025F068F90088BC97 /* CheckoutOptionsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5539D82F25F068F90088BC97 /* CheckoutOptionsCell.swift */; }; - 55719BA926363ED800634B27 /* SwiftUIWidgetExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55719BA826363ED800634B27 /* SwiftUIWidgetExample.swift */; }; 55432838263B7EC2005512E4 /* ExampleUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55432837263B7EC2005512E4 /* ExampleUITests.swift */; }; + 55719BA926363ED800634B27 /* SwiftUIWidgetExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55719BA826363ED800634B27 /* SwiftUIWidgetExample.swift */; }; 55FA7270260025DC0006EFCB /* WidgetHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55FA726F260025DC0006EFCB /* WidgetHandler.swift */; }; 660072B724A1B55E00E9A2BC /* TextSettingCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 660072B624A1B55E00E9A2BC /* TextSettingCell.swift */; }; 6620B5D224934FB3004162BC /* AppFlowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6620B5D124934FB3004162BC /* AppFlowController.swift */; }; @@ -49,6 +49,7 @@ 66F2D8BC24A06E3300F65621 /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66F2D8BB24A06E3300F65621 /* SettingsViewController.swift */; }; 66F9767824999F5C001D38FA /* Afterpay.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 66F9767724999F5C001D38FA /* Afterpay.framework */; }; 66F9767924999F5C001D38FA /* Afterpay.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 66F9767724999F5C001D38FA /* Afterpay.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 83F7DEE3269D2BAA00F9FB75 /* SingleUseCardResultViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEE2269D2BAA00F9FB75 /* SingleUseCardResultViewController.swift */; }; 945B92A624DA19FA0009BD3C /* Environment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 945B92A524DA19FA0009BD3C /* Environment.swift */; }; 948F664824B6E95900DC0202 /* SegmentedSettingCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 948F664724B6E95900DC0202 /* SegmentedSettingCell.swift */; }; 9490D1D624D8ED4F001E1EFC /* Repository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9490D1D524D8ED4F001E1EFC /* Repository.swift */; }; @@ -87,9 +88,9 @@ 5539D82B25EDE97E0088BC97 /* ControlCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlCell.swift; sourceTree = ""; }; 5539D82F25F068F90088BC97 /* CheckoutOptionsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckoutOptionsCell.swift; sourceTree = ""; }; 55432835263B7EC2005512E4 /* ExampleUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ExampleUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 55719BA826363ED800634B27 /* SwiftUIWidgetExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIWidgetExample.swift; sourceTree = ""; }; 55432837263B7EC2005512E4 /* ExampleUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleUITests.swift; sourceTree = ""; }; 55432839263B7EC2005512E4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 55719BA826363ED800634B27 /* SwiftUIWidgetExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIWidgetExample.swift; sourceTree = ""; }; 55FA726F260025DC0006EFCB /* WidgetHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetHandler.swift; sourceTree = ""; }; 660072B624A1B55E00E9A2BC /* TextSettingCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextSettingCell.swift; sourceTree = ""; }; 6620B5D124934FB3004162BC /* AppFlowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppFlowController.swift; sourceTree = ""; }; @@ -126,6 +127,7 @@ 66F2D8B924A0415700F65621 /* WindowHolder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowHolder.swift; sourceTree = ""; }; 66F2D8BB24A06E3300F65621 /* SettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = ""; }; 66F9767724999F5C001D38FA /* Afterpay.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Afterpay.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 83F7DEE2269D2BAA00F9FB75 /* SingleUseCardResultViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleUseCardResultViewController.swift; sourceTree = ""; }; 945B92A524DA19FA0009BD3C /* Environment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Environment.swift; sourceTree = ""; }; 948F664724B6E95900DC0202 /* SegmentedSettingCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentedSettingCell.swift; sourceTree = ""; }; 9490D1D524D8ED4F001E1EFC /* Repository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Repository.swift; sourceTree = ""; }; @@ -200,6 +202,7 @@ 155AD17024AB2464000013B4 /* PurchaseFlowController.swift */, 66E6FDA324AC344800ED81E8 /* PurchaseLogicController.swift */, 66E6FDA724AC3BDD00ED81E8 /* PurchaseState.swift */, + 83F7DEE2269D2BAA00F9FB75 /* SingleUseCardResultViewController.swift */, 66BD11B324ADB7EB00039DA6 /* TitleSubtitleCell.swift */, 55FA726F260025DC0006EFCB /* WidgetHandler.swift */, ); @@ -493,6 +496,7 @@ 668F162424877F950040345C /* SceneDelegate.swift in Sources */, 155AD17124AB2464000013B4 /* PurchaseFlowController.swift in Sources */, 55719BA926363ED800634B27 /* SwiftUIWidgetExample.swift in Sources */, + 83F7DEE3269D2BAA00F9FB75 /* SingleUseCardResultViewController.swift in Sources */, 55FA7270260025DC0006EFCB /* WidgetHandler.swift in Sources */, 6662628A2496F5EB0094FC26 /* APIClient.swift in Sources */, 665E6A0B24935624009E3BA1 /* Extensions.swift in Sources */, diff --git a/Example/Example/Purchase/CartViewController.swift b/Example/Example/Purchase/CartViewController.swift index ee9613d9..a24b96c9 100644 --- a/Example/Example/Purchase/CartViewController.swift +++ b/Example/Example/Purchase/CartViewController.swift @@ -22,6 +22,7 @@ final class CartViewController: UIViewController, UITableViewDataSource { enum Event { case didTapPay case optionsChanged(CheckoutOptionsCell.Event) + case didTapSingleUseCardButton } init(cart: CartDisplay, eventHandler: @escaping (Event) -> Void) { @@ -71,7 +72,7 @@ final class CartViewController: UIViewController, UITableViewDataSource { // MARK: Actions @objc private func didTapPay() { - eventHandler(.didTapPay) + eventHandler(.didTapSingleUseCardButton) } // MARK: UITableViewDataSource diff --git a/Example/Example/Purchase/PurchaseFlowController.swift b/Example/Example/Purchase/PurchaseFlowController.swift index 2b079b7f..4c2f1e37 100644 --- a/Example/Example/Purchase/PurchaseFlowController.swift +++ b/Example/Example/Purchase/PurchaseFlowController.swift @@ -42,6 +42,15 @@ final class PurchaseFlowController: UIViewController { Afterpay.setCheckoutV2Handler(checkoutHandler) + // This option object may also be passed directly into calls to `AfterPay.presentCheckoutV3Modally` + Afterpay.setV3Options(CheckoutV3Options( + shopDirectoryId: "", + shopDirectoryMerchantId: "", + merchantPublicKey: "", + region: .US, + environment: .sandbox + )) + widgetHandler = WidgetEventHandler() Afterpay.setWidgetHandler(widgetHandler) @@ -83,6 +92,8 @@ final class PurchaseFlowController: UIViewController { logicController.toggleCheckoutV2Option(\.shippingOptionRequired) case .optionsChanged(.expressToggled): logicController.toggleExpressCheckout() + case .didTapSingleUseCardButton: + logicController.payWithAfterpayV3() } } @@ -94,8 +105,11 @@ final class PurchaseFlowController: UIViewController { loading: checkoutURL ) { result in switch result { - case .success(let token): + case .success(.token(let token)): logicController.success(with: token) + // Here for backward compatibility; this case will never be hit + case .success(_): + break case .cancelled(let reason): logicController.cancelled(with: reason) } @@ -104,8 +118,33 @@ final class PurchaseFlowController: UIViewController { case .showAfterpayCheckoutV2(let options): Afterpay.presentCheckoutV2Modally(over: ownedNavigationController, options: options) { result in switch result { - case .success(let token): + case .success(.token(let token)): logicController.success(with: token) + // Here for backward compatibility; this case will never be hit + case .success(_): + break + case .cancelled(let reason): + logicController.cancelled(with: reason) + } + } + + case .showAfterpayCheckoutV3(let consumer, let total): + Afterpay.presentCheckoutV3Modally( + over: ownedNavigationController, + consumer: consumer, + total: total, + requestHandler: APIClient.live.session.dataTask + ) { result in + switch result { + // Here for backward compatibility; this case will never be hit + case .success(.token(_)): + break + case .success(.singleUseCard(_, let validUntil, let cardDetails)): + let controller = SingleUseCardResultViewController( + details: cardDetails, + authorizationExpiration: validUntil + ) + navigationController.pushViewController(controller, animated: true) case .cancelled(let reason): logicController.cancelled(with: reason) } @@ -136,3 +175,10 @@ final class PurchaseFlowController: UIViewController { } } + +struct Consumer: CheckoutV3Consumer { + var email: String + var givenNames: String? { nil } + var surname: String? { nil } + var phoneNumber: String? { nil } +} diff --git a/Example/Example/Purchase/PurchaseLogicController.swift b/Example/Example/Purchase/PurchaseLogicController.swift index c45b2585..ff72d60f 100644 --- a/Example/Example/Purchase/PurchaseLogicController.swift +++ b/Example/Example/Purchase/PurchaseLogicController.swift @@ -18,6 +18,7 @@ final class PurchaseLogicController { case showAfterpayCheckoutV1(checkoutURL: URL) case showAfterpayCheckoutV2(CheckoutV2Options) + case showAfterpayCheckoutV3(consumer: Consumer, total: Decimal) case provideCheckoutTokenResult(TokenResult) case provideShippingOptionsResult(ShippingOptionsResult) @@ -116,6 +117,13 @@ final class PurchaseLogicController { } } + func payWithAfterpayV3() { + commandHandler(.showAfterpayCheckoutV3( + consumer: Consumer(email: email), + total: total + )) + } + func loadCheckoutURL(then command: @escaping (URL) -> Void) { let formatter = CurrencyFormatter(currencyCode: currencyCode) let amount = formatter.string(from: total) @@ -170,7 +178,7 @@ final class PurchaseLogicController { let errorMessageToShow: String? switch reason { - case .networkError(let error): + case .networkError(let error), .apiError(let error): errorMessageToShow = error.localizedDescription case .userInitiated: errorMessageToShow = nil diff --git a/Example/Example/Purchase/SingleUseCardResultViewController.swift b/Example/Example/Purchase/SingleUseCardResultViewController.swift new file mode 100644 index 00000000..a6e8d3c5 --- /dev/null +++ b/Example/Example/Purchase/SingleUseCardResultViewController.swift @@ -0,0 +1,139 @@ +// +// SingleUseCardResultViewController.swift +// Example +// +// Created by Chris Kolbu on 13/7/21. +// Copyright © 2021 Afterpay. All rights reserved. +// + +import Afterpay +import Foundation +import UIKit + +final class SingleUseCardResultViewController: UIViewController { + + // MARK: - Properties + private let details: CardDetails + private let authorizationExpiration: Date? + + private let vStack: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.spacing = 8 + stackView.translatesAutoresizingMaskIntoConstraints = false + return stackView + }() + + private lazy var labels: [UILabel] = { + let strings = [ + "Card number: \(details.cardNumber)", + "CVC: \(details.cvc)", + "Expiration: \(details.expiryMonth)/\(details.expiryYear)", + "Virtual card expiry: \(authorizationExpiration?.shortDuration ?? "Unavailable")", + ] + + return strings.map { string in + let label = UILabel() + label.numberOfLines = 0 + label.font = .preferredFont(forTextStyle: .body) + label.adjustsFontForContentSizeCategory = true + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = .systemGray + label.text = string + return label + } + }() + + private lazy var explanatoryHeaderLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 0 + label.font = .preferredFont(forTextStyle: .headline) + label.adjustsFontForContentSizeCategory = true + label.translatesAutoresizingMaskIntoConstraints = false + label.text = "Next steps" + return label + }() + + private lazy var explanatoryBodyLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 0 + label.font = .preferredFont(forTextStyle: .body) + label.adjustsFontForContentSizeCategory = true + label.translatesAutoresizingMaskIntoConstraints = false + label.text = "After the user has completed their Afterpay checkout, " + + "you will have until the `virtual card expiry` to perform an authorisation on the card. " + + "This completes the purchase." + return label + }() + + // MARK: - Initializer + + init(details: CardDetails, authorizationExpiration: Date?) { + self.details = details + self.authorizationExpiration = authorizationExpiration + + super.init(nibName: nil, bundle: nil) + + self.title = "Single Use Card" + } + + // MARK: - View lifecycle + + override func loadView() { + view = UIScrollView() + view.backgroundColor = .appBackground + } + + override func viewDidLoad() { + super.viewDidLoad() + view.addSubview(vStack) + (labels + [explanatoryHeaderLabel, explanatoryBodyLabel]).forEach(vStack.addArrangedSubview) + vStack.setCustomSpacing(24, after: labels.last!) + setConstraints() + } + + // MARK: - Constraints + + private func setConstraints() { + let scrollView = view as! UIScrollView + NSLayoutConstraint.activate([ + vStack.topAnchor.constraint( + equalToSystemSpacingBelow: scrollView.contentLayoutGuide.topAnchor, + multiplier: 2 + ), + vStack.leadingAnchor.constraint( + equalToSystemSpacingAfter: scrollView.contentLayoutGuide.leadingAnchor, + multiplier: 2 + ), + scrollView.contentLayoutGuide.trailingAnchor.constraint( + equalToSystemSpacingAfter: vStack.trailingAnchor, + multiplier: 2 + ), + scrollView.contentLayoutGuide.bottomAnchor.constraint( + greaterThanOrEqualToSystemSpacingBelow: vStack.bottomAnchor, + multiplier: 2 + ), + scrollView.contentLayoutGuide.widthAnchor.constraint( + equalTo: scrollView.frameLayoutGuide.widthAnchor + ), + ]) + } + + @available(*, unavailable) + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Private extensions + +private extension Date { + var shortDuration: String? { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.hour, .minute, .second] + formatter.maximumUnitCount = 3 + formatter.unitsStyle = .abbreviated + + return formatter.string(from: Date(), to: self) + } +} diff --git a/Example/Example/Shared/APIClient.swift b/Example/Example/Shared/APIClient.swift index 5a2fa35f..f5c73375 100644 --- a/Example/Example/Shared/APIClient.swift +++ b/Example/Example/Shared/APIClient.swift @@ -8,7 +8,7 @@ import Foundation -private let session = URLSession(configuration: .default) +private let urlSession = URLSession(configuration: .default) enum CheckoutMode: Equatable { case v1 @@ -55,6 +55,7 @@ enum NetworkError: Error { struct APIClient { typealias Completion = (Result) -> Void + var session: URLSession { urlSession } var configuration: (_ completion: @escaping Completion) -> Void var checkout: (_ email: String, _ amount: String, _ checkoutMode: CheckoutMode, _ completion: @escaping Completion) -> Void @@ -63,10 +64,10 @@ struct APIClient { extension APIClient { static let live = Self( configuration: { completion in - session.request(.configuration, completion: completion) + urlSession.request(.configuration, completion: completion) }, checkout: { email, amount, checkoutMode, completion in - session.request(.checkout(email: email, amount: amount, checkoutMode: checkoutMode), completion: completion) + urlSession.request(.checkout(email: email, amount: amount, checkoutMode: checkoutMode), completion: completion) } ) } From f3d7b4e256ca4cda439c87389167391fd3b71d94 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Tue, 13 Jul 2021 14:45:51 +1000 Subject: [PATCH 03/81] Add `api-plus` to set of valid hosts This is required for the V3/Button functionality --- Sources/Afterpay/Checkout/CheckoutHost.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/Afterpay/Checkout/CheckoutHost.swift b/Sources/Afterpay/Checkout/CheckoutHost.swift index f4b76376..076ba973 100644 --- a/Sources/Afterpay/Checkout/CheckoutHost.swift +++ b/Sources/Afterpay/Checkout/CheckoutHost.swift @@ -14,6 +14,8 @@ enum CheckoutHost: String, CaseIterable { case afterpay = "portal.afterpay.com" case afterpaySandbox = "portal.sandbox.afterpay.com" + case afterpayPlusSandbox = "api-plus.us-sandbox.afterpay.com" + case afterpayPlus = "api-plus.us.afterpay.com" case clearpay = "portal.clearpay.co.uk" case clearpaySandbox = "portal.sandbox.clearpay.co.uk" From 84bce2ef7cf873f76d134c45bce09f34861ff6d1 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Tue, 13 Jul 2021 14:46:59 +1000 Subject: [PATCH 04/81] Make CheckoutResult success case polymorphic --- Sources/Afterpay/Checkout/CheckoutV2ViewController.swift | 2 +- Sources/Afterpay/Checkout/CheckoutWebViewController.swift | 2 +- Sources/Afterpay/Model/CheckoutResult.swift | 8 +++++++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/Sources/Afterpay/Checkout/CheckoutV2ViewController.swift b/Sources/Afterpay/Checkout/CheckoutV2ViewController.swift index c361dcc2..f981f94e 100644 --- a/Sources/Afterpay/Checkout/CheckoutV2ViewController.swift +++ b/Sources/Afterpay/Checkout/CheckoutV2ViewController.swift @@ -334,7 +334,7 @@ final class CheckoutV2ViewController: } else if let completion = completion { switch completion { case .success(let token): - dismiss(animated: true) { self.completion(.success(token: token)) } + dismiss(animated: true) { self.completion(.success(value: .token(token: token))) } case .cancelled: dismiss(animated: true) { self.completion(.cancelled(reason: .userInitiated)) } } diff --git a/Sources/Afterpay/Checkout/CheckoutWebViewController.swift b/Sources/Afterpay/Checkout/CheckoutWebViewController.swift index 04c55a8b..44bda2ea 100644 --- a/Sources/Afterpay/Checkout/CheckoutWebViewController.swift +++ b/Sources/Afterpay/Checkout/CheckoutWebViewController.swift @@ -138,7 +138,7 @@ final class CheckoutWebViewController: case (false, .success(let token)): decisionHandler(.cancel) - dismiss(animated: true) { self.completion(.success(token: token)) } + dismiss(animated: true) { self.completion(.success(value: .token(token: token))) } case (false, .cancelled): decisionHandler(.cancel) diff --git a/Sources/Afterpay/Model/CheckoutResult.swift b/Sources/Afterpay/Model/CheckoutResult.swift index d0711f96..f49775c8 100644 --- a/Sources/Afterpay/Model/CheckoutResult.swift +++ b/Sources/Afterpay/Model/CheckoutResult.swift @@ -9,11 +9,17 @@ import Foundation @frozen public enum CheckoutResult { - case success(token: String) + case success(value: Value) case cancelled(reason: CancellationReason) + @frozen public enum Value { + case token(token: String) + case singleUseCard(authToken: String, cardValidUntil: Date?, details: CardDetails) + } + public enum CancellationReason { case userInitiated + case apiError(Error) case networkError(Error) case invalidURL(URL) } From d27a8315310f02442856a7db80c6bb50e9ce94a2 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Tue, 13 Jul 2021 14:47:45 +1000 Subject: [PATCH 05/81] Add ObjcWrapper for new ApiError --- Sources/Afterpay/Wrappers/ObjcWrapper.swift | 23 ++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/Sources/Afterpay/Wrappers/ObjcWrapper.swift b/Sources/Afterpay/Wrappers/ObjcWrapper.swift index 0c213e37..9dd4311a 100644 --- a/Sources/Afterpay/Wrappers/ObjcWrapper.swift +++ b/Sources/Afterpay/Wrappers/ObjcWrapper.swift @@ -61,9 +61,14 @@ public final class ObjcWrapper: NSObject { CancellationReasonNetworkError(error) } + static func apiError(error: Error) -> CancellationReasonApiError { + CancellationReasonApiError(error) + } + static func invalidURL(_ url: URL) -> CancellationReasonInvalidURL { CancellationReasonInvalidURL(url) } + } @objc(APCancellationReasonUserInitiated) @@ -80,6 +85,15 @@ public final class ObjcWrapper: NSObject { } } + @objc(APCancellationReasonApiError) + public class CancellationReasonApiError: CancellationReason { + @objc public let error: Error + + init(_ error: Error) { + self.error = error + } + } + @objc(APCancellationReasonInvalidURL) public class CancellationReasonInvalidURL: CancellationReason { @objc public let url: URL @@ -102,9 +116,13 @@ public final class ObjcWrapper: NSObject { animated: animated, completion: { result in switch result { - case .success(let token): + case .success(.token(let token)): completion(.success(token: token)) + // Here for backward compatibility; this case will never trigger + case .success(_): + break + case .cancelled(.userInitiated): completion(.cancelled(reason: .userInitiated())) @@ -113,6 +131,9 @@ public final class ObjcWrapper: NSObject { case .cancelled(reason: .invalidURL(let url)): completion(.cancelled(reason: .invalidURL(url))) + + case .cancelled(reason: .apiError(let error)): + completion(.cancelled(reason: .apiError(error: error))) } } ) From 3f1fd8bf7e1d802027844878802da32f44647a87 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Tue, 13 Jul 2021 15:09:54 +1000 Subject: [PATCH 06/81] Remove V2 cart options for V3 purposes --- Example/Example/Purchase/CartViewController.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Example/Example/Purchase/CartViewController.swift b/Example/Example/Purchase/CartViewController.swift index a24b96c9..7f4fb7e7 100644 --- a/Example/Example/Purchase/CartViewController.swift +++ b/Example/Example/Purchase/CartViewController.swift @@ -97,8 +97,9 @@ final class CartViewController: UIViewController, UITableViewDataSource { return cart.products.count case .total: return 1 + // Disabled for V3 purposes case .options: - return 1 + return 0 } } From 5981219a936ae0ff418d2c05cd5acaca2df6a5b5 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Tue, 13 Jul 2021 15:37:59 +1000 Subject: [PATCH 07/81] Add AfterPay.presentCheckoutV3Modally method --- Sources/Afterpay/AfterpayV3.swift | 152 ++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 Sources/Afterpay/AfterpayV3.swift diff --git a/Sources/Afterpay/AfterpayV3.swift b/Sources/Afterpay/AfterpayV3.swift new file mode 100644 index 00000000..f318dc9b --- /dev/null +++ b/Sources/Afterpay/AfterpayV3.swift @@ -0,0 +1,152 @@ +// +// Afterpay.swift +// Afterpay +// +// Created by Chris Kolbu on 13/7/21. +// Copyright © 2020 Afterpay. All rights reserved. +// + +import Foundation +import UIKit + +// MARK: - Checkout + +public typealias URLRequestHandler = (URLRequest, @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask + +/// Present Afterpay Checkout modally over the specified view controller. This method +/// - Parameters: +/// - viewController: The viewController on which `UIViewController.present` will be called. +/// The Afterpay Checkout View Controller will be presented modally over this view controller +/// or it's closest parent that is able to handle the presentation. +/// - consumer: The personal details of the customer. +/// - total: The order total, represented as an unrounded `Decimal`. +/// - items: An optional array of items that will be added to the checkout. +/// These are not used as the basis of the order `total`. +/// - animated: Pass `true` to animate the presentation; otherwise, pass false. +/// - configuration: A collection of options and values required to interact with the Afterpay API. +/// - requestHandler: A function that takes a `URLRequest` and a closure to handle the result, +/// and returns a `URLSessionDataTask`. Defaults to `URLSession.shared.dataTask`. +/// - completion: The result of the user's completion (a success or cancellation). +public func presentCheckoutV3Modally( + over viewController: UIViewController, + consumer: CheckoutV3Consumer, + total: Decimal, + items: [CheckoutV3Item] = [], + animated: Bool = true, + configuration: CheckoutV3Configuration? = getV3Configuration(), + requestHandler: @escaping URLRequestHandler = URLSession.shared.dataTask, + completion: @escaping (_ result: CheckoutResult) -> Void +) { + guard let configuration = configuration else { + return assertionFailure( + "For checkout to function you must set `configuration` via either " + + "`Afterpay.presentCheckoutV3Modally` or `Afterpay.setV3Configuration`" + ) + } + + var viewControllerToPresent: UIViewController = CheckoutV3ViewController( + checkout: CheckoutV3.Request(consumer: consumer, amount: total, configuration: configuration), + configuration: configuration, + requestHandler: requestHandler, + completion: completion + ) + + viewControllerToPresent = UINavigationController(rootViewController: viewControllerToPresent) + viewController.present(viewControllerToPresent, animated: animated, completion: nil) +} + +private var checkoutV3Configuration: CheckoutV3Configuration? + +public func setV3Configuration(_ configuration: CheckoutV3Configuration) { + checkoutV3Configuration = configuration +} + +public func getV3Configuration() -> CheckoutV3Configuration? { + checkoutV3Configuration +} + +public struct CheckoutV3Configuration { + let shopDirectoryId: String + let shopDirectoryMerchantId: String + let merchantPublicKey: String + let region: Region + let environment: Environment + + public init( + shopDirectoryId: String, + shopDirectoryMerchantId: String, + merchantPublicKey: String, + region: Region, + environment: Environment + ) { + self.shopDirectoryId = shopDirectoryId + self.shopDirectoryMerchantId = shopDirectoryMerchantId + self.merchantPublicKey = merchantPublicKey + self.region = region + self.environment = environment + } + + // MARK: - Computed properties + + var v3CheckoutUrl: URL { + switch (region, environment) { + case (.US, .sandbox): + return URL(string: "https://api-plus.us-sandbox.afterpay.com/v3/button")! + case (.US, .production): + return URL(string: "https://api-plus.us.afterpay.com/v3/button")! + } + } + + var v3CheckoutConfirmationUrl: URL { + switch (region, environment) { + case (.US, .sandbox): + return URL(string: "https://api-plus.us-sandbox.afterpay.com/v3/button/confirm")! + case (.US, .production): + return URL(string: "https://api-plus.us.afterpay.com/v3/button/confirm")! + } + } + + // MARK: - Inner type + + public enum Region { + case US + + var locale: Locale { + switch self { + case .US: return Locale(identifier: "en_US") + } + } + + var currencyCode: String { + switch self { + case .US: return "USD" + } + } + + private static let formatter = NumberFormatter() + + func formatted(currency: Decimal) -> String { + Self.formatter.numberStyle = .decimal + return Self.formatter.string(from: currency as NSDecimalNumber)! + } + } +} + +public protocol CheckoutV3Consumer { + var email: String { get } + var givenNames: String? { get } + var surname: String? { get } + var phoneNumber: String? { get } +} + +public protocol CheckoutV3Item { + var name: String { get } + var quantity: Int { get } + var price: Decimal { get } + var sku: String? { get } + var pageUrl: URL? { get } + var imageUrl: URL? { get } + /// An array of arrays to accommodate multiple categories that might apply to the item. + /// Each array contains comma separated strings with the left-most category being the top level category. + var categories: [[String]]? { get } +} From f4763bec2d470d920bd908d2e1c4d24629321d19 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Tue, 13 Jul 2021 15:38:36 +1000 Subject: [PATCH 08/81] Clean up example code --- .../Purchase/PurchaseFlowController.swift | 8 ++--- .../SingleUseCardResultViewController.swift | 32 +++++++++---------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/Example/Example/Purchase/PurchaseFlowController.swift b/Example/Example/Purchase/PurchaseFlowController.swift index 4c2f1e37..132fe9e5 100644 --- a/Example/Example/Purchase/PurchaseFlowController.swift +++ b/Example/Example/Purchase/PurchaseFlowController.swift @@ -42,10 +42,10 @@ final class PurchaseFlowController: UIViewController { Afterpay.setCheckoutV2Handler(checkoutHandler) - // This option object may also be passed directly into calls to `AfterPay.presentCheckoutV3Modally` - Afterpay.setV3Options(CheckoutV3Options( - shopDirectoryId: "", - shopDirectoryMerchantId: "", + // This configuration object may also be passed directly into calls to `AfterPay.presentCheckoutV3Modally` + Afterpay.setV3Configuration(.init( + shopDirectoryId: "cd6b7914412b407d80aaf81d855d1105", + shopDirectoryMerchantId: "822ce7ffc2fa41258904baad1d0fe07351e89375108949e8bd951d387ef0e932", merchantPublicKey: "", region: .US, environment: .sandbox diff --git a/Example/Example/Purchase/SingleUseCardResultViewController.swift b/Example/Example/Purchase/SingleUseCardResultViewController.swift index a6e8d3c5..55394052 100644 --- a/Example/Example/Purchase/SingleUseCardResultViewController.swift +++ b/Example/Example/Purchase/SingleUseCardResultViewController.swift @@ -45,25 +45,25 @@ final class SingleUseCardResultViewController: UIViewController { }() private lazy var explanatoryHeaderLabel: UILabel = { - let label = UILabel() - label.numberOfLines = 0 - label.font = .preferredFont(forTextStyle: .headline) - label.adjustsFontForContentSizeCategory = true - label.translatesAutoresizingMaskIntoConstraints = false - label.text = "Next steps" - return label + let label = UILabel() + label.numberOfLines = 0 + label.font = .preferredFont(forTextStyle: .headline) + label.adjustsFontForContentSizeCategory = true + label.translatesAutoresizingMaskIntoConstraints = false + label.text = "Next steps" + return label }() private lazy var explanatoryBodyLabel: UILabel = { - let label = UILabel() - label.numberOfLines = 0 - label.font = .preferredFont(forTextStyle: .body) - label.adjustsFontForContentSizeCategory = true - label.translatesAutoresizingMaskIntoConstraints = false - label.text = "After the user has completed their Afterpay checkout, " - + "you will have until the `virtual card expiry` to perform an authorisation on the card. " - + "This completes the purchase." - return label + let label = UILabel() + label.numberOfLines = 0 + label.font = .preferredFont(forTextStyle: .body) + label.adjustsFontForContentSizeCategory = true + label.translatesAutoresizingMaskIntoConstraints = false + label.text = "After the user has completed their Afterpay checkout, " + + "you will have until the `virtual card expiry` to perform an authorisation on the card. " + + "This completes the purchase." + return label }() // MARK: - Initializer From 6e0b5150c60ce876c3cfa054af8f1b6c9058b18f Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Tue, 13 Jul 2021 15:39:26 +1000 Subject: [PATCH 09/81] Add V3 models and view controller --- Sources/Afterpay/Checkout/CheckoutV3.swift | 127 +++++++ .../Checkout/CheckoutV3ViewController.swift | 358 ++++++++++++++++++ .../Afterpay/Checkout/ConfirmationV3.swift | 71 ++++ Sources/Afterpay/Model/CardDetails.swift | 16 + 4 files changed, 572 insertions(+) create mode 100644 Sources/Afterpay/Checkout/CheckoutV3.swift create mode 100644 Sources/Afterpay/Checkout/CheckoutV3ViewController.swift create mode 100644 Sources/Afterpay/Checkout/ConfirmationV3.swift create mode 100644 Sources/Afterpay/Model/CardDetails.swift diff --git a/Sources/Afterpay/Checkout/CheckoutV3.swift b/Sources/Afterpay/Checkout/CheckoutV3.swift new file mode 100644 index 00000000..85f65af8 --- /dev/null +++ b/Sources/Afterpay/Checkout/CheckoutV3.swift @@ -0,0 +1,127 @@ +// +// CheckoutV3.swift +// Afterpay +// +// Created by Chris Kolbu on 12/7/21. +// Copyright © 2021 Afterpay. All rights reserved. +// + +import Foundation + +// swiftlint:disable nesting +@frozen enum CheckoutV3 { + struct Request: Encodable { + let shopDirectoryId: String + let shopDirectoryMerchantId: String + let merchantPublicKey: String + + let amount: Amount + let items: [Item] + let consumer: Consumer + let merchant: Merchant + + init( + consumer: CheckoutV3Consumer, + amount: Decimal, + items: [CheckoutV3Item] = [], + configuration: CheckoutV3Configuration + ) { + self.shopDirectoryId = configuration.shopDirectoryId + self.shopDirectoryMerchantId = configuration.shopDirectoryMerchantId + self.merchantPublicKey = configuration.merchantPublicKey + + self.amount = Amount( + amount: configuration.region.formatted(currency: amount), + currency: configuration.region.currencyCode + ) + self.items = items.map { Item($0, configuration.region) } + + self.consumer = Consumer(consumer) + + self.merchant = Merchant( + redirectConfirmUrl: URL(string: "https://www.afterpay.com")!, + redirectCancelUrl: URL(string: "https://www.afterpay.com")! + ) + } + + // MARK: - Inner types + + struct Amount: Encodable { + let amount: String + let currency: String + } + + struct Item: Encodable { + let name: String + let quantity: Int + let price: Amount + let sku: String? + let pageUrl: URL? + let imageUrl: URL? + let categories: [[String]]? + + init(_ item: CheckoutV3Item, _ region: CheckoutV3Configuration.Region) { + self.name = item.name + self.quantity = item.quantity + self.price = Amount( + amount: region.formatted(currency: item.price), + currency: region.currencyCode + ) + self.sku = item.sku + self.pageUrl = item.pageUrl + self.imageUrl = item.imageUrl + self.categories = item.categories + } + } + + struct Merchant: Encodable { + let redirectConfirmUrl: URL + let redirectCancelUrl: URL + } + + struct Consumer: Encodable { + let email: String + let givenNames: String? + let surname: String? + let phoneNumber: String? + + init(_ consumer: CheckoutV3Consumer) { + self.email = consumer.email + self.givenNames = consumer.givenNames + self.surname = consumer.surname + self.phoneNumber = consumer.phoneNumber + } + } + } + + enum Response: Decodable { + case success(CheckoutResponse) + case error(CheckoutError) + + init(from decoder: Decoder) throws { + if let error = try? CheckoutError(from: decoder) { + self = .error(error) + return + } + self = .success(try CheckoutResponse(from: decoder)) + } + } + + struct CheckoutResponse: Decodable { + let token: String + let confirmMustBeCalledBefore: Date? + let redirectCheckoutUrl: URL + let singleUseCardToken: String + } + + struct CheckoutError: Decodable, LocalizedError { + let errorCode: String + let errorId: String + let message: String + let httpStatusCode: Int + + public var failureReason: String? { + message + } + } +} diff --git a/Sources/Afterpay/Checkout/CheckoutV3ViewController.swift b/Sources/Afterpay/Checkout/CheckoutV3ViewController.swift new file mode 100644 index 00000000..6e861412 --- /dev/null +++ b/Sources/Afterpay/Checkout/CheckoutV3ViewController.swift @@ -0,0 +1,358 @@ +// +// CheckoutV3ViewController.swift +// Afterpay +// +// Created by Chris Kolbu on 12/7/21. +// Copyright © 2021 Afterpay. All rights reserved. +// + +import UIKit +import WebKit + +// swiftlint:disable:next colon type_body_length +final class CheckoutV3ViewController: + UIViewController, + UIAdaptivePresentationControllerDelegate, + WKNavigationDelegate +{ // swiftlint:disable:this opening_brace + private let checkout: CheckoutV3.Request + private let configuration: CheckoutV3Configuration + private let requester: URLRequestHandler + private var currentTask: URLSessionDataTask? + private let completion: (_ result: CheckoutResult) -> Void + + private var token: Token? + private var singleUseCardToken: Token? + private var ppaConfirmToken: Token? + + private var webView: WKWebView { view as! WKWebView } + + // MARK: Initialization + + init( + checkout: CheckoutV3.Request, + configuration: CheckoutV3Configuration, + requestHandler: @escaping URLRequestHandler, + completion: @escaping (_ result: CheckoutResult) -> Void + ) { + self.checkout = checkout + self.configuration = configuration + self.requester = requestHandler + self.completion = completion + + super.init(nibName: nil, bundle: nil) + } + + override func loadView() { + let config = WKWebViewConfiguration() + config.applicationNameForUserAgent = WKWebViewConfiguration.appNameForUserAgent + + view = WKWebView(frame: .zero, configuration: config) + } + + // MARK: - View lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "Cancel", + style: .plain, + target: self, + action: #selector(presentCancelConfirmation) + ) + + if #available(iOS 13.0, *) { + overrideUserInterfaceStyle = .light + isModalInPresentation = true + } + + navigationController?.presentationController?.delegate = self + + webView.allowsLinkPreview = false + webView.navigationDelegate = self + webView.scrollView.bounces = false + webView.loadHTMLString(StaticContent.loadingHTML, baseURL: nil) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + guard + let host = URLComponents(url: configuration.v3CheckoutUrl, resolvingAgainstBaseURL: false)?.host, + CheckoutHost.validSet.contains(host) + else { + return dismiss(animated: true) { [completion, url = configuration.v3CheckoutUrl] in + completion(.cancelled(reason: .invalidURL(url))) + } + } + + performCheckoutRequest { redirectUrl in + self.webView.load(self.request(from: redirectUrl)) + } + } + + // MARK: UIAdaptivePresentationControllerDelegate + + func presentationControllerDidAttemptToDismiss( + _ presentationController: UIPresentationController + ) { + presentCancelConfirmation() + } + + // MARK: Actions + + override func accessibilityPerformEscape() -> Bool { + presentCancelConfirmation() + return true + } + + @objc private func presentCancelConfirmation() { + let actionSheet = Alerts.areYouSureYouWantToCancel { + self.currentTask?.cancel() + self.dismiss(animated: true) { self.completion(.cancelled(reason: .userInitiated)) } + } + + present(actionSheet, animated: true, completion: nil) + } + + // MARK: WKNavigationDelegate + + private enum Completion { + case success(token: String, ppaConfirmToken: String) + case cancelled + + init?(url: URL) { + let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems + let statusItem = queryItems?.first { $0.name == "status" } + let ppaConfirmToken = queryItems?.first { $0.name == "ppaConfirmToken" } + let orderTokenItem = queryItems?.first { $0.name == "orderToken" } + + switch (statusItem?.value, orderTokenItem?.value, ppaConfirmToken?.value) { + case ("SUCCESS", let token?, let ppaConfirmToken?): + self = .success(token: token, ppaConfirmToken: ppaConfirmToken) + case ("CANCELLED", _, _): + self = .cancelled + default: + return nil + } + } + } + + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + guard let url = navigationAction.request.url else { + return decisionHandler(.allow) + } + + let shouldOpenExternally = navigationAction.targetFrame == nil + + switch (shouldOpenExternally, Completion(url: url)) { + case (true, _): + decisionHandler(.cancel) + UIApplication.shared.open(url) + + case (false, .success(_, let ppaConfirmToken)): + decisionHandler(.cancel) + self.ppaConfirmToken = ppaConfirmToken + self.performConfirmationRequest() + + case (false, .cancelled): + decisionHandler(.cancel) + dismiss(animated: true) { self.completion(.cancelled(reason: .userInitiated)) } + + case (false, nil): + decisionHandler(.allow) + } + } + + func webView( + _ webView: WKWebView, + didFailProvisionalNavigation navigation: WKNavigation!, + withError error: Error + ) { + let alert = Alerts.failedToLoad( + retry: { [url = configuration.environment.checkoutBootstrapURL] in + webView.load(URLRequest(url: url)) + }, + cancel: { + self.dismiss(animated: true) { + self.completion(.cancelled(reason: .networkError(error))) + } + } + ) + + present(alert, animated: true, completion: nil) + } + + func webView( + _ webView: WKWebView, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + let handled = authenticationChallengeHandler(challenge, completionHandler) + + if handled == false { + completionHandler(.performDefaultHandling, nil) + } + } + + // MARK: Unavailable + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Requests + + private func performCheckoutRequest(_ completion: @escaping (URL) -> Void) { + let request = self.createCheckoutRequest() + self.currentTask = self.request(request, type: CheckoutV3.Response.self) { result in + switch result { + case .success(.success(let response)): + self.token = response.token + self.singleUseCardToken = response.singleUseCardToken + completion(response.redirectCheckoutUrl) + case .success(.error(let error)): + self.dismiss(animated: true) { self.completion(.cancelled(reason: .apiError(error))) } + + case .failure(let error): + self.dismiss(animated: true) { self.completion(.cancelled(reason: .networkError(error))) } + } + } + + self.currentTask?.resume() + } + + private func performConfirmationRequest() { + guard let request = self.createConfirmationRequest() else { + return + } + + self.currentTask = self.request(request, type: ConfirmationV3.Response.self) { result in + switch result { + case .success(let response): + self.dismiss(animated: true) { + self.completion(.success(value: .singleUseCard( + authToken: response.authToken, + cardValidUntil: response.cardValidUntil, + details: response.paymentDetails.virtualCard + ))) + } + + case .failure(let error): + self.dismiss(animated: true) { self.completion(.cancelled(reason: .networkError(error))) } + } + } + self.currentTask?.resume() + } + + private func createCheckoutRequest() -> URLRequest { + let data = try! JSONEncoder().encode(self.checkout) // swiftlint:disable:this force_try + + var request = self.request(from: configuration.v3CheckoutUrl) + request.httpMethod = "POST" + request.httpBody = data + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + return request + } + + private func createConfirmationRequest() -> URLRequest? { + guard + let token = self.token, + let singleUseCardToken = self.singleUseCardToken, + let ppaConfirmToken = self.ppaConfirmToken + else { + return nil + } + + let confirmation = ConfirmationV3.Request( + token: token, + singleUseCardToken: + singleUseCardToken, + ppaConfirmToken: ppaConfirmToken + ) + + guard let data = try? JSONEncoder().encode(confirmation) else { + return nil + } + + var request = self.request(from: configuration.v3CheckoutConfirmationUrl) + request.httpMethod = "POST" + request.httpBody = data + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + return request + } + + // MARK: - Request convenience methods + + private static let decoder: JSONDecoder = { + let decoder = JSONDecoder() + let formatter = ISO8601DateFormatter() + + formatter.timeZone = TimeZone(abbreviation: "GMT") + formatter.formatOptions = [ + .withInternetDateTime, + .withDashSeparatorInDate, + .withColonSeparatorInTime, + .withTimeZone, + .withFractionalSeconds, + ] + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let string = try container.decode(String.self) + guard + string.isEmpty == false, + let date = formatter.date(from: string) + else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Could not create Date from `\(string)`" + ) + } + return date + } + return decoder + }() + + private func request(from url: URL) -> URLRequest { + var request = URLRequest(url: url) + request.setValue(Version.sdkVersion, forHTTPHeaderField: "X-Afterpay-SDK") + return request + } + + private func request( + _ request: URLRequest, + type: ReturnType.Type, + completion: @escaping (Result) -> Void + ) -> URLSessionDataTask { + let completeOnMainThread: (Result) -> Void = { result in + DispatchQueue.main.async { + completion(result) + } + } + return self.requester(request) { data, urlResponse, error in + if error == nil, let data = data { + do { + let parsed = try Self.decoder.decode(ReturnType.self, from: data) + completeOnMainThread(.success(parsed)) + } catch { + completeOnMainThread(.failure(error)) + } + } else if let error = error { + completeOnMainThread(.failure(error)) + } else { + completeOnMainThread(.failure(NetworkError.unknown(urlResponse))) + } + } + } + + public enum NetworkError: Error { + case unknown(URLResponse?) + } +} diff --git a/Sources/Afterpay/Checkout/ConfirmationV3.swift b/Sources/Afterpay/Checkout/ConfirmationV3.swift new file mode 100644 index 00000000..56f89be5 --- /dev/null +++ b/Sources/Afterpay/Checkout/ConfirmationV3.swift @@ -0,0 +1,71 @@ +// +// ConfirmationV3.swift +// Afterpay +// +// Created by Chris Kolbu on 12/7/21. +// Copyright © 2021 Afterpay. All rights reserved. +// + +import Foundation + +// swiftlint:disable nesting +@frozen enum ConfirmationV3 { + struct Request: Encodable { + let token: String + let singleUseCardToken: String + let ppaConfirmToken: String + } + + struct Response: Decodable { + let paymentDetails: PaymentDetails + let cardValidUntil: Date? + let authToken: String + + struct PaymentDetails: Decodable { + let virtualCard: VirtualCard + } + + struct VirtualCard: Decodable, CardDetails { + let cardNumber: String + let cvc: String + let expiryMonth: Int + let expiryYear: Int + + // swiftlint:disable:next nesting + private enum CodingKeys: String, CodingKey { + case cardNumber, cvc, expiry + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let expiryString = try container.decode(String.self, forKey: .expiry).split(separator: "-") + + guard expiryString.count == 2 else { + throw DecodingError.dataCorruptedError( + forKey: .expiry, + in: container, + debugDescription: "Expiry string wrong format Expected `-` as separator" + ) + } + + guard + let yearString = expiryString.first, + let year = Int(yearString), + let monthString = expiryString.last, + let month = Int(monthString) + else { + throw DecodingError.dataCorruptedError( + forKey: .expiry, + in: container, + debugDescription: "Expiry string wrong format Expected two integral values representing year and month" + ) + } + + self.expiryYear = year + self.expiryMonth = month + self.cardNumber = try container.decode(String.self, forKey: .cardNumber) + self.cvc = try container.decode(String.self, forKey: .cvc) + } + } + } +} diff --git a/Sources/Afterpay/Model/CardDetails.swift b/Sources/Afterpay/Model/CardDetails.swift new file mode 100644 index 00000000..33ba57fb --- /dev/null +++ b/Sources/Afterpay/Model/CardDetails.swift @@ -0,0 +1,16 @@ +// +// CardDetails.swift +// Afterpay +// +// Created by Chris Kolbu on 12/7/21. +// Copyright © 2021 Afterpay. All rights reserved. +// + +import Foundation + +public protocol CardDetails { + var cardNumber: String { get } + var cvc: String { get } + var expiryMonth: Int { get} + var expiryYear: Int { get } +} From 5620f02d44a9345cce0258d5a453d4d8d8135240 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Tue, 13 Jul 2021 15:39:41 +1000 Subject: [PATCH 10/81] Update project file --- Afterpay.xcodeproj/project.pbxproj | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Afterpay.xcodeproj/project.pbxproj b/Afterpay.xcodeproj/project.pbxproj index 024607e4..e4f0f967 100644 --- a/Afterpay.xcodeproj/project.pbxproj +++ b/Afterpay.xcodeproj/project.pbxproj @@ -58,6 +58,11 @@ 66EE378724D39FC50029BF42 /* BadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66EE378624D39FC50029BF42 /* BadgeView.swift */; }; 66EE9BD724DCEC3E00A81C19 /* LinkTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66EE9BD624DCEC3D00A81C19 /* LinkTextView.swift */; }; 66F9767C2499A11A001D38FA /* Afterpay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66F9767B2499A11A001D38FA /* Afterpay.swift */; }; + 838942B7269B8FD50036D1C7 /* CheckoutV3ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 838942B6269B8FD50036D1C7 /* CheckoutV3ViewController.swift */; }; + 838942BB269BAC9B0036D1C7 /* CardDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 838942BA269BAC9B0036D1C7 /* CardDetails.swift */; }; + 838942BD269BB39A0036D1C7 /* CheckoutV3.swift in Sources */ = {isa = PBXBuildFile; fileRef = 838942BC269BB39A0036D1C7 /* CheckoutV3.swift */; }; + 83F7DEDF269BF43600F9FB75 /* ConfirmationV3.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEDE269BF43600F9FB75 /* ConfirmationV3.swift */; }; + 83F7DEE1269CDCF900F9FB75 /* AfterpayV3.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEE0269CDCF900F9FB75 /* AfterpayV3.swift */; }; 946388FE24DD077F00A1227A /* InfoWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 946388FD24DD077F00A1227A /* InfoWebViewController.swift */; }; /* End PBXBuildFile section */ @@ -137,6 +142,11 @@ 66EE378624D39FC50029BF42 /* BadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeView.swift; sourceTree = ""; }; 66EE9BD624DCEC3D00A81C19 /* LinkTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkTextView.swift; sourceTree = ""; }; 66F9767B2499A11A001D38FA /* Afterpay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Afterpay.swift; sourceTree = ""; }; + 838942B6269B8FD50036D1C7 /* CheckoutV3ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckoutV3ViewController.swift; sourceTree = ""; }; + 838942BA269BAC9B0036D1C7 /* CardDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardDetails.swift; sourceTree = ""; }; + 838942BC269BB39A0036D1C7 /* CheckoutV3.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckoutV3.swift; sourceTree = ""; }; + 83F7DEDE269BF43600F9FB75 /* ConfirmationV3.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmationV3.swift; sourceTree = ""; }; + 83F7DEE0269CDCF900F9FB75 /* AfterpayV3.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AfterpayV3.swift; sourceTree = ""; }; 946388FD24DD077F00A1227A /* InfoWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoWebViewController.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -284,6 +294,7 @@ isa = PBXGroup; children = ( 66F9767B2499A11A001D38FA /* Afterpay.swift */, + 83F7DEE0269CDCF900F9FB75 /* AfterpayV3.swift */, 551BEDEA25F983E200FDF9EE /* Features.swift */, 946388FB24DD05D400A1227A /* Checkout */, 157E88CF25CBCA22007E54C4 /* Helpers */, @@ -301,6 +312,7 @@ 6691776D24E0CB7C00D0A4B2 /* Model */ = { isa = PBXGroup; children = ( + 838942BA269BAC9B0036D1C7 /* CardDetails.swift */, 662A3AEC24A999A500EFD826 /* CheckoutResult.swift */, 6689536B24C96CB5005090B4 /* Configuration.swift */, 15F7DDB625393BD30011EC25 /* CurrencyFormatter.swift */, @@ -352,7 +364,10 @@ 66996F662580A5BE0061C365 /* CheckoutV2Completion.swift */, 66169311257A06B200DF6CF4 /* CheckoutV2Message.swift */, 66B54587256B3FD9002B3DD5 /* CheckoutV2ViewController.swift */, + 838942BC269BB39A0036D1C7 /* CheckoutV3.swift */, + 838942B6269B8FD50036D1C7 /* CheckoutV3ViewController.swift */, 667AD3532497121100BF94E5 /* CheckoutWebViewController.swift */, + 83F7DEDE269BF43600F9FB75 /* ConfirmationV3.swift */, ); path = Checkout; sourceTree = ""; @@ -541,6 +556,7 @@ 66639B5424D2619200C68558 /* SVGView.swift in Sources */, 66E255AE24E3C14600C81F20 /* Strings.swift in Sources */, 6615F99B24D14620005036F1 /* SVG.swift in Sources */, + 838942B7269B8FD50036D1C7 /* CheckoutV3ViewController.swift in Sources */, 66483F3B24D7A164000BE6B5 /* PriceBreakdownView.swift in Sources */, 6602EF0F25358A8000A0468C /* ColorScheme.swift in Sources */, 66B5458C256B65B7002B3DD5 /* CheckoutHost.swift in Sources */, @@ -550,8 +566,10 @@ 6689536C24C96CB5005090B4 /* Configuration.swift in Sources */, 15F7DDB725393BD30011EC25 /* CurrencyFormatter.swift in Sources */, 66DAAC8B24E0CF0100127460 /* PriceBreakdown.swift in Sources */, + 83F7DEDF269BF43600F9FB75 /* ConfirmationV3.swift in Sources */, 551BEDFC25F9C56600FDF9EE /* WidgetEvent.swift in Sources */, 661CFDB62570E7F000D8A1E8 /* PaymentButton.swift in Sources */, + 83F7DEE1269CDCF900F9FB75 /* AfterpayV3.swift in Sources */, 557511C326489F090040CC51 /* SVG+Source.swift in Sources */, 662A3AED24A999A500EFD826 /* CheckoutResult.swift in Sources */, 551BEDF825F9B95C00FDF9EE /* WidgetView.swift in Sources */, @@ -574,8 +592,10 @@ 661D4323257DF1CB00ACCDE1 /* ShippingOption.swift in Sources */, 946388FE24DD077F00A1227A /* InfoWebViewController.swift in Sources */, 66EE9BD724DCEC3E00A81C19 /* LinkTextView.swift in Sources */, + 838942BD269BB39A0036D1C7 /* CheckoutV3.swift in Sources */, 661D431F257DC86C00ACCDE1 /* ShippingAddress.swift in Sources */, 557511BF2644CAA50040CC51 /* WKWebView+Cache.swift in Sources */, + 838942BB269BAC9B0036D1C7 /* CardDetails.swift in Sources */, 6605666324E5199500DA588E /* Locales.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; From 265a93c3d60c127b2e2138ac498e0c659726a219 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Wed, 14 Jul 2021 06:29:00 +1000 Subject: [PATCH 11/81] Remove CancellationReason.apiError --- .../Purchase/PurchaseLogicController.swift | 2 +- Sources/Afterpay/Model/CheckoutResult.swift | 1 - Sources/Afterpay/Wrappers/ObjcWrapper.swift | 16 ---------------- 3 files changed, 1 insertion(+), 18 deletions(-) diff --git a/Example/Example/Purchase/PurchaseLogicController.swift b/Example/Example/Purchase/PurchaseLogicController.swift index ff72d60f..7644f0fc 100644 --- a/Example/Example/Purchase/PurchaseLogicController.swift +++ b/Example/Example/Purchase/PurchaseLogicController.swift @@ -178,7 +178,7 @@ final class PurchaseLogicController { let errorMessageToShow: String? switch reason { - case .networkError(let error), .apiError(let error): + case .networkError(let error): errorMessageToShow = error.localizedDescription case .userInitiated: errorMessageToShow = nil diff --git a/Sources/Afterpay/Model/CheckoutResult.swift b/Sources/Afterpay/Model/CheckoutResult.swift index f49775c8..f83994a0 100644 --- a/Sources/Afterpay/Model/CheckoutResult.swift +++ b/Sources/Afterpay/Model/CheckoutResult.swift @@ -19,7 +19,6 @@ import Foundation public enum CancellationReason { case userInitiated - case apiError(Error) case networkError(Error) case invalidURL(URL) } diff --git a/Sources/Afterpay/Wrappers/ObjcWrapper.swift b/Sources/Afterpay/Wrappers/ObjcWrapper.swift index 9dd4311a..1bc115e1 100644 --- a/Sources/Afterpay/Wrappers/ObjcWrapper.swift +++ b/Sources/Afterpay/Wrappers/ObjcWrapper.swift @@ -61,10 +61,6 @@ public final class ObjcWrapper: NSObject { CancellationReasonNetworkError(error) } - static func apiError(error: Error) -> CancellationReasonApiError { - CancellationReasonApiError(error) - } - static func invalidURL(_ url: URL) -> CancellationReasonInvalidURL { CancellationReasonInvalidURL(url) } @@ -85,15 +81,6 @@ public final class ObjcWrapper: NSObject { } } - @objc(APCancellationReasonApiError) - public class CancellationReasonApiError: CancellationReason { - @objc public let error: Error - - init(_ error: Error) { - self.error = error - } - } - @objc(APCancellationReasonInvalidURL) public class CancellationReasonInvalidURL: CancellationReason { @objc public let url: URL @@ -131,9 +118,6 @@ public final class ObjcWrapper: NSObject { case .cancelled(reason: .invalidURL(let url)): completion(.cancelled(reason: .invalidURL(url))) - - case .cancelled(reason: .apiError(let error)): - completion(.cancelled(reason: .apiError(error: error))) } } ) From f694936206af8bb825ab09b3bbd6d28adb825a45 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Wed, 14 Jul 2021 06:29:45 +1000 Subject: [PATCH 12/81] Extract request convenience methods from Checkout VC --- Sources/Afterpay/Checkout/ApiV3.swift | 98 +++++++++++++++++++ .../Checkout/CheckoutV3ViewController.swift | 89 +++-------------- 2 files changed, 109 insertions(+), 78 deletions(-) create mode 100644 Sources/Afterpay/Checkout/ApiV3.swift diff --git a/Sources/Afterpay/Checkout/ApiV3.swift b/Sources/Afterpay/Checkout/ApiV3.swift new file mode 100644 index 00000000..3847634a --- /dev/null +++ b/Sources/Afterpay/Checkout/ApiV3.swift @@ -0,0 +1,98 @@ +// +// ApiV3.swift +// Afterpay +// +// Created by Chris Kolbu on 14/7/21. +// Copyright © 2021 Afterpay. All rights reserved. +// + +import Foundation + +public typealias URLRequestHandler = (URLRequest, @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask + +enum ApiV3 { + + private static let decoder: JSONDecoder = { + let decoder = JSONDecoder() + let formatter = ISO8601DateFormatter() + + formatter.timeZone = TimeZone(abbreviation: "GMT") + formatter.formatOptions = [ + .withInternetDateTime, + .withDashSeparatorInDate, + .withColonSeparatorInTime, + .withTimeZone, + .withFractionalSeconds, + ] + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let string = try container.decode(String.self) + guard + string.isEmpty == false, + let date = formatter.date(from: string) + else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Could not create Date from `\(string)`" + ) + } + return date + } + return decoder + }() + + static func request(from url: URL) -> URLRequest { + var request = URLRequest(url: url) + request.setValue(Version.sdkVersion, forHTTPHeaderField: "X-Afterpay-SDK") + return request + } + + static func request( + _ requestHandler: URLRequestHandler, + _ request: URLRequest, + type: ReturnType.Type, + completion: @escaping (Result) -> Void + ) -> URLSessionDataTask { + let completeOnMainThread: (Result) -> Void = { result in + DispatchQueue.main.async { + completion(result) + } + } + return requestHandler(request) { data, urlResponse, error in + if error == nil, let data = data { + if let error = try? Self.decoder.decode(ApiError.self, from: data) { + completeOnMainThread(.failure(error)) + return + } + do { + let parsed = try Self.decoder.decode(ReturnType.self, from: data) + completeOnMainThread(.success(parsed)) + } catch { + completeOnMainThread(.failure(error)) + } + } else if let error = error { + completeOnMainThread(.failure(error)) + } else { + completeOnMainThread(.failure(NetworkError.unknown(urlResponse))) + } + } + } + + public enum NetworkError: Error { + case unknown(URLResponse?) + } + +} + +public struct ApiError: Decodable, LocalizedError { + + let errorCode: String + let errorId: String + let message: String + let httpStatusCode: Int + + public var failureReason: String? { + message + } + +} diff --git a/Sources/Afterpay/Checkout/CheckoutV3ViewController.swift b/Sources/Afterpay/Checkout/CheckoutV3ViewController.swift index 6e861412..507673fb 100644 --- a/Sources/Afterpay/Checkout/CheckoutV3ViewController.swift +++ b/Sources/Afterpay/Checkout/CheckoutV3ViewController.swift @@ -9,15 +9,16 @@ import UIKit import WebKit -// swiftlint:disable:next colon type_body_length +// swiftlint:disable:next colon final class CheckoutV3ViewController: UIViewController, UIAdaptivePresentationControllerDelegate, WKNavigationDelegate { // swiftlint:disable:this opening_brace + private let checkout: CheckoutV3.Request private let configuration: CheckoutV3Configuration - private let requester: URLRequestHandler + private let requestHandler: URLRequestHandler private var currentTask: URLSessionDataTask? private let completion: (_ result: CheckoutResult) -> Void @@ -37,10 +38,11 @@ final class CheckoutV3ViewController: ) { self.checkout = checkout self.configuration = configuration - self.requester = requestHandler + self.requestHandler = requestHandler self.completion = completion super.init(nibName: nil, bundle: nil) + self.title = "Afterpay" } override func loadView() { @@ -87,7 +89,7 @@ final class CheckoutV3ViewController: } performCheckoutRequest { redirectUrl in - self.webView.load(self.request(from: redirectUrl)) + self.webView.load(ApiV3.request(from: redirectUrl)) } } @@ -210,15 +212,12 @@ final class CheckoutV3ViewController: private func performCheckoutRequest(_ completion: @escaping (URL) -> Void) { let request = self.createCheckoutRequest() - self.currentTask = self.request(request, type: CheckoutV3.Response.self) { result in + self.currentTask = ApiV3.request(self.requestHandler, request, type: CheckoutV3.Response.self) { result in switch result { - case .success(.success(let response)): + case .success(let response): self.token = response.token self.singleUseCardToken = response.singleUseCardToken completion(response.redirectCheckoutUrl) - case .success(.error(let error)): - self.dismiss(animated: true) { self.completion(.cancelled(reason: .apiError(error))) } - case .failure(let error): self.dismiss(animated: true) { self.completion(.cancelled(reason: .networkError(error))) } } @@ -232,7 +231,7 @@ final class CheckoutV3ViewController: return } - self.currentTask = self.request(request, type: ConfirmationV3.Response.self) { result in + self.currentTask = ApiV3.request(self.requestHandler, request, type: ConfirmationV3.Response.self) { result in switch result { case .success(let response): self.dismiss(animated: true) { @@ -253,7 +252,7 @@ final class CheckoutV3ViewController: private func createCheckoutRequest() -> URLRequest { let data = try! JSONEncoder().encode(self.checkout) // swiftlint:disable:this force_try - var request = self.request(from: configuration.v3CheckoutUrl) + var request = ApiV3.request(from: configuration.v3CheckoutUrl) request.httpMethod = "POST" request.httpBody = data request.setValue("application/json", forHTTPHeaderField: "Accept") @@ -281,7 +280,7 @@ final class CheckoutV3ViewController: return nil } - var request = self.request(from: configuration.v3CheckoutConfirmationUrl) + var request = ApiV3.request(from: configuration.v3CheckoutConfirmationUrl) request.httpMethod = "POST" request.httpBody = data request.setValue("application/json", forHTTPHeaderField: "Accept") @@ -289,70 +288,4 @@ final class CheckoutV3ViewController: return request } - // MARK: - Request convenience methods - - private static let decoder: JSONDecoder = { - let decoder = JSONDecoder() - let formatter = ISO8601DateFormatter() - - formatter.timeZone = TimeZone(abbreviation: "GMT") - formatter.formatOptions = [ - .withInternetDateTime, - .withDashSeparatorInDate, - .withColonSeparatorInTime, - .withTimeZone, - .withFractionalSeconds, - ] - decoder.dateDecodingStrategy = .custom { decoder in - let container = try decoder.singleValueContainer() - let string = try container.decode(String.self) - guard - string.isEmpty == false, - let date = formatter.date(from: string) - else { - throw DecodingError.dataCorruptedError( - in: container, - debugDescription: "Could not create Date from `\(string)`" - ) - } - return date - } - return decoder - }() - - private func request(from url: URL) -> URLRequest { - var request = URLRequest(url: url) - request.setValue(Version.sdkVersion, forHTTPHeaderField: "X-Afterpay-SDK") - return request - } - - private func request( - _ request: URLRequest, - type: ReturnType.Type, - completion: @escaping (Result) -> Void - ) -> URLSessionDataTask { - let completeOnMainThread: (Result) -> Void = { result in - DispatchQueue.main.async { - completion(result) - } - } - return self.requester(request) { data, urlResponse, error in - if error == nil, let data = data { - do { - let parsed = try Self.decoder.decode(ReturnType.self, from: data) - completeOnMainThread(.success(parsed)) - } catch { - completeOnMainThread(.failure(error)) - } - } else if let error = error { - completeOnMainThread(.failure(error)) - } else { - completeOnMainThread(.failure(NetworkError.unknown(urlResponse))) - } - } - } - - public enum NetworkError: Error { - case unknown(URLResponse?) - } } From be3ef1577f25a7b3a114b502c78a13c2857a46c4 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Wed, 14 Jul 2021 06:30:17 +1000 Subject: [PATCH 13/81] Extract Amount model from CheckoutV3.Request --- Afterpay.xcodeproj/project.pbxproj | 8 ++++++ Sources/Afterpay/Checkout/CheckoutV3.swift | 33 ++-------------------- Sources/Afterpay/Model/Amount.swift | 14 +++++++++ 3 files changed, 25 insertions(+), 30 deletions(-) create mode 100644 Sources/Afterpay/Model/Amount.swift diff --git a/Afterpay.xcodeproj/project.pbxproj b/Afterpay.xcodeproj/project.pbxproj index e4f0f967..111ab217 100644 --- a/Afterpay.xcodeproj/project.pbxproj +++ b/Afterpay.xcodeproj/project.pbxproj @@ -63,6 +63,8 @@ 838942BD269BB39A0036D1C7 /* CheckoutV3.swift in Sources */ = {isa = PBXBuildFile; fileRef = 838942BC269BB39A0036D1C7 /* CheckoutV3.swift */; }; 83F7DEDF269BF43600F9FB75 /* ConfirmationV3.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEDE269BF43600F9FB75 /* ConfirmationV3.swift */; }; 83F7DEE1269CDCF900F9FB75 /* AfterpayV3.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEE0269CDCF900F9FB75 /* AfterpayV3.swift */; }; + 83F7DEE7269E2B3600F9FB75 /* ApiV3.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEE6269E2B3600F9FB75 /* ApiV3.swift */; }; + 83F7DEE9269E2EB300F9FB75 /* Amount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEE8269E2EB300F9FB75 /* Amount.swift */; }; 946388FE24DD077F00A1227A /* InfoWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 946388FD24DD077F00A1227A /* InfoWebViewController.swift */; }; /* End PBXBuildFile section */ @@ -147,6 +149,8 @@ 838942BC269BB39A0036D1C7 /* CheckoutV3.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckoutV3.swift; sourceTree = ""; }; 83F7DEDE269BF43600F9FB75 /* ConfirmationV3.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmationV3.swift; sourceTree = ""; }; 83F7DEE0269CDCF900F9FB75 /* AfterpayV3.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AfterpayV3.swift; sourceTree = ""; }; + 83F7DEE6269E2B3600F9FB75 /* ApiV3.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiV3.swift; sourceTree = ""; }; + 83F7DEE8269E2EB300F9FB75 /* Amount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Amount.swift; sourceTree = ""; }; 946388FD24DD077F00A1227A /* InfoWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoWebViewController.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -323,6 +327,7 @@ 661D431E257DC86C00ACCDE1 /* ShippingAddress.swift */, 661D4322257DF1CB00ACCDE1 /* ShippingOption.swift */, 157C65AE25D23E8F00115149 /* Version.swift */, + 83F7DEE8269E2EB300F9FB75 /* Amount.swift */, ); path = Model; sourceTree = ""; @@ -368,6 +373,7 @@ 838942B6269B8FD50036D1C7 /* CheckoutV3ViewController.swift */, 667AD3532497121100BF94E5 /* CheckoutWebViewController.swift */, 83F7DEDE269BF43600F9FB75 /* ConfirmationV3.swift */, + 83F7DEE6269E2B3600F9FB75 /* ApiV3.swift */, ); path = Checkout; sourceTree = ""; @@ -568,6 +574,7 @@ 66DAAC8B24E0CF0100127460 /* PriceBreakdown.swift in Sources */, 83F7DEDF269BF43600F9FB75 /* ConfirmationV3.swift in Sources */, 551BEDFC25F9C56600FDF9EE /* WidgetEvent.swift in Sources */, + 83F7DEE9269E2EB300F9FB75 /* Amount.swift in Sources */, 661CFDB62570E7F000D8A1E8 /* PaymentButton.swift in Sources */, 83F7DEE1269CDCF900F9FB75 /* AfterpayV3.swift in Sources */, 557511C326489F090040CC51 /* SVG+Source.swift in Sources */, @@ -591,6 +598,7 @@ 55A2D307261BB36C00D8E23A /* Money.swift in Sources */, 661D4323257DF1CB00ACCDE1 /* ShippingOption.swift in Sources */, 946388FE24DD077F00A1227A /* InfoWebViewController.swift in Sources */, + 83F7DEE7269E2B3600F9FB75 /* ApiV3.swift in Sources */, 66EE9BD724DCEC3E00A81C19 /* LinkTextView.swift in Sources */, 838942BD269BB39A0036D1C7 /* CheckoutV3.swift in Sources */, 661D431F257DC86C00ACCDE1 /* ShippingAddress.swift in Sources */, diff --git a/Sources/Afterpay/Checkout/CheckoutV3.swift b/Sources/Afterpay/Checkout/CheckoutV3.swift index 85f65af8..aa756b10 100644 --- a/Sources/Afterpay/Checkout/CheckoutV3.swift +++ b/Sources/Afterpay/Checkout/CheckoutV3.swift @@ -9,7 +9,8 @@ import Foundation // swiftlint:disable nesting -@frozen enum CheckoutV3 { +enum CheckoutV3 { + struct Request: Encodable { let shopDirectoryId: String let shopDirectoryMerchantId: String @@ -46,11 +47,6 @@ import Foundation // MARK: - Inner types - struct Amount: Encodable { - let amount: String - let currency: String - } - struct Item: Encodable { let name: String let quantity: Int @@ -94,34 +90,11 @@ import Foundation } } - enum Response: Decodable { - case success(CheckoutResponse) - case error(CheckoutError) - - init(from decoder: Decoder) throws { - if let error = try? CheckoutError(from: decoder) { - self = .error(error) - return - } - self = .success(try CheckoutResponse(from: decoder)) - } - } - - struct CheckoutResponse: Decodable { + struct Response: Decodable { let token: String let confirmMustBeCalledBefore: Date? let redirectCheckoutUrl: URL let singleUseCardToken: String } - struct CheckoutError: Decodable, LocalizedError { - let errorCode: String - let errorId: String - let message: String - let httpStatusCode: Int - - public var failureReason: String? { - message - } - } } diff --git a/Sources/Afterpay/Model/Amount.swift b/Sources/Afterpay/Model/Amount.swift new file mode 100644 index 00000000..37ded9af --- /dev/null +++ b/Sources/Afterpay/Model/Amount.swift @@ -0,0 +1,14 @@ +// +// Amount.swift +// Afterpay +// +// Created by Chris Kolbu on 14/7/21. +// Copyright © 2021 Afterpay. All rights reserved. +// + +import Foundation + +struct Amount: Codable { + let amount: String + let currency: String +} From 892f3736a204fe32553367c74c984b174d5ab0db Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Wed, 14 Jul 2021 06:31:15 +1000 Subject: [PATCH 14/81] Add Codable support to Configuration model This is required now that the Afterpay SDK is interacting directly with the API rather than through a merchant API --- Sources/Afterpay/Model/Configuration.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Sources/Afterpay/Model/Configuration.swift b/Sources/Afterpay/Model/Configuration.swift index 786dada9..dc0e4ac4 100644 --- a/Sources/Afterpay/Model/Configuration.swift +++ b/Sources/Afterpay/Model/Configuration.swift @@ -118,4 +118,19 @@ public struct Configuration { self.environment = environment } + init(_ object: Object, configuration: CheckoutV3Configuration) throws { + try self.init( + minimumAmount: object.minimumAmount.amount, + maximumAmount: object.maximumAmount.amount, + currencyCode: object.minimumAmount.currency, + locale: configuration.region.locale, + environment: configuration.environment + ) + } + + struct Object: Decodable { + let minimumAmount: Amount + let maximumAmount: Amount + } + } From 73e8ed099d2963af17ea07712f0dbf88f0ed34d5 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Wed, 14 Jul 2021 06:34:52 +1000 Subject: [PATCH 15/81] Add fetchMerchantConfiguration function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, this object was retrieved via the merchant’s API. Now, we add an option to fetch it directly for V3 use --- .../Purchase/PurchaseFlowController.swift | 14 ++++- Sources/Afterpay/AfterpayV3.swift | 54 ++++++++++++++++++- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/Example/Example/Purchase/PurchaseFlowController.swift b/Example/Example/Purchase/PurchaseFlowController.swift index 132fe9e5..3ce4a55f 100644 --- a/Example/Example/Purchase/PurchaseFlowController.swift +++ b/Example/Example/Purchase/PurchaseFlowController.swift @@ -42,7 +42,8 @@ final class PurchaseFlowController: UIViewController { Afterpay.setCheckoutV2Handler(checkoutHandler) - // This configuration object may also be passed directly into calls to `AfterPay.presentCheckoutV3Modally` + // This configuration object may also be passed directly into calls to + // `Afterpay.presentCheckoutV3Modally` and `Afterpay.fetchMerchantConfiguration` Afterpay.setV3Configuration(.init( shopDirectoryId: "cd6b7914412b407d80aaf81d855d1105", shopDirectoryMerchantId: "822ce7ffc2fa41258904baad1d0fe07351e89375108949e8bd951d387ef0e932", @@ -68,6 +69,17 @@ final class PurchaseFlowController: UIViewController { logicController.commandHandler = { [unowned self] command in DispatchQueue.main.async { self.execute(command: command) } } + + Afterpay.fetchMerchantConfiguration { result in + switch result { + case .success(let configuration): + // Do something with the configuration object here + print(configuration) + case .failure(let error): + let alert = AlertFactory.alert(for: error.localizedDescription) + self.present(alert, animated: true) + } + } } // swiftlint:disable:next cyclomatic_complexity diff --git a/Sources/Afterpay/AfterpayV3.swift b/Sources/Afterpay/AfterpayV3.swift index f318dc9b..f553f2f3 100644 --- a/Sources/Afterpay/AfterpayV3.swift +++ b/Sources/Afterpay/AfterpayV3.swift @@ -11,7 +11,38 @@ import UIKit // MARK: - Checkout -public typealias URLRequestHandler = (URLRequest, @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask +/// Returns the merchant configuration object, containing minimum and maximum amounts +/// - Parameters: +/// - configuration: A collection of options and values required to interact with the Afterpay API. +/// - requestHandler: A function that takes a `URLRequest` and a closure to handle the result. +/// - completion: The result of the user's completion (a success or cancellation). +public func fetchMerchantConfiguration( + configuration: CheckoutV3Configuration? = getV3Configuration(), + requestHandler: @escaping URLRequestHandler = URLSession.shared.dataTask, + completion: @escaping (_ result: Result) -> Void +) { + guard let configuration = configuration else { + return assertionFailure( + "For fetchMerchantConfiguration to function you must set `configuration` via either " + + "`Afterpay.fetchMerchantConfiguration` or `Afterpay.setV3Configuration`" + ) + } + let request = ApiV3.request(from: configuration.v3ConfigurationUrl) + let task = ApiV3.request(requestHandler, request, type: Configuration.Object.self) { result in + switch result { + case .success(let object): + do { + let config = try Configuration(object, configuration: configuration) + completion(.success(config)) + } catch { + completion(.failure(error)) + } + case .failure(let error): + completion(.failure(error)) + } + } + task.resume() +} /// Present Afterpay Checkout modally over the specified view controller. This method /// - Parameters: @@ -24,7 +55,7 @@ public typealias URLRequestHandler = (URLRequest, @escaping (Data?, URLResponse? /// These are not used as the basis of the order `total`. /// - animated: Pass `true` to animate the presentation; otherwise, pass false. /// - configuration: A collection of options and values required to interact with the Afterpay API. -/// - requestHandler: A function that takes a `URLRequest` and a closure to handle the result, +/// - requestHandler: A function that takes a `URLRequest` and a closure to handle the result. /// and returns a `URLSessionDataTask`. Defaults to `URLSession.shared.dataTask`. /// - completion: The result of the user's completion (a success or cancellation). public func presentCheckoutV3Modally( @@ -106,6 +137,25 @@ public struct CheckoutV3Configuration { } } + var v3ConfigurationUrl: URL { + var url: URL + switch (region, environment) { + case (.US, .sandbox): + url = URL(string: "https://api-plus.us-sandbox.afterpay.com/v3/button/merchant/config")! + case (.US, .production): + url = URL(string: "https://api-plus.us.afterpay.com/v3/button/merchant/config")! + } + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + components?.queryItems = [ + URLQueryItem(name: "shopDirectoryId", value: shopDirectoryId), + URLQueryItem(name: "shopDirectoryMerchantId", value: shopDirectoryMerchantId), + ] + guard let url = components?.url else { + fatalError("Could not create valid URL for `\(Self.self).v3ConfigurationUrl`") + } + return url + } + // MARK: - Inner type public enum Region { From db0cbd8d8f4864500e0ea54211588ee1e47f8167 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Wed, 14 Jul 2021 06:35:07 +1000 Subject: [PATCH 16/81] Fix formatting --- Afterpay.xcodeproj/project.pbxproj | 2 +- Sources/Afterpay/Checkout/ConfirmationV3.swift | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Afterpay.xcodeproj/project.pbxproj b/Afterpay.xcodeproj/project.pbxproj index 111ab217..25837838 100644 --- a/Afterpay.xcodeproj/project.pbxproj +++ b/Afterpay.xcodeproj/project.pbxproj @@ -364,6 +364,7 @@ 946388FB24DD05D400A1227A /* Checkout */ = { isa = PBXGroup; children = ( + 83F7DEE6269E2B3600F9FB75 /* ApiV3.swift */, 66B5458B256B65B7002B3DD5 /* CheckoutHost.swift */, 1535ACB825DCBD0500727818 /* CheckoutV2.swift */, 66996F662580A5BE0061C365 /* CheckoutV2Completion.swift */, @@ -373,7 +374,6 @@ 838942B6269B8FD50036D1C7 /* CheckoutV3ViewController.swift */, 667AD3532497121100BF94E5 /* CheckoutWebViewController.swift */, 83F7DEDE269BF43600F9FB75 /* ConfirmationV3.swift */, - 83F7DEE6269E2B3600F9FB75 /* ApiV3.swift */, ); path = Checkout; sourceTree = ""; diff --git a/Sources/Afterpay/Checkout/ConfirmationV3.swift b/Sources/Afterpay/Checkout/ConfirmationV3.swift index 56f89be5..e500acae 100644 --- a/Sources/Afterpay/Checkout/ConfirmationV3.swift +++ b/Sources/Afterpay/Checkout/ConfirmationV3.swift @@ -9,7 +9,8 @@ import Foundation // swiftlint:disable nesting -@frozen enum ConfirmationV3 { +enum ConfirmationV3 { + struct Request: Encodable { let token: String let singleUseCardToken: String @@ -68,4 +69,5 @@ import Foundation } } } + } From 10b23cdbae4489e3087678af4c23b745c29d29d7 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Wed, 14 Jul 2021 06:36:29 +1000 Subject: [PATCH 17/81] Move ApiV3 up one level --- Afterpay.xcodeproj/project.pbxproj | 2 +- Sources/Afterpay/{Checkout => }/ApiV3.swift | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename Sources/Afterpay/{Checkout => }/ApiV3.swift (100%) diff --git a/Afterpay.xcodeproj/project.pbxproj b/Afterpay.xcodeproj/project.pbxproj index 25837838..baca8fec 100644 --- a/Afterpay.xcodeproj/project.pbxproj +++ b/Afterpay.xcodeproj/project.pbxproj @@ -299,6 +299,7 @@ children = ( 66F9767B2499A11A001D38FA /* Afterpay.swift */, 83F7DEE0269CDCF900F9FB75 /* AfterpayV3.swift */, + 83F7DEE6269E2B3600F9FB75 /* ApiV3.swift */, 551BEDEA25F983E200FDF9EE /* Features.swift */, 946388FB24DD05D400A1227A /* Checkout */, 157E88CF25CBCA22007E54C4 /* Helpers */, @@ -364,7 +365,6 @@ 946388FB24DD05D400A1227A /* Checkout */ = { isa = PBXGroup; children = ( - 83F7DEE6269E2B3600F9FB75 /* ApiV3.swift */, 66B5458B256B65B7002B3DD5 /* CheckoutHost.swift */, 1535ACB825DCBD0500727818 /* CheckoutV2.swift */, 66996F662580A5BE0061C365 /* CheckoutV2Completion.swift */, diff --git a/Sources/Afterpay/Checkout/ApiV3.swift b/Sources/Afterpay/ApiV3.swift similarity index 100% rename from Sources/Afterpay/Checkout/ApiV3.swift rename to Sources/Afterpay/ApiV3.swift From 82e4990aacd6ac3ed0bccdee1a368ba015f08e48 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Wed, 14 Jul 2021 06:49:17 +1000 Subject: [PATCH 18/81] Update README with v3 functionality --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.md b/README.md index 92843429..fc5f3162 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,26 @@ Checkout version 2 allows you to load the checkout token on demand via `didComme The configuration object *must* be set before calling checkout v2. +### Checkout v3 + +```swift +Afterpay.presentCheckoutV3Modally(over:consumer:total:items:animated:configuration:requestHandler:completion:) +``` + +Checkout version 3 returns a unique single use card for you to use in your existing checkout flow. + +The configuration object may be set using `setV3Configuration`, or passed into the checkout call. + +### Configuration v3 + +```swift +Afterpay.fetchMerchantConfiguration(configuration:requestHandler:completion:) +``` + +As v3 removes the need for merchant integration with the Afterpay API, the `Configuration` — providing information about minimum and maximum order amounts — is now available through the SDK. + +The configuration object may be set using `setV3Configuration`, or passed into the checkout call. + ### Clearpay Checkout Checkout supports Clearpay for v1 this means supplying a correctly formed URL for the Clearpay environment with a token created for a Clearpay checkout. For v2 this means loading a Clearpay token on demand as well as ensuring to set the locale as `en_GB` in Afterpay configuration. From ac8fcf084341f3c6374555107f04dbc535fcd2df Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Wed, 14 Jul 2021 06:54:53 +1000 Subject: [PATCH 19/81] Use provided currencyCode rather than what is returned from the API --- Sources/Afterpay/Model/Configuration.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Afterpay/Model/Configuration.swift b/Sources/Afterpay/Model/Configuration.swift index dc0e4ac4..0d0143d4 100644 --- a/Sources/Afterpay/Model/Configuration.swift +++ b/Sources/Afterpay/Model/Configuration.swift @@ -122,7 +122,7 @@ public struct Configuration { try self.init( minimumAmount: object.minimumAmount.amount, maximumAmount: object.maximumAmount.amount, - currencyCode: object.minimumAmount.currency, + currencyCode: configuration.region.currencyCode, locale: configuration.region.locale, environment: configuration.environment ) From 5cbc5480e1b1ffdd29bb0d21bd1e54d64a35ef21 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Wed, 14 Jul 2021 07:03:05 +1000 Subject: [PATCH 20/81] Remove Amount in favour of Money --- Afterpay.xcodeproj/project.pbxproj | 4 ---- Sources/Afterpay/AfterpayV3.swift | 12 +++++++++++- Sources/Afterpay/Checkout/CheckoutV3.swift | 8 ++++---- Sources/Afterpay/Model/Amount.swift | 14 -------------- Sources/Afterpay/Model/Configuration.swift | 4 ++-- Sources/Afterpay/Model/Money.swift | 3 ++- 6 files changed, 19 insertions(+), 26 deletions(-) delete mode 100644 Sources/Afterpay/Model/Amount.swift diff --git a/Afterpay.xcodeproj/project.pbxproj b/Afterpay.xcodeproj/project.pbxproj index baca8fec..56a6deb6 100644 --- a/Afterpay.xcodeproj/project.pbxproj +++ b/Afterpay.xcodeproj/project.pbxproj @@ -64,7 +64,6 @@ 83F7DEDF269BF43600F9FB75 /* ConfirmationV3.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEDE269BF43600F9FB75 /* ConfirmationV3.swift */; }; 83F7DEE1269CDCF900F9FB75 /* AfterpayV3.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEE0269CDCF900F9FB75 /* AfterpayV3.swift */; }; 83F7DEE7269E2B3600F9FB75 /* ApiV3.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEE6269E2B3600F9FB75 /* ApiV3.swift */; }; - 83F7DEE9269E2EB300F9FB75 /* Amount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEE8269E2EB300F9FB75 /* Amount.swift */; }; 946388FE24DD077F00A1227A /* InfoWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 946388FD24DD077F00A1227A /* InfoWebViewController.swift */; }; /* End PBXBuildFile section */ @@ -150,7 +149,6 @@ 83F7DEDE269BF43600F9FB75 /* ConfirmationV3.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmationV3.swift; sourceTree = ""; }; 83F7DEE0269CDCF900F9FB75 /* AfterpayV3.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AfterpayV3.swift; sourceTree = ""; }; 83F7DEE6269E2B3600F9FB75 /* ApiV3.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiV3.swift; sourceTree = ""; }; - 83F7DEE8269E2EB300F9FB75 /* Amount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Amount.swift; sourceTree = ""; }; 946388FD24DD077F00A1227A /* InfoWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoWebViewController.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -328,7 +326,6 @@ 661D431E257DC86C00ACCDE1 /* ShippingAddress.swift */, 661D4322257DF1CB00ACCDE1 /* ShippingOption.swift */, 157C65AE25D23E8F00115149 /* Version.swift */, - 83F7DEE8269E2EB300F9FB75 /* Amount.swift */, ); path = Model; sourceTree = ""; @@ -574,7 +571,6 @@ 66DAAC8B24E0CF0100127460 /* PriceBreakdown.swift in Sources */, 83F7DEDF269BF43600F9FB75 /* ConfirmationV3.swift in Sources */, 551BEDFC25F9C56600FDF9EE /* WidgetEvent.swift in Sources */, - 83F7DEE9269E2EB300F9FB75 /* Amount.swift in Sources */, 661CFDB62570E7F000D8A1E8 /* PaymentButton.swift in Sources */, 83F7DEE1269CDCF900F9FB75 /* AfterpayV3.swift in Sources */, 557511C326489F090040CC51 /* SVG+Source.swift in Sources */, diff --git a/Sources/Afterpay/AfterpayV3.swift b/Sources/Afterpay/AfterpayV3.swift index f553f2f3..ca5e184d 100644 --- a/Sources/Afterpay/AfterpayV3.swift +++ b/Sources/Afterpay/AfterpayV3.swift @@ -11,7 +11,7 @@ import UIKit // MARK: - Checkout -/// Returns the merchant configuration object, containing minimum and maximum amounts +/// Returns the merchant configuration object, representing the merchant's applicable payment limits. /// - Parameters: /// - configuration: A collection of options and values required to interact with the Afterpay API. /// - requestHandler: A function that takes a `URLRequest` and a closure to handle the result. @@ -96,6 +96,8 @@ public func getV3Configuration() -> CheckoutV3Configuration? { checkoutV3Configuration } + +/// A collection of options and values required to interact with the Afterpay API. public struct CheckoutV3Configuration { let shopDirectoryId: String let shopDirectoryMerchantId: String @@ -158,6 +160,7 @@ public struct CheckoutV3Configuration { // MARK: - Inner type + /// Regions supporting V3 checkouts public enum Region { case US @@ -190,11 +193,18 @@ public protocol CheckoutV3Consumer { } public protocol CheckoutV3Item { + /// Product name. Limited to 255 characters. var name: String { get } + /// The quantity of the item, stored as a signed 32-bit integer. var quantity: Int { get } + /// The unit price of the individual item. Must be a positive value. var price: Decimal { get } + /// Product SKU. Limited to 128 characters. var sku: String? { get } + /// The canonical URL for the item's Product Detail Page. Limited to 2048 characters. var pageUrl: URL? { get } + /// A URL for a web-optimised photo of the item, suitable for use directly as the src attribute of an img tag. + /// Limited to 2048 characters. var imageUrl: URL? { get } /// An array of arrays to accommodate multiple categories that might apply to the item. /// Each array contains comma separated strings with the left-most category being the top level category. diff --git a/Sources/Afterpay/Checkout/CheckoutV3.swift b/Sources/Afterpay/Checkout/CheckoutV3.swift index aa756b10..c9fb7da1 100644 --- a/Sources/Afterpay/Checkout/CheckoutV3.swift +++ b/Sources/Afterpay/Checkout/CheckoutV3.swift @@ -16,7 +16,7 @@ enum CheckoutV3 { let shopDirectoryMerchantId: String let merchantPublicKey: String - let amount: Amount + let amount: Money let items: [Item] let consumer: Consumer let merchant: Merchant @@ -31,7 +31,7 @@ enum CheckoutV3 { self.shopDirectoryMerchantId = configuration.shopDirectoryMerchantId self.merchantPublicKey = configuration.merchantPublicKey - self.amount = Amount( + self.amount = Money( amount: configuration.region.formatted(currency: amount), currency: configuration.region.currencyCode ) @@ -50,7 +50,7 @@ enum CheckoutV3 { struct Item: Encodable { let name: String let quantity: Int - let price: Amount + let price: Money let sku: String? let pageUrl: URL? let imageUrl: URL? @@ -59,7 +59,7 @@ enum CheckoutV3 { init(_ item: CheckoutV3Item, _ region: CheckoutV3Configuration.Region) { self.name = item.name self.quantity = item.quantity - self.price = Amount( + self.price = Money( amount: region.formatted(currency: item.price), currency: region.currencyCode ) diff --git a/Sources/Afterpay/Model/Amount.swift b/Sources/Afterpay/Model/Amount.swift deleted file mode 100644 index 37ded9af..00000000 --- a/Sources/Afterpay/Model/Amount.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// Amount.swift -// Afterpay -// -// Created by Chris Kolbu on 14/7/21. -// Copyright © 2021 Afterpay. All rights reserved. -// - -import Foundation - -struct Amount: Codable { - let amount: String - let currency: String -} diff --git a/Sources/Afterpay/Model/Configuration.swift b/Sources/Afterpay/Model/Configuration.swift index 0d0143d4..b5f8f81f 100644 --- a/Sources/Afterpay/Model/Configuration.swift +++ b/Sources/Afterpay/Model/Configuration.swift @@ -129,8 +129,8 @@ public struct Configuration { } struct Object: Decodable { - let minimumAmount: Amount - let maximumAmount: Amount + let minimumAmount: Money + let maximumAmount: Money } } diff --git a/Sources/Afterpay/Model/Money.swift b/Sources/Afterpay/Model/Money.swift index bbe4e0cf..8df4cefa 100644 --- a/Sources/Afterpay/Model/Money.swift +++ b/Sources/Afterpay/Model/Money.swift @@ -9,8 +9,9 @@ import Foundation public struct Money: Codable, Equatable { - + /// The amount is a string representation of a decimal number, rounded to 2 decimal places var amount: String + /// The currency in ISO 4217 format. Supported values include "AUD", "NZD", "CAD", and "USD". However, the value provided must correspond to the currency of the Merchant account making the request. var currency: String public init(amount: String, currency: String) { From 98d89c06de2075d4308dd0b66444e232c9109c40 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Wed, 14 Jul 2021 07:11:41 +1000 Subject: [PATCH 21/81] Add documentation and properties to CheckoutV3 protocols --- Sources/Afterpay/AfterpayV3.swift | 6 ++++++ Sources/Afterpay/Checkout/CheckoutV3.swift | 2 ++ 2 files changed, 8 insertions(+) diff --git a/Sources/Afterpay/AfterpayV3.swift b/Sources/Afterpay/AfterpayV3.swift index ca5e184d..8465935f 100644 --- a/Sources/Afterpay/AfterpayV3.swift +++ b/Sources/Afterpay/AfterpayV3.swift @@ -186,9 +186,13 @@ public struct CheckoutV3Configuration { } public protocol CheckoutV3Consumer { + /// The consumer’s email address. Limited to 128 characters. var email: String { get } + /// The consumer’s first name and any middle names. Limited to 128 characters. var givenNames: String? { get } + /// The consumer’s last name. Limited to 128 characters. var surname: String? { get } + /// The consumer’s phone number. Limited to 32 characters. var phoneNumber: String? { get } } @@ -209,4 +213,6 @@ public protocol CheckoutV3Item { /// An array of arrays to accommodate multiple categories that might apply to the item. /// Each array contains comma separated strings with the left-most category being the top level category. var categories: [[String]]? { get } + /// The estimated date when the order will be shipped. YYYY-MM or YYYY-MM-DD format. + var estimatedShipmentDate: String? { get } } diff --git a/Sources/Afterpay/Checkout/CheckoutV3.swift b/Sources/Afterpay/Checkout/CheckoutV3.swift index c9fb7da1..d4f4a295 100644 --- a/Sources/Afterpay/Checkout/CheckoutV3.swift +++ b/Sources/Afterpay/Checkout/CheckoutV3.swift @@ -55,6 +55,7 @@ enum CheckoutV3 { let pageUrl: URL? let imageUrl: URL? let categories: [[String]]? + let estimatedShipmentDate: String? init(_ item: CheckoutV3Item, _ region: CheckoutV3Configuration.Region) { self.name = item.name @@ -67,6 +68,7 @@ enum CheckoutV3 { self.pageUrl = item.pageUrl self.imageUrl = item.imageUrl self.categories = item.categories + self.estimatedShipmentDate = item.estimatedShipmentDate } } From f02965d39f7bca5ffb43f72786b3a75bb21b83b5 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Wed, 14 Jul 2021 10:38:03 +1000 Subject: [PATCH 22/81] Add newline to comment --- Sources/Afterpay/Model/Money.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/Afterpay/Model/Money.swift b/Sources/Afterpay/Model/Money.swift index 8df4cefa..bec03fe1 100644 --- a/Sources/Afterpay/Model/Money.swift +++ b/Sources/Afterpay/Model/Money.swift @@ -11,7 +11,8 @@ import Foundation public struct Money: Codable, Equatable { /// The amount is a string representation of a decimal number, rounded to 2 decimal places var amount: String - /// The currency in ISO 4217 format. Supported values include "AUD", "NZD", "CAD", and "USD". However, the value provided must correspond to the currency of the Merchant account making the request. + /// The currency in ISO 4217 format. Supported values include "AUD", "NZD", "CAD", and "USD". + /// However, the value provided must correspond to the currency of the Merchant account making the request. var currency: String public init(amount: String, currency: String) { From 9dca600ccaa22e8f61f20b5b914d8b8bbcba4add Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Wed, 14 Jul 2021 10:38:58 +1000 Subject: [PATCH 23/81] Add shipping and billing options to V3 checkout --- Afterpay.xcodeproj/project.pbxproj | 4 ++ .../Purchase/PurchaseFlowController.swift | 7 ---- Sources/Afterpay/AfterpayV3.swift | 27 +++++++++++++- Sources/Afterpay/Checkout/CheckoutV3.swift | 32 ++++++++++++++++ Sources/Afterpay/Model/Consumer.swift | 37 +++++++++++++++++++ 5 files changed, 99 insertions(+), 8 deletions(-) create mode 100644 Sources/Afterpay/Model/Consumer.swift diff --git a/Afterpay.xcodeproj/project.pbxproj b/Afterpay.xcodeproj/project.pbxproj index 56a6deb6..630018c4 100644 --- a/Afterpay.xcodeproj/project.pbxproj +++ b/Afterpay.xcodeproj/project.pbxproj @@ -64,6 +64,7 @@ 83F7DEDF269BF43600F9FB75 /* ConfirmationV3.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEDE269BF43600F9FB75 /* ConfirmationV3.swift */; }; 83F7DEE1269CDCF900F9FB75 /* AfterpayV3.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEE0269CDCF900F9FB75 /* AfterpayV3.swift */; }; 83F7DEE7269E2B3600F9FB75 /* ApiV3.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEE6269E2B3600F9FB75 /* ApiV3.swift */; }; + 83F7DEEC269E62DA00F9FB75 /* Consumer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEEA269E624600F9FB75 /* Consumer.swift */; }; 946388FE24DD077F00A1227A /* InfoWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 946388FD24DD077F00A1227A /* InfoWebViewController.swift */; }; /* End PBXBuildFile section */ @@ -149,6 +150,7 @@ 83F7DEDE269BF43600F9FB75 /* ConfirmationV3.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmationV3.swift; sourceTree = ""; }; 83F7DEE0269CDCF900F9FB75 /* AfterpayV3.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AfterpayV3.swift; sourceTree = ""; }; 83F7DEE6269E2B3600F9FB75 /* ApiV3.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiV3.swift; sourceTree = ""; }; + 83F7DEEA269E624600F9FB75 /* Consumer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Consumer.swift; sourceTree = ""; }; 946388FD24DD077F00A1227A /* InfoWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoWebViewController.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -326,6 +328,7 @@ 661D431E257DC86C00ACCDE1 /* ShippingAddress.swift */, 661D4322257DF1CB00ACCDE1 /* ShippingOption.swift */, 157C65AE25D23E8F00115149 /* Version.swift */, + 83F7DEEA269E624600F9FB75 /* Consumer.swift */, ); path = Model; sourceTree = ""; @@ -601,6 +604,7 @@ 557511BF2644CAA50040CC51 /* WKWebView+Cache.swift in Sources */, 838942BB269BAC9B0036D1C7 /* CardDetails.swift in Sources */, 6605666324E5199500DA588E /* Locales.swift in Sources */, + 83F7DEEC269E62DA00F9FB75 /* Consumer.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Example/Example/Purchase/PurchaseFlowController.swift b/Example/Example/Purchase/PurchaseFlowController.swift index 3ce4a55f..6dc4894c 100644 --- a/Example/Example/Purchase/PurchaseFlowController.swift +++ b/Example/Example/Purchase/PurchaseFlowController.swift @@ -187,10 +187,3 @@ final class PurchaseFlowController: UIViewController { } } - -struct Consumer: CheckoutV3Consumer { - var email: String - var givenNames: String? { nil } - var surname: String? { nil } - var phoneNumber: String? { nil } -} diff --git a/Sources/Afterpay/AfterpayV3.swift b/Sources/Afterpay/AfterpayV3.swift index 8465935f..a628a913 100644 --- a/Sources/Afterpay/AfterpayV3.swift +++ b/Sources/Afterpay/AfterpayV3.swift @@ -96,7 +96,6 @@ public func getV3Configuration() -> CheckoutV3Configuration? { checkoutV3Configuration } - /// A collection of options and values required to interact with the Afterpay API. public struct CheckoutV3Configuration { let shopDirectoryId: String @@ -194,6 +193,32 @@ public protocol CheckoutV3Consumer { var surname: String? { get } /// The consumer’s phone number. Limited to 32 characters. var phoneNumber: String? { get } + /// The consumer's shipping information. + var shippingInformation: CheckoutV3Contact? { get } + /// The consumer's billing information. + var billingInformation: CheckoutV3Contact? { get } +} + +public protocol CheckoutV3Contact { + /// Full name of contact. Limited to 255 characters + var name: String { get } + /// First line of the address. Limited to 128 characters + var line1: String { get } + /// Second line of the address. Limited to 128 characters. + var line2: String? { get } + /// Australian suburb, U.S. city, New Zealand town or city, U.K. Postal town. + /// Maximum length is 128 characters. + var area1: String? { get } + /// New Zealand suburb, U.K. village or local area. Maximum length is 128 characters. + var area2: String? { get } + /// U.S. state, Australian state, U.K. county, New Zealand region. Maximum length is 128 characters. + var region: String? { get } + /// The zip code or equivalent. Maximum length is 128 characters. + var postcode: String? { get } + /// The two-character ISO 3166-1 country code. + var countryCode: String { get } + /// The phone number, in E.123 format. Maximum length is 32 characters. + var phoneNumber: String? { get } } public protocol CheckoutV3Item { diff --git a/Sources/Afterpay/Checkout/CheckoutV3.swift b/Sources/Afterpay/Checkout/CheckoutV3.swift index d4f4a295..87ff841e 100644 --- a/Sources/Afterpay/Checkout/CheckoutV3.swift +++ b/Sources/Afterpay/Checkout/CheckoutV3.swift @@ -20,6 +20,8 @@ enum CheckoutV3 { let items: [Item] let consumer: Consumer let merchant: Merchant + let shipping: Contact? + let billing: Contact? init( consumer: CheckoutV3Consumer, @@ -43,6 +45,9 @@ enum CheckoutV3 { redirectConfirmUrl: URL(string: "https://www.afterpay.com")!, redirectCancelUrl: URL(string: "https://www.afterpay.com")! ) + + self.shipping = Contact(consumer.shippingInformation) + self.billing = Contact(consumer.billingInformation) } // MARK: - Inner types @@ -90,6 +95,33 @@ enum CheckoutV3 { self.phoneNumber = consumer.phoneNumber } } + + struct Contact: Encodable { + let name: String + let line1: String + let line2: String? + let area1: String? + let area2: String? + let region: String? + let postcode: String? + let countryCode: String + let phoneNumber: String? + + init?(_ contact: CheckoutV3Contact?) { + guard let contact = contact else { + return nil + } + self.name = contact.name + self.line1 = contact.line1 + self.line2 = contact.line2 + self.area1 = contact.area1 + self.area2 = contact.area2 + self.region = contact.region + self.postcode = contact.postcode + self.countryCode = contact.countryCode + self.phoneNumber = contact.phoneNumber + } + } } struct Response: Decodable { diff --git a/Sources/Afterpay/Model/Consumer.swift b/Sources/Afterpay/Model/Consumer.swift new file mode 100644 index 00000000..2f67b81d --- /dev/null +++ b/Sources/Afterpay/Model/Consumer.swift @@ -0,0 +1,37 @@ +// +// Consumer.swift +// Afterpay +// +// Created by Chris Kolbu on 14/7/21. +// Copyright © 2021 Afterpay. All rights reserved. +// + +import Foundation + +/// A minimal implementation of `CheckoutV3Consumer` +public struct Consumer: CheckoutV3Consumer { + + public var email: String + public var givenNames: String? + public var surname: String? + public var phoneNumber: String? + public var shippingInformation: CheckoutV3Contact? + public var billingInformation: CheckoutV3Contact? + + public init( + email: String, + givenNames: String? = nil, + surname: String? = nil, + phoneNumber: String? = nil, + shippingInformation: CheckoutV3Contact? = nil, + billingInformation: CheckoutV3Contact? = nil + ) { + self.email = email + self.givenNames = givenNames + self.surname = surname + self.phoneNumber = phoneNumber + self.shippingInformation = shippingInformation + self.billingInformation = billingInformation + } + +} From 59f67591d01b917bb4a04bfae3e359ef57b91ad3 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Wed, 14 Jul 2021 11:33:41 +1000 Subject: [PATCH 24/81] Change button text; re-add options on cart screen --- Example/Example/Purchase/CartViewController.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Example/Example/Purchase/CartViewController.swift b/Example/Example/Purchase/CartViewController.swift index 7f4fb7e7..0f1fd515 100644 --- a/Example/Example/Purchase/CartViewController.swift +++ b/Example/Example/Purchase/CartViewController.swift @@ -50,7 +50,7 @@ final class CartViewController: UIViewController, UITableViewDataSource { tableView.register(TitleSubtitleCell.self, forCellReuseIdentifier: titleSubtitleCellIdentifier) let payButton: UIButton = - PaymentButton(colorScheme: .dynamic(lightPalette: .blackOnMint, darkPalette: .mintOnBlack), buttonKind: .checkout) + PaymentButton(colorScheme: .dynamic(lightPalette: .blackOnMint, darkPalette: .mintOnBlack), buttonKind: .payNow) payButton.isEnabled = cart.payEnabled payButton.accessibilityIdentifier = "payNow" payButton.addTarget(self, action: #selector(didTapPay), for: .touchUpInside) @@ -97,9 +97,8 @@ final class CartViewController: UIViewController, UITableViewDataSource { return cart.products.count case .total: return 1 - // Disabled for V3 purposes case .options: - return 0 + return 1 } } From 3e82f0a6f420fefa7ae5aa8acc149084e0be2b6f Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Wed, 14 Jul 2021 16:19:15 +1000 Subject: [PATCH 25/81] Add ApiV3.request overload that returns a Void result This is handy for cases where the response body is 204/empty but valid --- Sources/Afterpay/ApiV3.swift | 31 +++++++++++++++++++ .../Afterpay/Checkout/CancellationV3.swift | 3 ++ .../Afterpay/Checkout/CheckoutV3Result.swift | 16 ++++++++++ 3 files changed, 50 insertions(+) create mode 100644 Sources/Afterpay/Checkout/CancellationV3.swift create mode 100644 Sources/Afterpay/Checkout/CheckoutV3Result.swift diff --git a/Sources/Afterpay/ApiV3.swift b/Sources/Afterpay/ApiV3.swift index 3847634a..df201e06 100644 --- a/Sources/Afterpay/ApiV3.swift +++ b/Sources/Afterpay/ApiV3.swift @@ -47,6 +47,36 @@ enum ApiV3 { return request } + static func request( + _ requestHandler: URLRequestHandler, + _ request: URLRequest, + completion: @escaping (Result) -> Void + ) -> URLSessionDataTask { + let completeOnMainThread: (Result) -> Void = { result in + DispatchQueue.main.async { + completion(result) + } + } + return requestHandler(request) { data, urlResponse, error in + guard let httpResponse = urlResponse as? HTTPURLResponse else { + completeOnMainThread(.failure(NetworkError.unknown(urlResponse))) + return + } + switch (error, data) { + case (.none, .some(let data)) where data.isEmpty && httpResponse.statusCode == 204: + completeOnMainThread(.success(())) + case (.none, .some(let data)): + if let error = try? Self.decoder.decode(ApiError.self, from: data) { + completeOnMainThread(.failure(error)) + } + case (.some(let error), _): + completeOnMainThread(.failure(error)) + default: + completeOnMainThread(.failure(NetworkError.unexpectedResponse(urlResponse))) + } + } + } + static func request( _ requestHandler: URLRequestHandler, _ request: URLRequest, @@ -80,6 +110,7 @@ enum ApiV3 { public enum NetworkError: Error { case unknown(URLResponse?) + case unexpectedResponse(URLResponse?) } } diff --git a/Sources/Afterpay/Checkout/CancellationV3.swift b/Sources/Afterpay/Checkout/CancellationV3.swift new file mode 100644 index 00000000..cffbf2ad --- /dev/null +++ b/Sources/Afterpay/Checkout/CancellationV3.swift @@ -0,0 +1,3 @@ +// Copyright 2021 Itty Bitty Apps Pty Ltd + +import Foundation diff --git a/Sources/Afterpay/Checkout/CheckoutV3Result.swift b/Sources/Afterpay/Checkout/CheckoutV3Result.swift new file mode 100644 index 00000000..16a4bac6 --- /dev/null +++ b/Sources/Afterpay/Checkout/CheckoutV3Result.swift @@ -0,0 +1,16 @@ +// +// ConfirmationV3.swift +// Afterpay +// +// Created by Chris Kolbu on 14/7/21. +// Copyright © 2021 Afterpay. All rights reserved. +// + +import Foundation + +public protocol CheckoutV3Data { + var cancellation: CancellationClosure { get } + var merchantReferenceUpdate: MerchantReferenceUpdateClosure { get } + var cardValidUntil: Date? { get } + var cardDetails: CardDetails { get } +} From d469d51dd5c288d2be39514ad41e8b23e7d95d9c Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Wed, 14 Jul 2021 16:21:57 +1000 Subject: [PATCH 26/81] Extract V3 Checkout result from V1/2 CheckoutResults model --- .../Purchase/PurchaseFlowController.swift | 21 +++++++------------ .../Checkout/CheckoutV2ViewController.swift | 2 +- .../Afterpay/Checkout/CheckoutV3Result.swift | 5 +++++ .../Checkout/CheckoutWebViewController.swift | 2 +- Sources/Afterpay/Model/CheckoutResult.swift | 7 +------ Sources/Afterpay/Wrappers/ObjcWrapper.swift | 6 +----- 6 files changed, 16 insertions(+), 27 deletions(-) diff --git a/Example/Example/Purchase/PurchaseFlowController.swift b/Example/Example/Purchase/PurchaseFlowController.swift index 6dc4894c..69e0b32f 100644 --- a/Example/Example/Purchase/PurchaseFlowController.swift +++ b/Example/Example/Purchase/PurchaseFlowController.swift @@ -117,11 +117,8 @@ final class PurchaseFlowController: UIViewController { loading: checkoutURL ) { result in switch result { - case .success(.token(let token)): + case .success(let token): logicController.success(with: token) - // Here for backward compatibility; this case will never be hit - case .success(_): - break case .cancelled(let reason): logicController.cancelled(with: reason) } @@ -130,11 +127,8 @@ final class PurchaseFlowController: UIViewController { case .showAfterpayCheckoutV2(let options): Afterpay.presentCheckoutV2Modally(over: ownedNavigationController, options: options) { result in switch result { - case .success(.token(let token)): + case .success(let token): logicController.success(with: token) - // Here for backward compatibility; this case will never be hit - case .success(_): - break case .cancelled(let reason): logicController.cancelled(with: reason) } @@ -148,13 +142,12 @@ final class PurchaseFlowController: UIViewController { requestHandler: APIClient.live.session.dataTask ) { result in switch result { - // Here for backward compatibility; this case will never be hit - case .success(.token(_)): - break - case .success(.singleUseCard(_, let validUntil, let cardDetails)): + case .success(let data): let controller = SingleUseCardResultViewController( - details: cardDetails, - authorizationExpiration: validUntil + details: data.cardDetails, + authorizationExpiration: data.cardValidUntil, + cancellationClosure: data.cancellation, + merchantReferenceUpdateClosure: data.merchantReferenceUpdate ) navigationController.pushViewController(controller, animated: true) case .cancelled(let reason): diff --git a/Sources/Afterpay/Checkout/CheckoutV2ViewController.swift b/Sources/Afterpay/Checkout/CheckoutV2ViewController.swift index f981f94e..c361dcc2 100644 --- a/Sources/Afterpay/Checkout/CheckoutV2ViewController.swift +++ b/Sources/Afterpay/Checkout/CheckoutV2ViewController.swift @@ -334,7 +334,7 @@ final class CheckoutV2ViewController: } else if let completion = completion { switch completion { case .success(let token): - dismiss(animated: true) { self.completion(.success(value: .token(token: token))) } + dismiss(animated: true) { self.completion(.success(token: token)) } case .cancelled: dismiss(animated: true) { self.completion(.cancelled(reason: .userInitiated)) } } diff --git a/Sources/Afterpay/Checkout/CheckoutV3Result.swift b/Sources/Afterpay/Checkout/CheckoutV3Result.swift index 16a4bac6..4393ef9f 100644 --- a/Sources/Afterpay/Checkout/CheckoutV3Result.swift +++ b/Sources/Afterpay/Checkout/CheckoutV3Result.swift @@ -14,3 +14,8 @@ public protocol CheckoutV3Data { var cardValidUntil: Date? { get } var cardDetails: CardDetails { get } } + +@frozen public enum CheckoutV3Result { + case success(data: CheckoutV3Data) + case cancelled(reason: CheckoutResult.CancellationReason) +} diff --git a/Sources/Afterpay/Checkout/CheckoutWebViewController.swift b/Sources/Afterpay/Checkout/CheckoutWebViewController.swift index 44bda2ea..04c55a8b 100644 --- a/Sources/Afterpay/Checkout/CheckoutWebViewController.swift +++ b/Sources/Afterpay/Checkout/CheckoutWebViewController.swift @@ -138,7 +138,7 @@ final class CheckoutWebViewController: case (false, .success(let token)): decisionHandler(.cancel) - dismiss(animated: true) { self.completion(.success(value: .token(token: token))) } + dismiss(animated: true) { self.completion(.success(token: token)) } case (false, .cancelled): decisionHandler(.cancel) diff --git a/Sources/Afterpay/Model/CheckoutResult.swift b/Sources/Afterpay/Model/CheckoutResult.swift index f83994a0..d0711f96 100644 --- a/Sources/Afterpay/Model/CheckoutResult.swift +++ b/Sources/Afterpay/Model/CheckoutResult.swift @@ -9,14 +9,9 @@ import Foundation @frozen public enum CheckoutResult { - case success(value: Value) + case success(token: String) case cancelled(reason: CancellationReason) - @frozen public enum Value { - case token(token: String) - case singleUseCard(authToken: String, cardValidUntil: Date?, details: CardDetails) - } - public enum CancellationReason { case userInitiated case networkError(Error) diff --git a/Sources/Afterpay/Wrappers/ObjcWrapper.swift b/Sources/Afterpay/Wrappers/ObjcWrapper.swift index 1bc115e1..4bb96d95 100644 --- a/Sources/Afterpay/Wrappers/ObjcWrapper.swift +++ b/Sources/Afterpay/Wrappers/ObjcWrapper.swift @@ -103,13 +103,9 @@ public final class ObjcWrapper: NSObject { animated: animated, completion: { result in switch result { - case .success(.token(let token)): + case .success(let token): completion(.success(token: token)) - // Here for backward compatibility; this case will never trigger - case .success(_): - break - case .cancelled(.userInitiated): completion(.cancelled(reason: .userInitiated())) From 839a89301a4017b22aefa8b1f0bb00a402e04b40 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Wed, 14 Jul 2021 16:25:15 +1000 Subject: [PATCH 27/81] Add support for cancellation and merchant reference updates --- Afterpay.xcodeproj/project.pbxproj | 8 ++ .../SingleUseCardResultViewController.swift | 74 +++++++++++++++++- Sources/Afterpay/AfterpayV3.swift | 11 ++- .../Afterpay/Checkout/CancellationV3.swift | 26 ++++++- Sources/Afterpay/Checkout/CheckoutV3.swift | 19 +++++ .../Checkout/CheckoutV3ViewController.swift | 78 ++++++++++++++++--- 6 files changed, 203 insertions(+), 13 deletions(-) diff --git a/Afterpay.xcodeproj/project.pbxproj b/Afterpay.xcodeproj/project.pbxproj index 630018c4..f4e2a9da 100644 --- a/Afterpay.xcodeproj/project.pbxproj +++ b/Afterpay.xcodeproj/project.pbxproj @@ -65,6 +65,8 @@ 83F7DEE1269CDCF900F9FB75 /* AfterpayV3.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEE0269CDCF900F9FB75 /* AfterpayV3.swift */; }; 83F7DEE7269E2B3600F9FB75 /* ApiV3.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEE6269E2B3600F9FB75 /* ApiV3.swift */; }; 83F7DEEC269E62DA00F9FB75 /* Consumer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEEA269E624600F9FB75 /* Consumer.swift */; }; + 83F7DEF4269E94CE00F9FB75 /* CancellationV3.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEF3269E94CE00F9FB75 /* CancellationV3.swift */; }; + 83F7DEF6269EB4CA00F9FB75 /* CheckoutV3Result.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEF5269EB4CA00F9FB75 /* CheckoutV3Result.swift */; }; 946388FE24DD077F00A1227A /* InfoWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 946388FD24DD077F00A1227A /* InfoWebViewController.swift */; }; /* End PBXBuildFile section */ @@ -151,6 +153,8 @@ 83F7DEE0269CDCF900F9FB75 /* AfterpayV3.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AfterpayV3.swift; sourceTree = ""; }; 83F7DEE6269E2B3600F9FB75 /* ApiV3.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiV3.swift; sourceTree = ""; }; 83F7DEEA269E624600F9FB75 /* Consumer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Consumer.swift; sourceTree = ""; }; + 83F7DEF3269E94CE00F9FB75 /* CancellationV3.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CancellationV3.swift; sourceTree = ""; }; + 83F7DEF5269EB4CA00F9FB75 /* CheckoutV3Result.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckoutV3Result.swift; sourceTree = ""; }; 946388FD24DD077F00A1227A /* InfoWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoWebViewController.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -374,6 +378,8 @@ 838942B6269B8FD50036D1C7 /* CheckoutV3ViewController.swift */, 667AD3532497121100BF94E5 /* CheckoutWebViewController.swift */, 83F7DEDE269BF43600F9FB75 /* ConfirmationV3.swift */, + 83F7DEF3269E94CE00F9FB75 /* CancellationV3.swift */, + 83F7DEF5269EB4CA00F9FB75 /* CheckoutV3Result.swift */, ); path = Checkout; sourceTree = ""; @@ -595,7 +601,9 @@ 55432830263A61C4005512E4 /* CombineWrapper.swift in Sources */, 157E88D125CBCA49007E54C4 /* Result+Fold.swift in Sources */, 55A2D307261BB36C00D8E23A /* Money.swift in Sources */, + 83F7DEF4269E94CE00F9FB75 /* CancellationV3.swift in Sources */, 661D4323257DF1CB00ACCDE1 /* ShippingOption.swift in Sources */, + 83F7DEF6269EB4CA00F9FB75 /* CheckoutV3Result.swift in Sources */, 946388FE24DD077F00A1227A /* InfoWebViewController.swift in Sources */, 83F7DEE7269E2B3600F9FB75 /* ApiV3.swift in Sources */, 66EE9BD724DCEC3E00A81C19 /* LinkTextView.swift in Sources */, diff --git a/Example/Example/Purchase/SingleUseCardResultViewController.swift b/Example/Example/Purchase/SingleUseCardResultViewController.swift index 55394052..2a27ccab 100644 --- a/Example/Example/Purchase/SingleUseCardResultViewController.swift +++ b/Example/Example/Purchase/SingleUseCardResultViewController.swift @@ -15,6 +15,8 @@ final class SingleUseCardResultViewController: UIViewController { // MARK: - Properties private let details: CardDetails private let authorizationExpiration: Date? + private(set) var cancellationClosure: CancellationClosure? + private let merchantReferenceUpdateClosure: MerchantReferenceUpdateClosure private let vStack: UIStackView = { let stackView = UIStackView() @@ -30,6 +32,7 @@ final class SingleUseCardResultViewController: UIViewController { "CVC: \(details.cvc)", "Expiration: \(details.expiryMonth)/\(details.expiryYear)", "Virtual card expiry: \(authorizationExpiration?.shortDuration ?? "Unavailable")", + "Merchant reference: ", ] return strings.map { string in @@ -66,11 +69,40 @@ final class SingleUseCardResultViewController: UIViewController { return label }() + private lazy var cancellationButton: UIButton = { + let button = UIButton(type: .roundedRect) + button.setTitle("Cancel card", for: .normal) + button.setTitle("Card cancelled", for: .disabled) + button.addTarget(self, action: #selector(cancel), for: .touchUpInside) + button.backgroundColor = .systemRed + button.setTitleColor(.white, for: .normal) + button.setTitleColor(.darkText, for: .disabled) + button.layer.cornerRadius = 8 + return button + }() + + private lazy var updateButton: UIButton = { + let button = UIButton(type: .roundedRect) + button.setTitle("Update merchant reference", for: .normal) + button.addTarget(self, action: #selector(update), for: .touchUpInside) + button.backgroundColor = .systemGreen + button.setTitleColor(.white, for: .normal) + button.layer.cornerRadius = 8 + return button + }() + // MARK: - Initializer - init(details: CardDetails, authorizationExpiration: Date?) { + init( + details: CardDetails, + authorizationExpiration: Date?, + cancellationClosure: @escaping CancellationClosure, + merchantReferenceUpdateClosure: @escaping MerchantReferenceUpdateClosure + ) { self.details = details self.authorizationExpiration = authorizationExpiration + self.cancellationClosure = cancellationClosure + self.merchantReferenceUpdateClosure = merchantReferenceUpdateClosure super.init(nibName: nil, bundle: nil) @@ -87,11 +119,47 @@ final class SingleUseCardResultViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() view.addSubview(vStack) - (labels + [explanatoryHeaderLabel, explanatoryBodyLabel]).forEach(vStack.addArrangedSubview) + (labels + [explanatoryHeaderLabel, explanatoryBodyLabel, cancellationButton, updateButton]) + .forEach(vStack.addArrangedSubview) vStack.setCustomSpacing(24, after: labels.last!) + vStack.setCustomSpacing(24, after: explanatoryBodyLabel) + vStack.setCustomSpacing(16, after: cancellationButton) setConstraints() } + // MARK: - Actions + + @objc func cancel() { + self.cancellationClosure? { [weak self] result in + switch result { + case .success: + UIView.animate(withDuration: 0.3, animations: { + self?.cancellationButton.isEnabled = false + self?.cancellationButton.backgroundColor = .systemGray + }) + self?.cancellationClosure = nil + case .failure(let error): + let alert = AlertFactory.alert(for: error.localizedDescription) + self?.present(alert, animated: true) + } + } + } + + @objc func update() { + updateButton.setTitle("Updating ...", for: .normal) + let newId = UUID().uuidString + self.merchantReferenceUpdateClosure(UUID().uuidString) { [weak self] result in + switch result { + case .success: + self?.updateButton.setTitle("Merchant reference updated!", for: .normal) + self?.labels.last?.text = "Merchant reference: \(newId)" + case .failure(let error): + let alert = AlertFactory.alert(for: error.localizedDescription) + self?.present(alert, animated: true) + } + } + } + // MARK: - Constraints private func setConstraints() { @@ -116,6 +184,8 @@ final class SingleUseCardResultViewController: UIViewController { scrollView.contentLayoutGuide.widthAnchor.constraint( equalTo: scrollView.frameLayoutGuide.widthAnchor ), + cancellationButton.heightAnchor.constraint(equalToConstant: 44), + updateButton.heightAnchor.constraint(equalToConstant: 44), ]) } diff --git a/Sources/Afterpay/AfterpayV3.swift b/Sources/Afterpay/AfterpayV3.swift index a628a913..3596a612 100644 --- a/Sources/Afterpay/AfterpayV3.swift +++ b/Sources/Afterpay/AfterpayV3.swift @@ -66,7 +66,7 @@ public func presentCheckoutV3Modally( animated: Bool = true, configuration: CheckoutV3Configuration? = getV3Configuration(), requestHandler: @escaping URLRequestHandler = URLSession.shared.dataTask, - completion: @escaping (_ result: CheckoutResult) -> Void + completion: @escaping (_ result: CheckoutV3Result) -> Void ) { guard let configuration = configuration else { return assertionFailure( @@ -138,6 +138,15 @@ public struct CheckoutV3Configuration { } } + var v3CheckoutCancellationUrl: URL { + switch (region, environment) { + case (.US, .sandbox): + return URL(string: "https://api-plus.us-sandbox.afterpay.com/v3/button/cancel")! + case (.US, .production): + return URL(string: "https://api-plus.us.afterpay.com/v3/button/cancel")! + } + } + var v3ConfigurationUrl: URL { var url: URL switch (region, environment) { diff --git a/Sources/Afterpay/Checkout/CancellationV3.swift b/Sources/Afterpay/Checkout/CancellationV3.swift index cffbf2ad..61ca20d7 100644 --- a/Sources/Afterpay/Checkout/CancellationV3.swift +++ b/Sources/Afterpay/Checkout/CancellationV3.swift @@ -1,3 +1,27 @@ -// Copyright 2021 Itty Bitty Apps Pty Ltd +// +// ConfirmationV3.swift +// Afterpay +// +// Created by Chris Kolbu on 12/7/21. +// Copyright © 2021 Afterpay. All rights reserved. +// import Foundation + +enum CancellationV3 { + + struct Request: Encodable { + let token: String + let ppaConfirmToken: String + let singleUseCardToken: String + } + + struct Response: Decodable, CheckoutV3Cancellation { + let authToken: String + } + +} + +public protocol CheckoutV3Cancellation { + +} diff --git a/Sources/Afterpay/Checkout/CheckoutV3.swift b/Sources/Afterpay/Checkout/CheckoutV3.swift index 87ff841e..e221ce60 100644 --- a/Sources/Afterpay/Checkout/CheckoutV3.swift +++ b/Sources/Afterpay/Checkout/CheckoutV3.swift @@ -8,6 +8,12 @@ import Foundation +/// A closure that will cancel the virtual card generated by Afterpay +public typealias CancellationClosure = (@escaping (Result) -> Void) -> Void +/// A closure that will update the merchant reference on the checkout object +/// initially created via `Afterpay.presentCheckoutV3Modally` +public typealias MerchantReferenceUpdateClosure = (String, @escaping (Result) -> Void) -> Void + // swiftlint:disable nesting enum CheckoutV3 { @@ -131,4 +137,17 @@ enum CheckoutV3 { let singleUseCardToken: String } + struct ResultData: CheckoutV3Data { + let cancellation: CancellationClosure + let merchantReferenceUpdate: MerchantReferenceUpdateClosure + let cardValidUntil: Date? + let cardDetails: CardDetails + } + + struct MerchantReferenceUpdate: Encodable { + let authToken: String + let token: String + let merchantReference: String + } + } diff --git a/Sources/Afterpay/Checkout/CheckoutV3ViewController.swift b/Sources/Afterpay/Checkout/CheckoutV3ViewController.swift index 507673fb..323d156d 100644 --- a/Sources/Afterpay/Checkout/CheckoutV3ViewController.swift +++ b/Sources/Afterpay/Checkout/CheckoutV3ViewController.swift @@ -9,7 +9,7 @@ import UIKit import WebKit -// swiftlint:disable:next colon +// swiftlint:disable:next colon type_body_length final class CheckoutV3ViewController: UIViewController, UIAdaptivePresentationControllerDelegate, @@ -20,7 +20,7 @@ final class CheckoutV3ViewController: private let configuration: CheckoutV3Configuration private let requestHandler: URLRequestHandler private var currentTask: URLSessionDataTask? - private let completion: (_ result: CheckoutResult) -> Void + private let completion: (_ result: CheckoutV3Result) -> Void private var token: Token? private var singleUseCardToken: Token? @@ -34,7 +34,7 @@ final class CheckoutV3ViewController: checkout: CheckoutV3.Request, configuration: CheckoutV3Configuration, requestHandler: @escaping URLRequestHandler, - completion: @escaping (_ result: CheckoutResult) -> Void + completion: @escaping (_ result: CheckoutV3Result) -> Void ) { self.checkout = checkout self.configuration = configuration @@ -235,13 +235,8 @@ final class CheckoutV3ViewController: switch result { case .success(let response): self.dismiss(animated: true) { - self.completion(.success(value: .singleUseCard( - authToken: response.authToken, - cardValidUntil: response.cardValidUntil, - details: response.paymentDetails.virtualCard - ))) + self.handleConfirmationResponse(response) } - case .failure(let error): self.dismiss(animated: true) { self.completion(.cancelled(reason: .networkError(error))) } } @@ -249,6 +244,71 @@ final class CheckoutV3ViewController: self.currentTask?.resume() } + private func handleConfirmationResponse(_ response: ConfirmationV3.Response) { + guard let token = self.token else { + return + } + let cancellationRequest = self.createCancellationRequest() + let updateRequest = self.createMerchantReferenceUpdateRequest() + + let result = CheckoutV3.ResultData( + cancellation: { [cancellationRequest, requestHandler] cancellationCompletion in + let task = ApiV3.request( + requestHandler, + cancellationRequest, + completion: cancellationCompletion) + task.resume() + }, + merchantReferenceUpdate: { [updateRequest, requestHandler] merchantReference, updateCompletion in + var request = updateRequest + // Serialize the merchant reference now that it is known + request.httpBody = try? JSONEncoder().encode( + CheckoutV3.MerchantReferenceUpdate( + authToken: response.authToken, + token: token, + merchantReference: merchantReference + ) + ) + + let task = ApiV3.request(requestHandler, request, completion: updateCompletion) + task.resume() + }, + cardValidUntil: response.cardValidUntil, + cardDetails: response.paymentDetails.virtualCard + ) + + self.completion(.success(data: result)) + } + + private func createMerchantReferenceUpdateRequest() -> URLRequest { + var request = ApiV3.request(from: configuration.v3CheckoutUrl) + request.httpMethod = "PUT" + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + return request + } + + private func createCancellationRequest() -> URLRequest { + guard + let token = self.token, + let singleUseCardToken = self.singleUseCardToken, + let ppaConfirmToken = self.ppaConfirmToken, + let data = try? JSONEncoder().encode(CancellationV3.Request( + token: token, + ppaConfirmToken: ppaConfirmToken, + singleUseCardToken: singleUseCardToken + )) + else { + fatalError("`token` or `singleUseToken` was nil") + } + var request = ApiV3.request(from: self.configuration.v3CheckoutCancellationUrl) + request.httpMethod = "POST" + request.httpBody = data + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + return request + } + private func createCheckoutRequest() -> URLRequest { let data = try! JSONEncoder().encode(self.checkout) // swiftlint:disable:this force_try From ad124288b5da0fe75e37d4e8e484995474f5d688 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Wed, 14 Jul 2021 16:27:41 +1000 Subject: [PATCH 28/81] Remove unused types --- Sources/Afterpay/Checkout/CancellationV3.swift | 8 -------- 1 file changed, 8 deletions(-) diff --git a/Sources/Afterpay/Checkout/CancellationV3.swift b/Sources/Afterpay/Checkout/CancellationV3.swift index 61ca20d7..31b00b6f 100644 --- a/Sources/Afterpay/Checkout/CancellationV3.swift +++ b/Sources/Afterpay/Checkout/CancellationV3.swift @@ -16,12 +16,4 @@ enum CancellationV3 { let singleUseCardToken: String } - struct Response: Decodable, CheckoutV3Cancellation { - let authToken: String - } - -} - -public protocol CheckoutV3Cancellation { - } From 1d4184aad848cc8368be85435db268133cf2ca37 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Wed, 14 Jul 2021 16:35:26 +1000 Subject: [PATCH 29/81] Use same ID for display in Example controller --- .../Example/Purchase/SingleUseCardResultViewController.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Example/Example/Purchase/SingleUseCardResultViewController.swift b/Example/Example/Purchase/SingleUseCardResultViewController.swift index 2a27ccab..d260de37 100644 --- a/Example/Example/Purchase/SingleUseCardResultViewController.swift +++ b/Example/Example/Purchase/SingleUseCardResultViewController.swift @@ -148,9 +148,9 @@ final class SingleUseCardResultViewController: UIViewController { @objc func update() { updateButton.setTitle("Updating ...", for: .normal) let newId = UUID().uuidString - self.merchantReferenceUpdateClosure(UUID().uuidString) { [weak self] result in + self.merchantReferenceUpdateClosure(newId) { [weak self] result in switch result { - case .success: + case .success: // This endpoint returns a 204, so no response body self?.updateButton.setTitle("Merchant reference updated!", for: .normal) self?.labels.last?.text = "Merchant reference: \(newId)" case .failure(let error): From e89277809e769478fecc77077cc1b3eec8e71655 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Wed, 14 Jul 2021 17:28:24 +1000 Subject: [PATCH 30/81] Add Checkout support for shipping and tax amounts --- Afterpay.xcodeproj/project.pbxproj | 4 ++++ .../Purchase/PurchaseFlowController.swift | 2 +- Sources/Afterpay/AfterpayV3.swift | 6 ++--- Sources/Afterpay/Checkout/CheckoutV3.swift | 15 +++++++++++-- Sources/Afterpay/Model/OrderTotal.swift | 22 +++++++++++++++++++ 5 files changed, 43 insertions(+), 6 deletions(-) create mode 100644 Sources/Afterpay/Model/OrderTotal.swift diff --git a/Afterpay.xcodeproj/project.pbxproj b/Afterpay.xcodeproj/project.pbxproj index f4e2a9da..15989939 100644 --- a/Afterpay.xcodeproj/project.pbxproj +++ b/Afterpay.xcodeproj/project.pbxproj @@ -67,6 +67,7 @@ 83F7DEEC269E62DA00F9FB75 /* Consumer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEEA269E624600F9FB75 /* Consumer.swift */; }; 83F7DEF4269E94CE00F9FB75 /* CancellationV3.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEF3269E94CE00F9FB75 /* CancellationV3.swift */; }; 83F7DEF6269EB4CA00F9FB75 /* CheckoutV3Result.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEF5269EB4CA00F9FB75 /* CheckoutV3Result.swift */; }; + 83F7DEF8269EC4DB00F9FB75 /* OrderTotal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEF7269EC4DB00F9FB75 /* OrderTotal.swift */; }; 946388FE24DD077F00A1227A /* InfoWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 946388FD24DD077F00A1227A /* InfoWebViewController.swift */; }; /* End PBXBuildFile section */ @@ -155,6 +156,7 @@ 83F7DEEA269E624600F9FB75 /* Consumer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Consumer.swift; sourceTree = ""; }; 83F7DEF3269E94CE00F9FB75 /* CancellationV3.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CancellationV3.swift; sourceTree = ""; }; 83F7DEF5269EB4CA00F9FB75 /* CheckoutV3Result.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckoutV3Result.swift; sourceTree = ""; }; + 83F7DEF7269EC4DB00F9FB75 /* OrderTotal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderTotal.swift; sourceTree = ""; }; 946388FD24DD077F00A1227A /* InfoWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoWebViewController.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -333,6 +335,7 @@ 661D4322257DF1CB00ACCDE1 /* ShippingOption.swift */, 157C65AE25D23E8F00115149 /* Version.swift */, 83F7DEEA269E624600F9FB75 /* Consumer.swift */, + 83F7DEF7269EC4DB00F9FB75 /* OrderTotal.swift */, ); path = Model; sourceTree = ""; @@ -585,6 +588,7 @@ 557511C326489F090040CC51 /* SVG+Source.swift in Sources */, 662A3AED24A999A500EFD826 /* CheckoutResult.swift in Sources */, 551BEDF825F9B95C00FDF9EE /* WidgetView.swift in Sources */, + 83F7DEF8269EC4DB00F9FB75 /* OrderTotal.swift in Sources */, 1522246025C925E5004B9CE5 /* Environment.swift in Sources */, 66996F672580A5BE0061C365 /* CheckoutV2Completion.swift in Sources */, 157C65AF25D23E8F00115149 /* Version.swift in Sources */, diff --git a/Example/Example/Purchase/PurchaseFlowController.swift b/Example/Example/Purchase/PurchaseFlowController.swift index 69e0b32f..6d189086 100644 --- a/Example/Example/Purchase/PurchaseFlowController.swift +++ b/Example/Example/Purchase/PurchaseFlowController.swift @@ -138,7 +138,7 @@ final class PurchaseFlowController: UIViewController { Afterpay.presentCheckoutV3Modally( over: ownedNavigationController, consumer: consumer, - total: total, + orderTotal: OrderTotal(shipping: 24.99, tax: 9.999, subtotal: total), requestHandler: APIClient.live.session.dataTask ) { result in switch result { diff --git a/Sources/Afterpay/AfterpayV3.swift b/Sources/Afterpay/AfterpayV3.swift index 3596a612..bb7276c7 100644 --- a/Sources/Afterpay/AfterpayV3.swift +++ b/Sources/Afterpay/AfterpayV3.swift @@ -50,7 +50,7 @@ public func fetchMerchantConfiguration( /// The Afterpay Checkout View Controller will be presented modally over this view controller /// or it's closest parent that is able to handle the presentation. /// - consumer: The personal details of the customer. -/// - total: The order total, represented as an unrounded `Decimal`. +/// - orderTotal: The order total: `Decimal`s representing the subtotal, tax and shipping. /// - items: An optional array of items that will be added to the checkout. /// These are not used as the basis of the order `total`. /// - animated: Pass `true` to animate the presentation; otherwise, pass false. @@ -61,7 +61,7 @@ public func fetchMerchantConfiguration( public func presentCheckoutV3Modally( over viewController: UIViewController, consumer: CheckoutV3Consumer, - total: Decimal, + orderTotal: OrderTotal, items: [CheckoutV3Item] = [], animated: Bool = true, configuration: CheckoutV3Configuration? = getV3Configuration(), @@ -76,7 +76,7 @@ public func presentCheckoutV3Modally( } var viewControllerToPresent: UIViewController = CheckoutV3ViewController( - checkout: CheckoutV3.Request(consumer: consumer, amount: total, configuration: configuration), + checkout: CheckoutV3.Request(consumer: consumer, orderTotal: orderTotal, configuration: configuration), configuration: configuration, requestHandler: requestHandler, completion: completion diff --git a/Sources/Afterpay/Checkout/CheckoutV3.swift b/Sources/Afterpay/Checkout/CheckoutV3.swift index e221ce60..69e38b44 100644 --- a/Sources/Afterpay/Checkout/CheckoutV3.swift +++ b/Sources/Afterpay/Checkout/CheckoutV3.swift @@ -23,6 +23,9 @@ enum CheckoutV3 { let merchantPublicKey: String let amount: Money + let shippingAmount: Money + let taxAmount: Money + let items: [Item] let consumer: Consumer let merchant: Merchant @@ -31,7 +34,7 @@ enum CheckoutV3 { init( consumer: CheckoutV3Consumer, - amount: Decimal, + orderTotal: OrderTotal, items: [CheckoutV3Item] = [], configuration: CheckoutV3Configuration ) { @@ -40,7 +43,15 @@ enum CheckoutV3 { self.merchantPublicKey = configuration.merchantPublicKey self.amount = Money( - amount: configuration.region.formatted(currency: amount), + amount: configuration.region.formatted(currency: orderTotal.subtotal), + currency: configuration.region.currencyCode + ) + self.shippingAmount = Money( + amount: configuration.region.formatted(currency: orderTotal.shipping), + currency: configuration.region.currencyCode + ) + self.taxAmount = Money( + amount: configuration.region.formatted(currency: orderTotal.tax), currency: configuration.region.currencyCode ) self.items = items.map { Item($0, configuration.region) } diff --git a/Sources/Afterpay/Model/OrderTotal.swift b/Sources/Afterpay/Model/OrderTotal.swift new file mode 100644 index 00000000..83e11d5a --- /dev/null +++ b/Sources/Afterpay/Model/OrderTotal.swift @@ -0,0 +1,22 @@ +// +// Consumer.swift +// Afterpay +// +// Created by Chris Kolbu on 14/7/21. +// Copyright © 2021 Afterpay. All rights reserved. +// + +import Foundation + +public struct OrderTotal { + + public var shipping: Decimal + public var tax: Decimal + public var subtotal: Decimal + + public init(shipping: Decimal, tax: Decimal, subtotal: Decimal) { + self.shipping = shipping + self.tax = tax + self.subtotal = subtotal + } +} From aa37c418b13774ccac898f72483f3cd2c17f0b07 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Wed, 14 Jul 2021 17:28:58 +1000 Subject: [PATCH 31/81] Conform V3Config currency formatter to ISO 4217 --- Sources/Afterpay/AfterpayV3.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Sources/Afterpay/AfterpayV3.swift b/Sources/Afterpay/AfterpayV3.swift index bb7276c7..cdecb7ac 100644 --- a/Sources/Afterpay/AfterpayV3.swift +++ b/Sources/Afterpay/AfterpayV3.swift @@ -184,10 +184,16 @@ public struct CheckoutV3Configuration { } } - private static let formatter = NumberFormatter() + private static var formatter: NumberFormatter = { + var formatter = NumberFormatter() + formatter.numberStyle = .decimal + // ISO 4217 specifies 2 decimal points + formatter.maximumFractionDigits = 2 + formatter.roundingMode = .halfEven // Banker's rounding + return formatter + }() func formatted(currency: Decimal) -> String { - Self.formatter.numberStyle = .decimal return Self.formatter.string(from: currency as NSDecimalNumber)! } } From 9ae69dcff3763e10a1f8cd7b3f67fcec7f8f63be Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Thu, 15 Jul 2021 06:39:51 +1000 Subject: [PATCH 32/81] Add test for Region formatter Decimal -> String --- AfterpayTests/CurrencyFormatterTests.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/AfterpayTests/CurrencyFormatterTests.swift b/AfterpayTests/CurrencyFormatterTests.swift index 32862570..f033568f 100644 --- a/AfterpayTests/CurrencyFormatterTests.swift +++ b/AfterpayTests/CurrencyFormatterTests.swift @@ -101,4 +101,15 @@ class CurrencyFormatterTests: XCTestCase { XCTAssertEqual(usdFormatter.string(from: 120), "$120.00") } + func testOrderTotalDecimalToStringPerformsRounding() { + let region = CheckoutV3Configuration.Region.US + + XCTAssertEqual(region.formatted(currency: 9), "9") + XCTAssertEqual(region.formatted(currency: 9.9), "9.9") + XCTAssertEqual(region.formatted(currency: 9.99), "9.99") + XCTAssertEqual(region.formatted(currency: 9.999), "10") + XCTAssertEqual(region.formatted(currency: 9.995), "9.99") + XCTAssertEqual(region.formatted(currency: 9.996), "10") + } + } From da139a43e9504f7462118817bdce8cd17c6c9435 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Thu, 15 Jul 2021 06:40:32 +1000 Subject: [PATCH 33/81] Make V3 Request shippingAmount and taxAmount optional --- .../Purchase/PurchaseFlowController.swift | 9 ++---- .../SingleUseCardResultViewController.swift | 15 ++++------ Sources/Afterpay/Checkout/CheckoutV3.swift | 30 ++++++++++++------- Sources/Afterpay/Model/OrderTotal.swift | 13 +++++--- 4 files changed, 36 insertions(+), 31 deletions(-) diff --git a/Example/Example/Purchase/PurchaseFlowController.swift b/Example/Example/Purchase/PurchaseFlowController.swift index 6d189086..a4e55ef5 100644 --- a/Example/Example/Purchase/PurchaseFlowController.swift +++ b/Example/Example/Purchase/PurchaseFlowController.swift @@ -138,17 +138,12 @@ final class PurchaseFlowController: UIViewController { Afterpay.presentCheckoutV3Modally( over: ownedNavigationController, consumer: consumer, - orderTotal: OrderTotal(shipping: 24.99, tax: 9.999, subtotal: total), + orderTotal: OrderTotal(subtotal: total, shipping: nil, tax: 9.999), requestHandler: APIClient.live.session.dataTask ) { result in switch result { case .success(let data): - let controller = SingleUseCardResultViewController( - details: data.cardDetails, - authorizationExpiration: data.cardValidUntil, - cancellationClosure: data.cancellation, - merchantReferenceUpdateClosure: data.merchantReferenceUpdate - ) + let controller = SingleUseCardResultViewController(data: data) navigationController.pushViewController(controller, animated: true) case .cancelled(let reason): logicController.cancelled(with: reason) diff --git a/Example/Example/Purchase/SingleUseCardResultViewController.swift b/Example/Example/Purchase/SingleUseCardResultViewController.swift index d260de37..c81088f5 100644 --- a/Example/Example/Purchase/SingleUseCardResultViewController.swift +++ b/Example/Example/Purchase/SingleUseCardResultViewController.swift @@ -93,16 +93,11 @@ final class SingleUseCardResultViewController: UIViewController { // MARK: - Initializer - init( - details: CardDetails, - authorizationExpiration: Date?, - cancellationClosure: @escaping CancellationClosure, - merchantReferenceUpdateClosure: @escaping MerchantReferenceUpdateClosure - ) { - self.details = details - self.authorizationExpiration = authorizationExpiration - self.cancellationClosure = cancellationClosure - self.merchantReferenceUpdateClosure = merchantReferenceUpdateClosure + init(data: CheckoutV3Data) { + self.details = data.cardDetails + self.authorizationExpiration = data.cardValidUntil + self.cancellationClosure = data.cancellation + self.merchantReferenceUpdateClosure = data.merchantReferenceUpdate super.init(nibName: nil, bundle: nil) diff --git a/Sources/Afterpay/Checkout/CheckoutV3.swift b/Sources/Afterpay/Checkout/CheckoutV3.swift index 69e38b44..e787dea7 100644 --- a/Sources/Afterpay/Checkout/CheckoutV3.swift +++ b/Sources/Afterpay/Checkout/CheckoutV3.swift @@ -23,8 +23,8 @@ enum CheckoutV3 { let merchantPublicKey: String let amount: Money - let shippingAmount: Money - let taxAmount: Money + let shippingAmount: Money? + let taxAmount: Money? let items: [Item] let consumer: Consumer @@ -46,14 +46,24 @@ enum CheckoutV3 { amount: configuration.region.formatted(currency: orderTotal.subtotal), currency: configuration.region.currencyCode ) - self.shippingAmount = Money( - amount: configuration.region.formatted(currency: orderTotal.shipping), - currency: configuration.region.currencyCode - ) - self.taxAmount = Money( - amount: configuration.region.formatted(currency: orderTotal.tax), - currency: configuration.region.currencyCode - ) + + if let shipping = orderTotal.shipping { + self.shippingAmount = Money( + amount: configuration.region.formatted(currency: shipping), + currency: configuration.region.currencyCode + ) + } else { + self.shippingAmount = nil + } + if let tax = orderTotal.tax { + self.taxAmount = Money( + amount: configuration.region.formatted(currency: tax), + currency: configuration.region.currencyCode + ) + } else { + self.taxAmount = nil + } + self.items = items.map { Item($0, configuration.region) } self.consumer = Consumer(consumer) diff --git a/Sources/Afterpay/Model/OrderTotal.swift b/Sources/Afterpay/Model/OrderTotal.swift index 83e11d5a..dd2ad80e 100644 --- a/Sources/Afterpay/Model/OrderTotal.swift +++ b/Sources/Afterpay/Model/OrderTotal.swift @@ -8,15 +8,20 @@ import Foundation +/// The order total. Each property will be transformed to a `Money` object by +/// conforming the amount to ISO 4217 by: +/// - Rounding to 2 decimals using banker's rounding. +/// - Including the currency code as provided by `CheckoutV3Configuration.Region`. public struct OrderTotal { - public var shipping: Decimal - public var tax: Decimal public var subtotal: Decimal + public var shipping: Decimal? + public var tax: Decimal? - public init(shipping: Decimal, tax: Decimal, subtotal: Decimal) { + public init(subtotal: Decimal, shipping: Decimal? = nil, tax: Decimal? = nil) { + self.subtotal = subtotal self.shipping = shipping self.tax = tax - self.subtotal = subtotal } + } From 67e0326e630ee8996c80cad884322516e8caac9e Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Thu, 15 Jul 2021 06:41:01 +1000 Subject: [PATCH 34/81] Tweak formatting and documentation --- Sources/Afterpay/AfterpayV3.swift | 8 ++++---- Sources/Afterpay/Checkout/CheckoutV3Result.swift | 5 +++++ Sources/Afterpay/Checkout/CheckoutV3ViewController.swift | 3 ++- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/Sources/Afterpay/AfterpayV3.swift b/Sources/Afterpay/AfterpayV3.swift index cdecb7ac..058a528a 100644 --- a/Sources/Afterpay/AfterpayV3.swift +++ b/Sources/Afterpay/AfterpayV3.swift @@ -49,13 +49,13 @@ public func fetchMerchantConfiguration( /// - viewController: The viewController on which `UIViewController.present` will be called. /// The Afterpay Checkout View Controller will be presented modally over this view controller /// or it's closest parent that is able to handle the presentation. -/// - consumer: The personal details of the customer. +/// - consumer: The personal details of the customer, including shipping and billing addresses. /// - orderTotal: The order total: `Decimal`s representing the subtotal, tax and shipping. /// - items: An optional array of items that will be added to the checkout. -/// These are not used as the basis of the order `total`. +/// These are not used to calculate the total amount due. /// - animated: Pass `true` to animate the presentation; otherwise, pass false. /// - configuration: A collection of options and values required to interact with the Afterpay API. -/// - requestHandler: A function that takes a `URLRequest` and a closure to handle the result. +/// - requestHandler: A function that takes a `URLRequest` and a closure to handle the result, /// and returns a `URLSessionDataTask`. Defaults to `URLSession.shared.dataTask`. /// - completion: The result of the user's completion (a success or cancellation). public func presentCheckoutV3Modally( @@ -174,7 +174,7 @@ public struct CheckoutV3Configuration { var locale: Locale { switch self { - case .US: return Locale(identifier: "en_US") + case .US: return Locales.unitedStates } } diff --git a/Sources/Afterpay/Checkout/CheckoutV3Result.swift b/Sources/Afterpay/Checkout/CheckoutV3Result.swift index 4393ef9f..0b2c250d 100644 --- a/Sources/Afterpay/Checkout/CheckoutV3Result.swift +++ b/Sources/Afterpay/Checkout/CheckoutV3Result.swift @@ -8,10 +8,15 @@ import Foundation +/// Data returned from a successful V3 checkout public protocol CheckoutV3Data { + /// A closure that will cancel the virtual card generated by Afterpay. var cancellation: CancellationClosure { get } + /// A closure that will update the merchant reference on the checkout. var merchantReferenceUpdate: MerchantReferenceUpdateClosure { get } + /// The time before which an authorization needs to be made on the virtual card. var cardValidUntil: Date? { get } + /// The virtual card details var cardDetails: CardDetails { get } } diff --git a/Sources/Afterpay/Checkout/CheckoutV3ViewController.swift b/Sources/Afterpay/Checkout/CheckoutV3ViewController.swift index 323d156d..a3305fae 100644 --- a/Sources/Afterpay/Checkout/CheckoutV3ViewController.swift +++ b/Sources/Afterpay/Checkout/CheckoutV3ViewController.swift @@ -256,7 +256,8 @@ final class CheckoutV3ViewController: let task = ApiV3.request( requestHandler, cancellationRequest, - completion: cancellationCompletion) + completion: cancellationCompletion + ) task.resume() }, merchantReferenceUpdate: { [updateRequest, requestHandler] merchantReference, updateCompletion in From d9785e998f47660b98727241c2f5d5188cc2c34a Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Thu, 15 Jul 2021 07:26:25 +1000 Subject: [PATCH 35/81] Add items to Example app checkout --- Example/Example/Purchase/CartDisplay.swift | 2 + Example/Example/Purchase/Product.swift | 20 +++- Example/Example/Purchase/ProductCell.swift | 2 +- .../Purchase/PurchaseFlowController.swift | 5 +- .../Purchase/PurchaseLogicController.swift | 13 ++- .../SingleUseCardResultViewController.swift | 23 ++-- Sources/Afterpay/AfterpayV3.swift | 102 ++++++++++++++++-- Sources/Afterpay/Checkout/CheckoutV3.swift | 22 ++-- .../Afterpay/Checkout/CheckoutV3Result.swift | 16 +-- .../Checkout/CheckoutV3ViewController.swift | 37 ++----- 10 files changed, 166 insertions(+), 76 deletions(-) diff --git a/Example/Example/Purchase/CartDisplay.swift b/Example/Example/Purchase/CartDisplay.swift index 4f092bdd..60074cfd 100644 --- a/Example/Example/Purchase/CartDisplay.swift +++ b/Example/Example/Purchase/CartDisplay.swift @@ -13,6 +13,7 @@ struct CartDisplay { let products: [ProductDisplay] let message: String? + let total: Decimal let displayTotal: String let payEnabled: Bool let checkoutV2Options: CheckoutV2Options @@ -30,6 +31,7 @@ struct CartDisplay { self.message = products.isEmpty ? "Please add some items to your cart." : nil self.payEnabled = products.isEmpty ? false : true + self.total = total let formatter = CurrencyFormatter(currencyCode: currencyCode) self.displayTotal = formatter.displayString(from: total) self.expressCheckout = expressCheckout diff --git a/Example/Example/Purchase/Product.swift b/Example/Example/Purchase/Product.swift index 9d2b9451..e22b484e 100644 --- a/Example/Example/Purchase/Product.swift +++ b/Example/Example/Purchase/Product.swift @@ -6,6 +6,7 @@ // Copyright © 2020 Afterpay. All rights reserved. // +import Afterpay import Foundation struct Product { @@ -38,14 +39,17 @@ extension Collection where Element == Product { } struct ProductDisplay { - + private let product: Product + let quantity: UInt let id: UUID let title: String let subtitle: String let displayPrice: String - let quantity: String + let displayQuantity: String init(product: Product, quantity: UInt, currencyCode: String) { + self.product = product + self.quantity = quantity id = product.id title = product.name subtitle = product.description @@ -53,7 +57,7 @@ struct ProductDisplay { let formatter = CurrencyFormatter(currencyCode: currencyCode) displayPrice = formatter.displayString(from: product.price) - self.quantity = "\(quantity)" + self.displayQuantity = "\(quantity)" } static func products( @@ -71,3 +75,13 @@ struct ProductDisplay { } } + +extension ProductDisplay: CheckoutV3Item { + var name: String { title } + var price: Decimal { product.price } + var sku: String? { id.uuidString } + var pageUrl: URL? { nil } + var imageUrl: URL? { nil } + var categories: [[String]]? { nil } + var estimatedShipmentDate: String? { nil } +} diff --git a/Example/Example/Purchase/ProductCell.swift b/Example/Example/Purchase/ProductCell.swift index 239faec2..fdea5a06 100644 --- a/Example/Example/Purchase/ProductCell.swift +++ b/Example/Example/Purchase/ProductCell.swift @@ -68,7 +68,7 @@ final class ProductCell: UITableViewCell { titleLabel.text = product.title priceLabel.text = product.displayPrice subtitleLabel.text = product.subtitle - quantityLabel.text = product.quantity + quantityLabel.text = product.displayQuantity self.eventHandler = eventHandler plusButton.isHidden = eventHandler == nil diff --git a/Example/Example/Purchase/PurchaseFlowController.swift b/Example/Example/Purchase/PurchaseFlowController.swift index a4e55ef5..c96e0871 100644 --- a/Example/Example/Purchase/PurchaseFlowController.swift +++ b/Example/Example/Purchase/PurchaseFlowController.swift @@ -134,11 +134,12 @@ final class PurchaseFlowController: UIViewController { } } - case .showAfterpayCheckoutV3(let consumer, let total): + case .showAfterpayCheckoutV3(let consumer, let cart): Afterpay.presentCheckoutV3Modally( over: ownedNavigationController, consumer: consumer, - orderTotal: OrderTotal(subtotal: total, shipping: nil, tax: 9.999), + orderTotal: OrderTotal(subtotal: cart.total, shipping: nil, tax: 9.999), + items: cart.products, requestHandler: APIClient.live.session.dataTask ) { result in switch result { diff --git a/Example/Example/Purchase/PurchaseLogicController.swift b/Example/Example/Purchase/PurchaseLogicController.swift index 7644f0fc..51023132 100644 --- a/Example/Example/Purchase/PurchaseLogicController.swift +++ b/Example/Example/Purchase/PurchaseLogicController.swift @@ -18,7 +18,7 @@ final class PurchaseLogicController { case showAfterpayCheckoutV1(checkoutURL: URL) case showAfterpayCheckoutV2(CheckoutV2Options) - case showAfterpayCheckoutV3(consumer: Consumer, total: Decimal) + case showAfterpayCheckoutV3(consumer: Consumer, cart: CartDisplay) case provideCheckoutTokenResult(TokenResult) case provideShippingOptionsResult(ShippingOptionsResult) @@ -97,16 +97,19 @@ final class PurchaseLogicController { expressCheckout.toggle() } - func viewCart() { + func buildCart() -> CartDisplay { let productsInCart = productDisplayModels.filter { (quantities[$0.id] ?? 0) > 0 } - let cart = CartDisplay( + return CartDisplay( products: productsInCart, total: total, currencyCode: currencyCode, expressCheckout: expressCheckout, initialCheckoutOptions: checkoutV2Options ) - commandHandler(.showCart(cart)) + } + + func viewCart() { + commandHandler(.showCart(buildCart())) } func payWithAfterpay() { @@ -120,7 +123,7 @@ final class PurchaseLogicController { func payWithAfterpayV3() { commandHandler(.showAfterpayCheckoutV3( consumer: Consumer(email: email), - total: total + cart: buildCart() )) } diff --git a/Example/Example/Purchase/SingleUseCardResultViewController.swift b/Example/Example/Purchase/SingleUseCardResultViewController.swift index c81088f5..ffad3f84 100644 --- a/Example/Example/Purchase/SingleUseCardResultViewController.swift +++ b/Example/Example/Purchase/SingleUseCardResultViewController.swift @@ -13,10 +13,7 @@ import UIKit final class SingleUseCardResultViewController: UIViewController { // MARK: - Properties - private let details: CardDetails - private let authorizationExpiration: Date? - private(set) var cancellationClosure: CancellationClosure? - private let merchantReferenceUpdateClosure: MerchantReferenceUpdateClosure + private let data: CheckoutV3Data private let vStack: UIStackView = { let stackView = UIStackView() @@ -28,10 +25,10 @@ final class SingleUseCardResultViewController: UIViewController { private lazy var labels: [UILabel] = { let strings = [ - "Card number: \(details.cardNumber)", - "CVC: \(details.cvc)", - "Expiration: \(details.expiryMonth)/\(details.expiryYear)", - "Virtual card expiry: \(authorizationExpiration?.shortDuration ?? "Unavailable")", + "Card number: \(data.cardDetails.cardNumber)", + "CVC: \(data.cardDetails.cvc)", + "Expiration: \(data.cardDetails.expiryMonth)/\(data.cardDetails.expiryYear)", + "Virtual card expiry: \(data.cardValidUntil?.shortDuration ?? "Unavailable")", "Merchant reference: ", ] @@ -94,10 +91,7 @@ final class SingleUseCardResultViewController: UIViewController { // MARK: - Initializer init(data: CheckoutV3Data) { - self.details = data.cardDetails - self.authorizationExpiration = data.cardValidUntil - self.cancellationClosure = data.cancellation - self.merchantReferenceUpdateClosure = data.merchantReferenceUpdate + self.data = data super.init(nibName: nil, bundle: nil) @@ -125,14 +119,13 @@ final class SingleUseCardResultViewController: UIViewController { // MARK: - Actions @objc func cancel() { - self.cancellationClosure? { [weak self] result in + Afterpay.cancelVirtualCard(tokens: data.tokens) { [weak self] result in switch result { case .success: UIView.animate(withDuration: 0.3, animations: { self?.cancellationButton.isEnabled = false self?.cancellationButton.backgroundColor = .systemGray }) - self?.cancellationClosure = nil case .failure(let error): let alert = AlertFactory.alert(for: error.localizedDescription) self?.present(alert, animated: true) @@ -143,7 +136,7 @@ final class SingleUseCardResultViewController: UIViewController { @objc func update() { updateButton.setTitle("Updating ...", for: .normal) let newId = UUID().uuidString - self.merchantReferenceUpdateClosure(newId) { [weak self] result in + Afterpay.updateMerchantReference(with: newId, tokens: data.tokens) { [weak self] result in switch result { case .success: // This endpoint returns a 204, so no response body self?.updateButton.setTitle("Merchant reference updated!", for: .normal) diff --git a/Sources/Afterpay/AfterpayV3.swift b/Sources/Afterpay/AfterpayV3.swift index 058a528a..5e643176 100644 --- a/Sources/Afterpay/AfterpayV3.swift +++ b/Sources/Afterpay/AfterpayV3.swift @@ -9,13 +9,94 @@ import Foundation import UIKit -// MARK: - Checkout +// MARK: - Update merchant reference + +/// Updates Afterpay's merchant reference for the transaction represented by the provided `tokens`. +/// - Parameters: +/// - merchantReference: A unique ID identifying the transaction. +/// - tokens: The set of tokens returned after a successful call to `Afterpay.presentCheckoutV3Modally`. +/// - configuration: A collection of options and values required to interact with the Afterpay API. +/// - requestHandler: A function that takes a `URLRequest` and a closure to handle the result. +/// - completion: The result of the user's completion (a success or cancellation). Returns on the main thread. +public func updateMerchantReference( + with merchantReference: String, + tokens: CheckoutV3Tokens, + configuration: CheckoutV3Configuration? = getV3Configuration(), + requestHandler: @escaping URLRequestHandler = URLSession.shared.dataTask, + completion: @escaping (_ result: Result) -> Void +) { + guard let configuration = configuration else { + return assertionFailure( + "For updateMerchantReference to function you must provide a `configuration` object via either " + + "`Afterpay.updateMerchantReference` or `Afterpay.setV3Configuration`" + ) + } + do { + var request = ApiV3.request(from: configuration.v3CheckoutUrl) + request.httpMethod = "PUT" + request.httpBody = try JSONEncoder().encode( + CheckoutV3.MerchantReferenceUpdate( + token: tokens.token, + singleUseCardToken: tokens.singleUseCardToken, + ppaConfirmToken: tokens.ppaConfirmToken, + merchantReference: merchantReference + ) + ) + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let task = ApiV3.request(requestHandler, request, completion: completion) + task.resume() + } catch { + completion(.failure(error)) + } +} + +// MARK: - Cancel virtual card + +/// Cancels the virtual card represented by the provided `tokens`. +/// - Parameters: +/// - tokens: The set of tokens returned after a successful call to `Afterpay.presentCheckoutV3Modally`. +/// - configuration: A collection of options and values required to interact with the Afterpay API. +/// - requestHandler: A function that takes a `URLRequest` and a closure to handle the result. +/// - completion: The result of the user's completion (a success or cancellation). Returns on the main thread. +public func cancelVirtualCard( + tokens: CheckoutV3Tokens, + configuration: CheckoutV3Configuration? = getV3Configuration(), + requestHandler: @escaping URLRequestHandler = URLSession.shared.dataTask, + completion: @escaping (_ result: Result) -> Void +) { + guard let configuration = configuration else { + return assertionFailure( + "For cancelVirtualCard to function you must provide a `configuration` object via either " + + "`Afterpay.cancelVirtualCard` or `Afterpay.setV3Configuration`" + ) + } + do { + var request = ApiV3.request(from: configuration.v3CheckoutCancellationUrl) + request.httpMethod = "POST" + request.httpBody = try JSONEncoder().encode(CancellationV3.Request( + token: tokens.token, + ppaConfirmToken: tokens.ppaConfirmToken, + singleUseCardToken: tokens.singleUseCardToken + )) + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let task = ApiV3.request(requestHandler, request, completion: completion) + task.resume() + } catch { + completion(.failure(error)) + } +} + +// MARK: - Fetch merchant configuration /// Returns the merchant configuration object, representing the merchant's applicable payment limits. /// - Parameters: /// - configuration: A collection of options and values required to interact with the Afterpay API. /// - requestHandler: A function that takes a `URLRequest` and a closure to handle the result. -/// - completion: The result of the user's completion (a success or cancellation). +/// - completion: The result of the user's completion (a success or cancellation). Returns on the main thread. public func fetchMerchantConfiguration( configuration: CheckoutV3Configuration? = getV3Configuration(), requestHandler: @escaping URLRequestHandler = URLSession.shared.dataTask, @@ -23,7 +104,7 @@ public func fetchMerchantConfiguration( ) { guard let configuration = configuration else { return assertionFailure( - "For fetchMerchantConfiguration to function you must set `configuration` via either " + "For fetchMerchantConfiguration to function you must provide a `configuration` object via either " + "`Afterpay.fetchMerchantConfiguration` or `Afterpay.setV3Configuration`" ) } @@ -44,6 +125,8 @@ public func fetchMerchantConfiguration( task.resume() } +// MARK: - Checkout + /// Present Afterpay Checkout modally over the specified view controller. This method /// - Parameters: /// - viewController: The viewController on which `UIViewController.present` will be called. @@ -57,7 +140,7 @@ public func fetchMerchantConfiguration( /// - configuration: A collection of options and values required to interact with the Afterpay API. /// - requestHandler: A function that takes a `URLRequest` and a closure to handle the result, /// and returns a `URLSessionDataTask`. Defaults to `URLSession.shared.dataTask`. -/// - completion: The result of the user's completion (a success or cancellation). +/// - completion: The result of the user's completion (a success or cancellation). Returns on the main thread. public func presentCheckoutV3Modally( over viewController: UIViewController, consumer: CheckoutV3Consumer, @@ -70,13 +153,18 @@ public func presentCheckoutV3Modally( ) { guard let configuration = configuration else { return assertionFailure( - "For checkout to function you must set `configuration` via either " + "For presentCheckoutV3Modally to function you must provide a `configuration` object via either " + "`Afterpay.presentCheckoutV3Modally` or `Afterpay.setV3Configuration`" ) } var viewControllerToPresent: UIViewController = CheckoutV3ViewController( - checkout: CheckoutV3.Request(consumer: consumer, orderTotal: orderTotal, configuration: configuration), + checkout: CheckoutV3.Request( + consumer: consumer, + orderTotal: orderTotal, + items: items, + configuration: configuration + ), configuration: configuration, requestHandler: requestHandler, completion: completion @@ -240,7 +328,7 @@ public protocol CheckoutV3Item { /// Product name. Limited to 255 characters. var name: String { get } /// The quantity of the item, stored as a signed 32-bit integer. - var quantity: Int { get } + var quantity: UInt { get } /// The unit price of the individual item. Must be a positive value. var price: Decimal { get } /// Product SKU. Limited to 128 characters. diff --git a/Sources/Afterpay/Checkout/CheckoutV3.swift b/Sources/Afterpay/Checkout/CheckoutV3.swift index e787dea7..7521ee83 100644 --- a/Sources/Afterpay/Checkout/CheckoutV3.swift +++ b/Sources/Afterpay/Checkout/CheckoutV3.swift @@ -8,12 +8,6 @@ import Foundation -/// A closure that will cancel the virtual card generated by Afterpay -public typealias CancellationClosure = (@escaping (Result) -> Void) -> Void -/// A closure that will update the merchant reference on the checkout object -/// initially created via `Afterpay.presentCheckoutV3Modally` -public typealias MerchantReferenceUpdateClosure = (String, @escaping (Result) -> Void) -> Void - // swiftlint:disable nesting enum CheckoutV3 { @@ -81,7 +75,7 @@ enum CheckoutV3 { struct Item: Encodable { let name: String - let quantity: Int + let quantity: UInt let price: Money let sku: String? let pageUrl: URL? @@ -159,15 +153,21 @@ enum CheckoutV3 { } struct ResultData: CheckoutV3Data { - let cancellation: CancellationClosure - let merchantReferenceUpdate: MerchantReferenceUpdateClosure - let cardValidUntil: Date? let cardDetails: CardDetails + let cardValidUntil: Date? + let tokens: CheckoutV3Tokens + } + + struct ResultTokens: CheckoutV3Tokens { + let token: String + let singleUseCardToken: String + let ppaConfirmToken: String } struct MerchantReferenceUpdate: Encodable { - let authToken: String let token: String + let singleUseCardToken: String + let ppaConfirmToken: String let merchantReference: String } diff --git a/Sources/Afterpay/Checkout/CheckoutV3Result.swift b/Sources/Afterpay/Checkout/CheckoutV3Result.swift index 0b2c250d..2bd1fb69 100644 --- a/Sources/Afterpay/Checkout/CheckoutV3Result.swift +++ b/Sources/Afterpay/Checkout/CheckoutV3Result.swift @@ -10,14 +10,18 @@ import Foundation /// Data returned from a successful V3 checkout public protocol CheckoutV3Data { - /// A closure that will cancel the virtual card generated by Afterpay. - var cancellation: CancellationClosure { get } - /// A closure that will update the merchant reference on the checkout. - var merchantReferenceUpdate: MerchantReferenceUpdateClosure { get } - /// The time before which an authorization needs to be made on the virtual card. - var cardValidUntil: Date? { get } /// The virtual card details var cardDetails: CardDetails { get } + /// The time before which an authorization needs to be made on the virtual card. + var cardValidUntil: Date? { get } + /// The collection of tokens required to update the merchant reference or cancel the virtual card + var tokens: CheckoutV3Tokens { get } +} + +public protocol CheckoutV3Tokens { + var token: String { get } + var singleUseCardToken: String { get } + var ppaConfirmToken: String { get } } @frozen public enum CheckoutV3Result { diff --git a/Sources/Afterpay/Checkout/CheckoutV3ViewController.swift b/Sources/Afterpay/Checkout/CheckoutV3ViewController.swift index a3305fae..a080a3c0 100644 --- a/Sources/Afterpay/Checkout/CheckoutV3ViewController.swift +++ b/Sources/Afterpay/Checkout/CheckoutV3ViewController.swift @@ -245,37 +245,22 @@ final class CheckoutV3ViewController: } private func handleConfirmationResponse(_ response: ConfirmationV3.Response) { - guard let token = self.token else { + guard + let token = self.token, + let ppaConfirmToken = self.ppaConfirmToken, + let singleUseCardToken = self.singleUseCardToken + else { return } - let cancellationRequest = self.createCancellationRequest() - let updateRequest = self.createMerchantReferenceUpdateRequest() let result = CheckoutV3.ResultData( - cancellation: { [cancellationRequest, requestHandler] cancellationCompletion in - let task = ApiV3.request( - requestHandler, - cancellationRequest, - completion: cancellationCompletion - ) - task.resume() - }, - merchantReferenceUpdate: { [updateRequest, requestHandler] merchantReference, updateCompletion in - var request = updateRequest - // Serialize the merchant reference now that it is known - request.httpBody = try? JSONEncoder().encode( - CheckoutV3.MerchantReferenceUpdate( - authToken: response.authToken, - token: token, - merchantReference: merchantReference - ) - ) - - let task = ApiV3.request(requestHandler, request, completion: updateCompletion) - task.resume() - }, + cardDetails: response.paymentDetails.virtualCard, cardValidUntil: response.cardValidUntil, - cardDetails: response.paymentDetails.virtualCard + tokens: CheckoutV3.ResultTokens( + token: token, + singleUseCardToken: singleUseCardToken, + ppaConfirmToken: ppaConfirmToken + ) ) self.completion(.success(data: result)) From 192b9dbeb1d91a5bfa2c95bf16cf950e39167d04 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Thu, 15 Jul 2021 08:28:13 +1000 Subject: [PATCH 36/81] Move public V3 protocols into separate file --- Afterpay.xcodeproj/project.pbxproj | 12 +-- .../Example/Purchase/CartViewController.swift | 3 +- .../SingleUseCardResultViewController.swift | 4 +- Sources/Afterpay/AfterpayV3.swift | 58 ----------- Sources/Afterpay/Checkout/CheckoutV3.swift | 2 +- .../Checkout/CheckoutV3Protocols.swift | 96 +++++++++++++++++++ .../Afterpay/Checkout/CheckoutV3Result.swift | 30 ------ .../Afterpay/Checkout/ConfirmationV3.swift | 2 +- Sources/Afterpay/Model/CardDetails.swift | 16 ---- 9 files changed, 107 insertions(+), 116 deletions(-) create mode 100644 Sources/Afterpay/Checkout/CheckoutV3Protocols.swift delete mode 100644 Sources/Afterpay/Checkout/CheckoutV3Result.swift delete mode 100644 Sources/Afterpay/Model/CardDetails.swift diff --git a/Afterpay.xcodeproj/project.pbxproj b/Afterpay.xcodeproj/project.pbxproj index 15989939..dd47bea4 100644 --- a/Afterpay.xcodeproj/project.pbxproj +++ b/Afterpay.xcodeproj/project.pbxproj @@ -59,14 +59,13 @@ 66EE9BD724DCEC3E00A81C19 /* LinkTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66EE9BD624DCEC3D00A81C19 /* LinkTextView.swift */; }; 66F9767C2499A11A001D38FA /* Afterpay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66F9767B2499A11A001D38FA /* Afterpay.swift */; }; 838942B7269B8FD50036D1C7 /* CheckoutV3ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 838942B6269B8FD50036D1C7 /* CheckoutV3ViewController.swift */; }; - 838942BB269BAC9B0036D1C7 /* CardDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 838942BA269BAC9B0036D1C7 /* CardDetails.swift */; }; 838942BD269BB39A0036D1C7 /* CheckoutV3.swift in Sources */ = {isa = PBXBuildFile; fileRef = 838942BC269BB39A0036D1C7 /* CheckoutV3.swift */; }; 83F7DEDF269BF43600F9FB75 /* ConfirmationV3.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEDE269BF43600F9FB75 /* ConfirmationV3.swift */; }; 83F7DEE1269CDCF900F9FB75 /* AfterpayV3.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEE0269CDCF900F9FB75 /* AfterpayV3.swift */; }; 83F7DEE7269E2B3600F9FB75 /* ApiV3.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEE6269E2B3600F9FB75 /* ApiV3.swift */; }; 83F7DEEC269E62DA00F9FB75 /* Consumer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEEA269E624600F9FB75 /* Consumer.swift */; }; 83F7DEF4269E94CE00F9FB75 /* CancellationV3.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEF3269E94CE00F9FB75 /* CancellationV3.swift */; }; - 83F7DEF6269EB4CA00F9FB75 /* CheckoutV3Result.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEF5269EB4CA00F9FB75 /* CheckoutV3Result.swift */; }; + 83F7DEF6269EB4CA00F9FB75 /* CheckoutV3Protocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEF5269EB4CA00F9FB75 /* CheckoutV3Protocols.swift */; }; 83F7DEF8269EC4DB00F9FB75 /* OrderTotal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEF7269EC4DB00F9FB75 /* OrderTotal.swift */; }; 946388FE24DD077F00A1227A /* InfoWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 946388FD24DD077F00A1227A /* InfoWebViewController.swift */; }; /* End PBXBuildFile section */ @@ -148,14 +147,13 @@ 66EE9BD624DCEC3D00A81C19 /* LinkTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkTextView.swift; sourceTree = ""; }; 66F9767B2499A11A001D38FA /* Afterpay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Afterpay.swift; sourceTree = ""; }; 838942B6269B8FD50036D1C7 /* CheckoutV3ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckoutV3ViewController.swift; sourceTree = ""; }; - 838942BA269BAC9B0036D1C7 /* CardDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardDetails.swift; sourceTree = ""; }; 838942BC269BB39A0036D1C7 /* CheckoutV3.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckoutV3.swift; sourceTree = ""; }; 83F7DEDE269BF43600F9FB75 /* ConfirmationV3.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmationV3.swift; sourceTree = ""; }; 83F7DEE0269CDCF900F9FB75 /* AfterpayV3.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AfterpayV3.swift; sourceTree = ""; }; 83F7DEE6269E2B3600F9FB75 /* ApiV3.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiV3.swift; sourceTree = ""; }; 83F7DEEA269E624600F9FB75 /* Consumer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Consumer.swift; sourceTree = ""; }; 83F7DEF3269E94CE00F9FB75 /* CancellationV3.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CancellationV3.swift; sourceTree = ""; }; - 83F7DEF5269EB4CA00F9FB75 /* CheckoutV3Result.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckoutV3Result.swift; sourceTree = ""; }; + 83F7DEF5269EB4CA00F9FB75 /* CheckoutV3Protocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckoutV3Protocols.swift; sourceTree = ""; }; 83F7DEF7269EC4DB00F9FB75 /* OrderTotal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderTotal.swift; sourceTree = ""; }; 946388FD24DD077F00A1227A /* InfoWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoWebViewController.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -323,7 +321,6 @@ 6691776D24E0CB7C00D0A4B2 /* Model */ = { isa = PBXGroup; children = ( - 838942BA269BAC9B0036D1C7 /* CardDetails.swift */, 662A3AEC24A999A500EFD826 /* CheckoutResult.swift */, 6689536B24C96CB5005090B4 /* Configuration.swift */, 15F7DDB625393BD30011EC25 /* CurrencyFormatter.swift */, @@ -382,7 +379,7 @@ 667AD3532497121100BF94E5 /* CheckoutWebViewController.swift */, 83F7DEDE269BF43600F9FB75 /* ConfirmationV3.swift */, 83F7DEF3269E94CE00F9FB75 /* CancellationV3.swift */, - 83F7DEF5269EB4CA00F9FB75 /* CheckoutV3Result.swift */, + 83F7DEF5269EB4CA00F9FB75 /* CheckoutV3Protocols.swift */, ); path = Checkout; sourceTree = ""; @@ -607,14 +604,13 @@ 55A2D307261BB36C00D8E23A /* Money.swift in Sources */, 83F7DEF4269E94CE00F9FB75 /* CancellationV3.swift in Sources */, 661D4323257DF1CB00ACCDE1 /* ShippingOption.swift in Sources */, - 83F7DEF6269EB4CA00F9FB75 /* CheckoutV3Result.swift in Sources */, + 83F7DEF6269EB4CA00F9FB75 /* CheckoutV3Protocols.swift in Sources */, 946388FE24DD077F00A1227A /* InfoWebViewController.swift in Sources */, 83F7DEE7269E2B3600F9FB75 /* ApiV3.swift in Sources */, 66EE9BD724DCEC3E00A81C19 /* LinkTextView.swift in Sources */, 838942BD269BB39A0036D1C7 /* CheckoutV3.swift in Sources */, 661D431F257DC86C00ACCDE1 /* ShippingAddress.swift in Sources */, 557511BF2644CAA50040CC51 /* WKWebView+Cache.swift in Sources */, - 838942BB269BAC9B0036D1C7 /* CardDetails.swift in Sources */, 6605666324E5199500DA588E /* Locales.swift in Sources */, 83F7DEEC269E62DA00F9FB75 /* Consumer.swift in Sources */, ); diff --git a/Example/Example/Purchase/CartViewController.swift b/Example/Example/Purchase/CartViewController.swift index 0f1fd515..05a376c5 100644 --- a/Example/Example/Purchase/CartViewController.swift +++ b/Example/Example/Purchase/CartViewController.swift @@ -97,8 +97,9 @@ final class CartViewController: UIViewController, UITableViewDataSource { return cart.products.count case .total: return 1 + // Temporarily disabled for v3 case .options: - return 1 + return 0 } } diff --git a/Example/Example/Purchase/SingleUseCardResultViewController.swift b/Example/Example/Purchase/SingleUseCardResultViewController.swift index ffad3f84..478fbe57 100644 --- a/Example/Example/Purchase/SingleUseCardResultViewController.swift +++ b/Example/Example/Purchase/SingleUseCardResultViewController.swift @@ -121,7 +121,7 @@ final class SingleUseCardResultViewController: UIViewController { @objc func cancel() { Afterpay.cancelVirtualCard(tokens: data.tokens) { [weak self] result in switch result { - case .success: + case .success: // This endpoint returns a 204, so no response body UIView.animate(withDuration: 0.3, animations: { self?.cancellationButton.isEnabled = false self?.cancellationButton.backgroundColor = .systemGray @@ -142,6 +142,8 @@ final class SingleUseCardResultViewController: UIViewController { self?.updateButton.setTitle("Merchant reference updated!", for: .normal) self?.labels.last?.text = "Merchant reference: \(newId)" case .failure(let error): + self?.updateButton.setTitle("Merchant reference updated!", for: .normal) + self?.labels.last?.text = "Merchant reference update failed!" let alert = AlertFactory.alert(for: error.localizedDescription) self?.present(alert, animated: true) } diff --git a/Sources/Afterpay/AfterpayV3.swift b/Sources/Afterpay/AfterpayV3.swift index 5e643176..48b08f10 100644 --- a/Sources/Afterpay/AfterpayV3.swift +++ b/Sources/Afterpay/AfterpayV3.swift @@ -286,61 +286,3 @@ public struct CheckoutV3Configuration { } } } - -public protocol CheckoutV3Consumer { - /// The consumer’s email address. Limited to 128 characters. - var email: String { get } - /// The consumer’s first name and any middle names. Limited to 128 characters. - var givenNames: String? { get } - /// The consumer’s last name. Limited to 128 characters. - var surname: String? { get } - /// The consumer’s phone number. Limited to 32 characters. - var phoneNumber: String? { get } - /// The consumer's shipping information. - var shippingInformation: CheckoutV3Contact? { get } - /// The consumer's billing information. - var billingInformation: CheckoutV3Contact? { get } -} - -public protocol CheckoutV3Contact { - /// Full name of contact. Limited to 255 characters - var name: String { get } - /// First line of the address. Limited to 128 characters - var line1: String { get } - /// Second line of the address. Limited to 128 characters. - var line2: String? { get } - /// Australian suburb, U.S. city, New Zealand town or city, U.K. Postal town. - /// Maximum length is 128 characters. - var area1: String? { get } - /// New Zealand suburb, U.K. village or local area. Maximum length is 128 characters. - var area2: String? { get } - /// U.S. state, Australian state, U.K. county, New Zealand region. Maximum length is 128 characters. - var region: String? { get } - /// The zip code or equivalent. Maximum length is 128 characters. - var postcode: String? { get } - /// The two-character ISO 3166-1 country code. - var countryCode: String { get } - /// The phone number, in E.123 format. Maximum length is 32 characters. - var phoneNumber: String? { get } -} - -public protocol CheckoutV3Item { - /// Product name. Limited to 255 characters. - var name: String { get } - /// The quantity of the item, stored as a signed 32-bit integer. - var quantity: UInt { get } - /// The unit price of the individual item. Must be a positive value. - var price: Decimal { get } - /// Product SKU. Limited to 128 characters. - var sku: String? { get } - /// The canonical URL for the item's Product Detail Page. Limited to 2048 characters. - var pageUrl: URL? { get } - /// A URL for a web-optimised photo of the item, suitable for use directly as the src attribute of an img tag. - /// Limited to 2048 characters. - var imageUrl: URL? { get } - /// An array of arrays to accommodate multiple categories that might apply to the item. - /// Each array contains comma separated strings with the left-most category being the top level category. - var categories: [[String]]? { get } - /// The estimated date when the order will be shipped. YYYY-MM or YYYY-MM-DD format. - var estimatedShipmentDate: String? { get } -} diff --git a/Sources/Afterpay/Checkout/CheckoutV3.swift b/Sources/Afterpay/Checkout/CheckoutV3.swift index 7521ee83..e982287c 100644 --- a/Sources/Afterpay/Checkout/CheckoutV3.swift +++ b/Sources/Afterpay/Checkout/CheckoutV3.swift @@ -153,7 +153,7 @@ enum CheckoutV3 { } struct ResultData: CheckoutV3Data { - let cardDetails: CardDetails + let cardDetails: CheckoutV3VirtualCard let cardValidUntil: Date? let tokens: CheckoutV3Tokens } diff --git a/Sources/Afterpay/Checkout/CheckoutV3Protocols.swift b/Sources/Afterpay/Checkout/CheckoutV3Protocols.swift new file mode 100644 index 00000000..74777347 --- /dev/null +++ b/Sources/Afterpay/Checkout/CheckoutV3Protocols.swift @@ -0,0 +1,96 @@ +// +// CheckoutV3Protocols.swift +// Afterpay +// +// Created by Chris Kolbu on 14/7/21. +// Copyright © 2021 Afterpay. All rights reserved. +// + +import Foundation + +/// Data returned from a successful V3 checkout +public protocol CheckoutV3Data { + /// The virtual card details + var cardDetails: CheckoutV3VirtualCard { get } + /// The time before which an authorization needs to be made on the virtual card. + var cardValidUntil: Date? { get } + /// The collection of tokens required to update the merchant reference or cancel the virtual card + var tokens: CheckoutV3Tokens { get } +} + +public protocol CheckoutV3Tokens { + var token: String { get } + var singleUseCardToken: String { get } + var ppaConfirmToken: String { get } +} + +@frozen public enum CheckoutV3Result { + case success(data: CheckoutV3Data) + case cancelled(reason: CheckoutResult.CancellationReason) +} + +public protocol CheckoutV3Consumer { + /// The consumer’s email address. Limited to 128 characters. + var email: String { get } + /// The consumer’s first name and any middle names. Limited to 128 characters. + var givenNames: String? { get } + /// The consumer’s last name. Limited to 128 characters. + var surname: String? { get } + /// The consumer’s phone number. Limited to 32 characters. + var phoneNumber: String? { get } + /// The consumer's shipping information. + var shippingInformation: CheckoutV3Contact? { get } + /// The consumer's billing information. + var billingInformation: CheckoutV3Contact? { get } +} + +public protocol CheckoutV3Contact { + /// Full name of contact. Limited to 255 characters + var name: String { get } + /// First line of the address. Limited to 128 characters + var line1: String { get } + /// Second line of the address. Limited to 128 characters. + var line2: String? { get } + /// Australian suburb, U.S. city, New Zealand town or city, U.K. Postal town. + /// Maximum length is 128 characters. + var area1: String? { get } + /// New Zealand suburb, U.K. village or local area. Maximum length is 128 characters. + var area2: String? { get } + /// U.S. state, Australian state, U.K. county, New Zealand region. Maximum length is 128 characters. + var region: String? { get } + /// The zip code or equivalent. Maximum length is 128 characters. + var postcode: String? { get } + /// The two-character ISO 3166-1 country code. + var countryCode: String { get } + /// The phone number, in E.123 format. Maximum length is 32 characters. + var phoneNumber: String? { get } +} + +public protocol CheckoutV3Item { + /// Product name. Limited to 255 characters. + var name: String { get } + /// The quantity of the item, stored as a signed 32-bit integer. + var quantity: UInt { get } + /// The unit price of the individual item. Must be a positive value. + var price: Decimal { get } + /// Product SKU. Limited to 128 characters. + var sku: String? { get } + /// The canonical URL for the item's Product Detail Page. Limited to 2048 characters. + var pageUrl: URL? { get } + /// A URL for a web-optimised photo of the item, suitable for use directly as the src attribute of an img tag. + /// Limited to 2048 characters. + var imageUrl: URL? { get } + /// An array of arrays to accommodate multiple categories that might apply to the item. + /// Each array contains comma separated strings with the left-most category being the top level category. + var categories: [[String]]? { get } + /// The estimated date when the order will be shipped. YYYY-MM or YYYY-MM-DD format. + var estimatedShipmentDate: String? { get } +} + +/// A virtual card generated by Afterpay through `Afterpay.presentCheckoutV3Modally` +public protocol CheckoutV3VirtualCard { + var cardNumber: String { get } + var cvc: String { get } + var expiryMonth: Int { get} + var expiryYear: Int { get } +} diff --git a/Sources/Afterpay/Checkout/CheckoutV3Result.swift b/Sources/Afterpay/Checkout/CheckoutV3Result.swift deleted file mode 100644 index 2bd1fb69..00000000 --- a/Sources/Afterpay/Checkout/CheckoutV3Result.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// ConfirmationV3.swift -// Afterpay -// -// Created by Chris Kolbu on 14/7/21. -// Copyright © 2021 Afterpay. All rights reserved. -// - -import Foundation - -/// Data returned from a successful V3 checkout -public protocol CheckoutV3Data { - /// The virtual card details - var cardDetails: CardDetails { get } - /// The time before which an authorization needs to be made on the virtual card. - var cardValidUntil: Date? { get } - /// The collection of tokens required to update the merchant reference or cancel the virtual card - var tokens: CheckoutV3Tokens { get } -} - -public protocol CheckoutV3Tokens { - var token: String { get } - var singleUseCardToken: String { get } - var ppaConfirmToken: String { get } -} - -@frozen public enum CheckoutV3Result { - case success(data: CheckoutV3Data) - case cancelled(reason: CheckoutResult.CancellationReason) -} diff --git a/Sources/Afterpay/Checkout/ConfirmationV3.swift b/Sources/Afterpay/Checkout/ConfirmationV3.swift index e500acae..27b0f277 100644 --- a/Sources/Afterpay/Checkout/ConfirmationV3.swift +++ b/Sources/Afterpay/Checkout/ConfirmationV3.swift @@ -26,7 +26,7 @@ enum ConfirmationV3 { let virtualCard: VirtualCard } - struct VirtualCard: Decodable, CardDetails { + struct VirtualCard: Decodable, CheckoutV3VirtualCard { let cardNumber: String let cvc: String let expiryMonth: Int diff --git a/Sources/Afterpay/Model/CardDetails.swift b/Sources/Afterpay/Model/CardDetails.swift deleted file mode 100644 index 33ba57fb..00000000 --- a/Sources/Afterpay/Model/CardDetails.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// CardDetails.swift -// Afterpay -// -// Created by Chris Kolbu on 12/7/21. -// Copyright © 2021 Afterpay. All rights reserved. -// - -import Foundation - -public protocol CardDetails { - var cardNumber: String { get } - var cvc: String { get } - var expiryMonth: Int { get} - var expiryYear: Int { get } -} From c09a2580e572ed6dde668aa2117b901c626b8dc4 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Thu, 15 Jul 2021 09:50:12 +1000 Subject: [PATCH 37/81] Make VirtualCard an enum representing a Card or a tokenized Card --- Afterpay.xcodeproj/project.pbxproj | 12 +- .../VirtualCardDeserializationTests.swift | 65 +++++++++ .../SingleUseCardResultViewController.swift | 19 ++- Sources/Afterpay/Checkout/CheckoutV3.swift | 2 +- .../Checkout/CheckoutV3Protocols.swift | 10 +- .../Afterpay/Checkout/ConfirmationV3.swift | 43 ------ Sources/Afterpay/Model/CheckoutV3Card.swift | 135 ++++++++++++++++++ 7 files changed, 227 insertions(+), 59 deletions(-) create mode 100644 AfterpayTests/VirtualCardDeserializationTests.swift create mode 100644 Sources/Afterpay/Model/CheckoutV3Card.swift diff --git a/Afterpay.xcodeproj/project.pbxproj b/Afterpay.xcodeproj/project.pbxproj index dd47bea4..31ed6e3e 100644 --- a/Afterpay.xcodeproj/project.pbxproj +++ b/Afterpay.xcodeproj/project.pbxproj @@ -67,6 +67,8 @@ 83F7DEF4269E94CE00F9FB75 /* CancellationV3.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEF3269E94CE00F9FB75 /* CancellationV3.swift */; }; 83F7DEF6269EB4CA00F9FB75 /* CheckoutV3Protocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEF5269EB4CA00F9FB75 /* CheckoutV3Protocols.swift */; }; 83F7DEF8269EC4DB00F9FB75 /* OrderTotal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEF7269EC4DB00F9FB75 /* OrderTotal.swift */; }; + 83F7DEFA269FA8C400F9FB75 /* CheckoutV3Card.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEF9269FA8C400F9FB75 /* CheckoutV3Card.swift */; }; + 83F7DEFC269FAA3A00F9FB75 /* VirtualCardDeserializationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEFB269FAA3A00F9FB75 /* VirtualCardDeserializationTests.swift */; }; 946388FE24DD077F00A1227A /* InfoWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 946388FD24DD077F00A1227A /* InfoWebViewController.swift */; }; /* End PBXBuildFile section */ @@ -155,6 +157,8 @@ 83F7DEF3269E94CE00F9FB75 /* CancellationV3.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CancellationV3.swift; sourceTree = ""; }; 83F7DEF5269EB4CA00F9FB75 /* CheckoutV3Protocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckoutV3Protocols.swift; sourceTree = ""; }; 83F7DEF7269EC4DB00F9FB75 /* OrderTotal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderTotal.swift; sourceTree = ""; }; + 83F7DEF9269FA8C400F9FB75 /* CheckoutV3Card.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckoutV3Card.swift; sourceTree = ""; }; + 83F7DEFB269FAA3A00F9FB75 /* VirtualCardDeserializationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VirtualCardDeserializationTests.swift; sourceTree = ""; }; 946388FD24DD077F00A1227A /* InfoWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoWebViewController.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -251,6 +255,7 @@ 550D481A26255D8600C0B0C6 /* WidgetEventTests.swift */, 550D48142625539900C0B0C6 /* WidgetStatusTests.swift */, 557511BA264259C30040CC51 /* CombineWrapperTests.swift */, + 83F7DEFB269FAA3A00F9FB75 /* VirtualCardDeserializationTests.swift */, ); path = AfterpayTests; sourceTree = ""; @@ -322,17 +327,18 @@ isa = PBXGroup; children = ( 662A3AEC24A999A500EFD826 /* CheckoutResult.swift */, + 83F7DEF9269FA8C400F9FB75 /* CheckoutV3Card.swift */, 6689536B24C96CB5005090B4 /* Configuration.swift */, + 83F7DEEA269E624600F9FB75 /* Consumer.swift */, 15F7DDB625393BD30011EC25 /* CurrencyFormatter.swift */, 1522245F25C925E5004B9CE5 /* Environment.swift */, 6605666224E5199500DA588E /* Locales.swift */, 55A2D306261BB36C00D8E23A /* Money.swift */, + 83F7DEF7269EC4DB00F9FB75 /* OrderTotal.swift */, 66DAAC8A24E0CF0100127460 /* PriceBreakdown.swift */, 661D431E257DC86C00ACCDE1 /* ShippingAddress.swift */, 661D4322257DF1CB00ACCDE1 /* ShippingOption.swift */, 157C65AE25D23E8F00115149 /* Version.swift */, - 83F7DEEA269E624600F9FB75 /* Consumer.swift */, - 83F7DEF7269EC4DB00F9FB75 /* OrderTotal.swift */, ); path = Model; sourceTree = ""; @@ -553,6 +559,7 @@ 551BEDF125F98FA800FDF9EE /* FeaturesTests.swift in Sources */, 6635B95F24CAA9F000EBB3A6 /* ConfigurationTests.swift in Sources */, 66DAAC8D24E109D200127460 /* PriceBreakdownTests.swift in Sources */, + 83F7DEFC269FAA3A00F9FB75 /* VirtualCardDeserializationTests.swift in Sources */, 550D481B26255D8600C0B0C6 /* WidgetEventTests.swift in Sources */, 550D48152625539900C0B0C6 /* WidgetStatusTests.swift in Sources */, 557511BB264259C30040CC51 /* CombineWrapperTests.swift in Sources */, @@ -568,6 +575,7 @@ 66639B5424D2619200C68558 /* SVGView.swift in Sources */, 66E255AE24E3C14600C81F20 /* Strings.swift in Sources */, 6615F99B24D14620005036F1 /* SVG.swift in Sources */, + 83F7DEFA269FA8C400F9FB75 /* CheckoutV3Card.swift in Sources */, 838942B7269B8FD50036D1C7 /* CheckoutV3ViewController.swift in Sources */, 66483F3B24D7A164000BE6B5 /* PriceBreakdownView.swift in Sources */, 6602EF0F25358A8000A0468C /* ColorScheme.swift in Sources */, diff --git a/AfterpayTests/VirtualCardDeserializationTests.swift b/AfterpayTests/VirtualCardDeserializationTests.swift new file mode 100644 index 00000000..7c7699d8 --- /dev/null +++ b/AfterpayTests/VirtualCardDeserializationTests.swift @@ -0,0 +1,65 @@ +// Copyright 2021 Itty Bitty Apps Pty Ltd + +import XCTest +@testable import Afterpay + +// swiftlint:disable indentation_width +final class VirtualCardDeserializationTests: XCTestCase { + + func testPlainCardIsDeserializedProperly() throws { + let json = """ +{ +"cardType": "VISA", +"cardNumber": "4444444444444448", +"cvc": "999", +"expiry": "2024-02" +} +""".data(using: .utf8)! + + let model = try JSONDecoder().decode(VirtualCard.self, from: json) + + guard case .card = model else { + XCTFail("Expected VirtualCard.card!") + return + } + } + + func testTokenizedCardIsDeserializedProperly() throws { + let json = """ +{ +"cardType": "VISA", +"cardToken": "magical string", +"cvc": "999", +"expiry": "2024-02" +} +""".data(using: .utf8)! + + let model = try JSONDecoder().decode(VirtualCard.self, from: json) + + guard case .tokenized = model else { + XCTFail("Expected VirtualCard.card!") + return + } + } + + func testUnknownCardFailsWithProperDescription() throws { + let json = """ +{ +"cardType": "VISA", +"cardWhatNow": "Didn't expect you here", +"cvc": "999", +"expiry": "2024-02" +} +""".data(using: .utf8)! + + do { + _ = try JSONDecoder().decode(VirtualCard.self, from: json) + XCTFail("Expected error to be thrown") + } catch is VirtualCard.Error { + // The correct error was thrown + } catch { + XCTFail("Expected a `VirtualCard.Error`") + } + } + +} diff --git a/Example/Example/Purchase/SingleUseCardResultViewController.swift b/Example/Example/Purchase/SingleUseCardResultViewController.swift index 478fbe57..71e00526 100644 --- a/Example/Example/Purchase/SingleUseCardResultViewController.swift +++ b/Example/Example/Purchase/SingleUseCardResultViewController.swift @@ -24,13 +24,24 @@ final class SingleUseCardResultViewController: UIViewController { }() private lazy var labels: [UILabel] = { - let strings = [ - "Card number: \(data.cardDetails.cardNumber)", - "CVC: \(data.cardDetails.cvc)", - "Expiration: \(data.cardDetails.expiryMonth)/\(data.cardDetails.expiryYear)", + var strings = [ "Virtual card expiry: \(data.cardValidUntil?.shortDuration ?? "Unavailable")", "Merchant reference: ", ] + switch data.cardDetails { + case .card(let card): + strings.insert(contentsOf: [ + "Card number: \(card.cardNumber)", + "CVC: \(card.cvc)", + "Expiration: \(card.expiryMonth)/\(card.expiryYear)", + ], at: 0) + case .tokenized(let card): + strings.insert(contentsOf: [ + "Card token: \(card.cardToken)", + "CVC: \(card.cvc)", + "Expiration: \(card.expiryMonth)/\(card.expiryYear)", + ], at: 0) + } return strings.map { string in let label = UILabel() diff --git a/Sources/Afterpay/Checkout/CheckoutV3.swift b/Sources/Afterpay/Checkout/CheckoutV3.swift index e982287c..c74fcb60 100644 --- a/Sources/Afterpay/Checkout/CheckoutV3.swift +++ b/Sources/Afterpay/Checkout/CheckoutV3.swift @@ -153,7 +153,7 @@ enum CheckoutV3 { } struct ResultData: CheckoutV3Data { - let cardDetails: CheckoutV3VirtualCard + let cardDetails: VirtualCard let cardValidUntil: Date? let tokens: CheckoutV3Tokens } diff --git a/Sources/Afterpay/Checkout/CheckoutV3Protocols.swift b/Sources/Afterpay/Checkout/CheckoutV3Protocols.swift index 74777347..63897414 100644 --- a/Sources/Afterpay/Checkout/CheckoutV3Protocols.swift +++ b/Sources/Afterpay/Checkout/CheckoutV3Protocols.swift @@ -11,7 +11,7 @@ import Foundation /// Data returned from a successful V3 checkout public protocol CheckoutV3Data { /// The virtual card details - var cardDetails: CheckoutV3VirtualCard { get } + var cardDetails: VirtualCard { get } /// The time before which an authorization needs to be made on the virtual card. var cardValidUntil: Date? { get } /// The collection of tokens required to update the merchant reference or cancel the virtual card @@ -86,11 +86,3 @@ public protocol CheckoutV3Item { /// The estimated date when the order will be shipped. YYYY-MM or YYYY-MM-DD format. var estimatedShipmentDate: String? { get } } - -/// A virtual card generated by Afterpay through `Afterpay.presentCheckoutV3Modally` -public protocol CheckoutV3VirtualCard { - var cardNumber: String { get } - var cvc: String { get } - var expiryMonth: Int { get} - var expiryYear: Int { get } -} diff --git a/Sources/Afterpay/Checkout/ConfirmationV3.swift b/Sources/Afterpay/Checkout/ConfirmationV3.swift index 27b0f277..f910f7ac 100644 --- a/Sources/Afterpay/Checkout/ConfirmationV3.swift +++ b/Sources/Afterpay/Checkout/ConfirmationV3.swift @@ -25,49 +25,6 @@ enum ConfirmationV3 { struct PaymentDetails: Decodable { let virtualCard: VirtualCard } - - struct VirtualCard: Decodable, CheckoutV3VirtualCard { - let cardNumber: String - let cvc: String - let expiryMonth: Int - let expiryYear: Int - - // swiftlint:disable:next nesting - private enum CodingKeys: String, CodingKey { - case cardNumber, cvc, expiry - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let expiryString = try container.decode(String.self, forKey: .expiry).split(separator: "-") - - guard expiryString.count == 2 else { - throw DecodingError.dataCorruptedError( - forKey: .expiry, - in: container, - debugDescription: "Expiry string wrong format Expected `-` as separator" - ) - } - - guard - let yearString = expiryString.first, - let year = Int(yearString), - let monthString = expiryString.last, - let month = Int(monthString) - else { - throw DecodingError.dataCorruptedError( - forKey: .expiry, - in: container, - debugDescription: "Expiry string wrong format Expected two integral values representing year and month" - ) - } - - self.expiryYear = year - self.expiryMonth = month - self.cardNumber = try container.decode(String.self, forKey: .cardNumber) - self.cvc = try container.decode(String.self, forKey: .cvc) - } - } } } diff --git a/Sources/Afterpay/Model/CheckoutV3Card.swift b/Sources/Afterpay/Model/CheckoutV3Card.swift new file mode 100644 index 00000000..ef755aeb --- /dev/null +++ b/Sources/Afterpay/Model/CheckoutV3Card.swift @@ -0,0 +1,135 @@ +// +// CheckoutV3Card.swift +// Afterpay +// +// Created by Chris Kolbu on 15/7/21. +// Copyright © 2021 Afterpay. All rights reserved. +// + +import Foundation + +public enum VirtualCard: Decodable { + case card(Card) + case tokenized(TokenizedCard) + + public init(from decoder: Decoder) throws { + if let card = try? Card(from: decoder) { + self = .card(card) + return + } + do { + let tokenizedCard = try TokenizedCard(from: decoder) + self = .tokenized(tokenizedCard) + } catch { + guard error is DecodingError else { + throw error + } + throw Error(message: + "Could not parse expected `\(VirtualCard.self)` as `\(Card.self)` or `\(TokenizedCard.self)`" + ) + } + } + + public struct Error: LocalizedError { + let message: String + + public var failureReason: String? { + NSLocalizedString(message, comment: "Failure reason") + } + + public var recoverySuggestion: String? { + NSLocalizedString( + "Please contact Afterpay", + comment: "Recovery suggestion for a `VirtualCard` parsing error" + ) + } + } +} + +public struct Card: Decodable { + public let cardType: String + public let cardNumber: String + public let cvc: String + public let expiryMonth: Int + public let expiryYear: Int + + private enum CodingKeys: String, CodingKey { + case cardNumber, cvc, expiry, cardType + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let expiryString = try container.decode(String.self, forKey: .expiry).split(separator: "-") + + guard expiryString.count == 2 else { + throw DecodingError.dataCorruptedError( + forKey: .expiry, + in: container, + debugDescription: "Expiry string wrong format Expected `-` as separator" + ) + } + + guard + let yearString = expiryString.first, + let year = Int(yearString), + let monthString = expiryString.last, + let month = Int(monthString) + else { + throw DecodingError.dataCorruptedError( + forKey: .expiry, + in: container, + debugDescription: "Expiry string wrong format Expected two integral values representing year and month" + ) + } + + self.expiryYear = year + self.expiryMonth = month + self.cardNumber = try container.decode(String.self, forKey: .cardNumber) + self.cvc = try container.decode(String.self, forKey: .cvc) + self.cardType = try container.decode(String.self, forKey: .cardType) + } +} + +public struct TokenizedCard: Decodable { + public let cardType: String + public let cardToken: String + public let cvc: String + public let expiryMonth: Int + public let expiryYear: Int + + private enum CodingKeys: String, CodingKey { + case cardType, cardToken, cvc, expiry + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let expiryString = try container.decode(String.self, forKey: .expiry).split(separator: "-") + + guard expiryString.count == 2 else { + throw DecodingError.dataCorruptedError( + forKey: .expiry, + in: container, + debugDescription: "Expiry string wrong format Expected `-` as separator" + ) + } + + guard + let yearString = expiryString.first, + let year = Int(yearString), + let monthString = expiryString.last, + let month = Int(monthString) + else { + throw DecodingError.dataCorruptedError( + forKey: .expiry, + in: container, + debugDescription: "Expiry string wrong format Expected two integral values representing year and month" + ) + } + + self.expiryYear = year + self.expiryMonth = month + self.cardToken = try container.decode(String.self, forKey: .cardToken) + self.cvc = try container.decode(String.self, forKey: .cvc) + self.cardType = try container.decode(String.self, forKey: .cardType) + } +} From 1bccb8178ccbe027b382dc779fa30b455a8e28ef Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Thu, 15 Jul 2021 09:51:55 +1000 Subject: [PATCH 38/81] =?UTF-8?q?Change=20Checkout=20amount=20to=20use=20O?= =?UTF-8?q?rderTotal=E2=80=99s=20new=20`total`=20property?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, we were assuming that the Afterpay API would calculate the total from `amount`, `shippingAmount` and `taxAmount`. Now, we know that the two latter properties are for fraud prevention purposes, and that `amount` should always reflect the total that should be charged to the customer --- Sources/Afterpay/Checkout/CheckoutV3.swift | 2 +- Sources/Afterpay/Model/OrderTotal.swift | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/Afterpay/Checkout/CheckoutV3.swift b/Sources/Afterpay/Checkout/CheckoutV3.swift index c74fcb60..5c7e6697 100644 --- a/Sources/Afterpay/Checkout/CheckoutV3.swift +++ b/Sources/Afterpay/Checkout/CheckoutV3.swift @@ -37,7 +37,7 @@ enum CheckoutV3 { self.merchantPublicKey = configuration.merchantPublicKey self.amount = Money( - amount: configuration.region.formatted(currency: orderTotal.subtotal), + amount: configuration.region.formatted(currency: orderTotal.total), currency: configuration.region.currencyCode ) diff --git a/Sources/Afterpay/Model/OrderTotal.swift b/Sources/Afterpay/Model/OrderTotal.swift index dd2ad80e..3b53c718 100644 --- a/Sources/Afterpay/Model/OrderTotal.swift +++ b/Sources/Afterpay/Model/OrderTotal.swift @@ -18,6 +18,10 @@ public struct OrderTotal { public var shipping: Decimal? public var tax: Decimal? + public var total: Decimal { + subtotal + (shipping ?? 0) + (tax ?? 0) + } + public init(subtotal: Decimal, shipping: Decimal? = nil, tax: Decimal? = nil) { self.subtotal = subtotal self.shipping = shipping From 61a6e85a174b0275c317c6c59d52732c5517b73b Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Thu, 15 Jul 2021 10:10:28 +1000 Subject: [PATCH 39/81] Add V3 support for buyNow flag during checkout --- .../Example/Purchase/CartViewController.swift | 3 +-- .../Purchase/PurchaseFlowController.swift | 1 + Sources/Afterpay/AfterpayV3.swift | 2 ++ .../Checkout/CheckoutV3ViewController.swift | 16 ++++++++++++++-- 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/Example/Example/Purchase/CartViewController.swift b/Example/Example/Purchase/CartViewController.swift index 05a376c5..0f1fd515 100644 --- a/Example/Example/Purchase/CartViewController.swift +++ b/Example/Example/Purchase/CartViewController.swift @@ -97,9 +97,8 @@ final class CartViewController: UIViewController, UITableViewDataSource { return cart.products.count case .total: return 1 - // Temporarily disabled for v3 case .options: - return 0 + return 1 } } diff --git a/Example/Example/Purchase/PurchaseFlowController.swift b/Example/Example/Purchase/PurchaseFlowController.swift index c96e0871..8f57108b 100644 --- a/Example/Example/Purchase/PurchaseFlowController.swift +++ b/Example/Example/Purchase/PurchaseFlowController.swift @@ -140,6 +140,7 @@ final class PurchaseFlowController: UIViewController { consumer: consumer, orderTotal: OrderTotal(subtotal: cart.total, shipping: nil, tax: 9.999), items: cart.products, + buyNow: cart.checkoutV2Options.buyNow ?? false, requestHandler: APIClient.live.session.dataTask ) { result in switch result { diff --git a/Sources/Afterpay/AfterpayV3.swift b/Sources/Afterpay/AfterpayV3.swift index 48b08f10..6e239504 100644 --- a/Sources/Afterpay/AfterpayV3.swift +++ b/Sources/Afterpay/AfterpayV3.swift @@ -146,6 +146,7 @@ public func presentCheckoutV3Modally( consumer: CheckoutV3Consumer, orderTotal: OrderTotal, items: [CheckoutV3Item] = [], + buyNow: Bool, animated: Bool = true, configuration: CheckoutV3Configuration? = getV3Configuration(), requestHandler: @escaping URLRequestHandler = URLSession.shared.dataTask, @@ -165,6 +166,7 @@ public func presentCheckoutV3Modally( items: items, configuration: configuration ), + buyNow: buyNow, configuration: configuration, requestHandler: requestHandler, completion: completion diff --git a/Sources/Afterpay/Checkout/CheckoutV3ViewController.swift b/Sources/Afterpay/Checkout/CheckoutV3ViewController.swift index a080a3c0..ada69387 100644 --- a/Sources/Afterpay/Checkout/CheckoutV3ViewController.swift +++ b/Sources/Afterpay/Checkout/CheckoutV3ViewController.swift @@ -17,6 +17,7 @@ final class CheckoutV3ViewController: { // swiftlint:disable:this opening_brace private let checkout: CheckoutV3.Request + private let buyNow: Bool private let configuration: CheckoutV3Configuration private let requestHandler: URLRequestHandler private var currentTask: URLSessionDataTask? @@ -32,11 +33,13 @@ final class CheckoutV3ViewController: init( checkout: CheckoutV3.Request, + buyNow: Bool, configuration: CheckoutV3Configuration, requestHandler: @escaping URLRequestHandler, completion: @escaping (_ result: CheckoutV3Result) -> Void ) { self.checkout = checkout + self.buyNow = buyNow self.configuration = configuration self.requestHandler = requestHandler self.completion = completion @@ -88,8 +91,17 @@ final class CheckoutV3ViewController: } } - performCheckoutRequest { redirectUrl in - self.webView.load(ApiV3.request(from: redirectUrl)) + performCheckoutRequest { [weak self] redirectCheckoutUrl in + guard + var components = URLComponents(url: redirectCheckoutUrl, resolvingAgainstBaseURL: false) + else { + self?.webView.load(ApiV3.request(from: redirectCheckoutUrl)) + return + } + var queryItems = components.queryItems ?? [] + queryItems.append(URLQueryItem(name: "buyNow", value: (self?.buyNow ?? false) ? "true" : "false")) + components.queryItems = queryItems + self?.webView.load(ApiV3.request(from: components.url!)) } } From 1fcf2961a1ab03d7ac65cd3712b87867b5c34b8f Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Thu, 15 Jul 2021 10:12:02 +1000 Subject: [PATCH 40/81] Remove button to cancel virtual card --- .../SingleUseCardResultViewController.swift | 31 +------------------ 1 file changed, 1 insertion(+), 30 deletions(-) diff --git a/Example/Example/Purchase/SingleUseCardResultViewController.swift b/Example/Example/Purchase/SingleUseCardResultViewController.swift index 71e00526..8158816f 100644 --- a/Example/Example/Purchase/SingleUseCardResultViewController.swift +++ b/Example/Example/Purchase/SingleUseCardResultViewController.swift @@ -77,18 +77,6 @@ final class SingleUseCardResultViewController: UIViewController { return label }() - private lazy var cancellationButton: UIButton = { - let button = UIButton(type: .roundedRect) - button.setTitle("Cancel card", for: .normal) - button.setTitle("Card cancelled", for: .disabled) - button.addTarget(self, action: #selector(cancel), for: .touchUpInside) - button.backgroundColor = .systemRed - button.setTitleColor(.white, for: .normal) - button.setTitleColor(.darkText, for: .disabled) - button.layer.cornerRadius = 8 - return button - }() - private lazy var updateButton: UIButton = { let button = UIButton(type: .roundedRect) button.setTitle("Update merchant reference", for: .normal) @@ -119,31 +107,15 @@ final class SingleUseCardResultViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() view.addSubview(vStack) - (labels + [explanatoryHeaderLabel, explanatoryBodyLabel, cancellationButton, updateButton]) + (labels + [explanatoryHeaderLabel, explanatoryBodyLabel, updateButton]) .forEach(vStack.addArrangedSubview) vStack.setCustomSpacing(24, after: labels.last!) vStack.setCustomSpacing(24, after: explanatoryBodyLabel) - vStack.setCustomSpacing(16, after: cancellationButton) setConstraints() } // MARK: - Actions - @objc func cancel() { - Afterpay.cancelVirtualCard(tokens: data.tokens) { [weak self] result in - switch result { - case .success: // This endpoint returns a 204, so no response body - UIView.animate(withDuration: 0.3, animations: { - self?.cancellationButton.isEnabled = false - self?.cancellationButton.backgroundColor = .systemGray - }) - case .failure(let error): - let alert = AlertFactory.alert(for: error.localizedDescription) - self?.present(alert, animated: true) - } - } - } - @objc func update() { updateButton.setTitle("Updating ...", for: .normal) let newId = UUID().uuidString @@ -185,7 +157,6 @@ final class SingleUseCardResultViewController: UIViewController { scrollView.contentLayoutGuide.widthAnchor.constraint( equalTo: scrollView.frameLayoutGuide.widthAnchor ), - cancellationButton.heightAnchor.constraint(equalToConstant: 44), updateButton.heightAnchor.constraint(equalToConstant: 44), ]) } From 3af63f6f9b7d4bb7276f66c799a2e68b55680cdd Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Thu, 15 Jul 2021 10:14:17 +1000 Subject: [PATCH 41/81] Remove V3 functionality to cancel virtual cards As requested by Vish Rathinavelu --- Sources/Afterpay/AfterpayV3.swift | 47 ------------------- .../Checkout/CheckoutV3ViewController.swift | 31 +----------- 2 files changed, 1 insertion(+), 77 deletions(-) diff --git a/Sources/Afterpay/AfterpayV3.swift b/Sources/Afterpay/AfterpayV3.swift index 6e239504..06619194 100644 --- a/Sources/Afterpay/AfterpayV3.swift +++ b/Sources/Afterpay/AfterpayV3.swift @@ -52,44 +52,6 @@ public func updateMerchantReference( } } -// MARK: - Cancel virtual card - -/// Cancels the virtual card represented by the provided `tokens`. -/// - Parameters: -/// - tokens: The set of tokens returned after a successful call to `Afterpay.presentCheckoutV3Modally`. -/// - configuration: A collection of options and values required to interact with the Afterpay API. -/// - requestHandler: A function that takes a `URLRequest` and a closure to handle the result. -/// - completion: The result of the user's completion (a success or cancellation). Returns on the main thread. -public func cancelVirtualCard( - tokens: CheckoutV3Tokens, - configuration: CheckoutV3Configuration? = getV3Configuration(), - requestHandler: @escaping URLRequestHandler = URLSession.shared.dataTask, - completion: @escaping (_ result: Result) -> Void -) { - guard let configuration = configuration else { - return assertionFailure( - "For cancelVirtualCard to function you must provide a `configuration` object via either " - + "`Afterpay.cancelVirtualCard` or `Afterpay.setV3Configuration`" - ) - } - do { - var request = ApiV3.request(from: configuration.v3CheckoutCancellationUrl) - request.httpMethod = "POST" - request.httpBody = try JSONEncoder().encode(CancellationV3.Request( - token: tokens.token, - ppaConfirmToken: tokens.ppaConfirmToken, - singleUseCardToken: tokens.singleUseCardToken - )) - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - let task = ApiV3.request(requestHandler, request, completion: completion) - task.resume() - } catch { - completion(.failure(error)) - } -} - // MARK: - Fetch merchant configuration /// Returns the merchant configuration object, representing the merchant's applicable payment limits. @@ -228,15 +190,6 @@ public struct CheckoutV3Configuration { } } - var v3CheckoutCancellationUrl: URL { - switch (region, environment) { - case (.US, .sandbox): - return URL(string: "https://api-plus.us-sandbox.afterpay.com/v3/button/cancel")! - case (.US, .production): - return URL(string: "https://api-plus.us.afterpay.com/v3/button/cancel")! - } - } - var v3ConfigurationUrl: URL { var url: URL switch (region, environment) { diff --git a/Sources/Afterpay/Checkout/CheckoutV3ViewController.swift b/Sources/Afterpay/Checkout/CheckoutV3ViewController.swift index ada69387..26666c82 100644 --- a/Sources/Afterpay/Checkout/CheckoutV3ViewController.swift +++ b/Sources/Afterpay/Checkout/CheckoutV3ViewController.swift @@ -9,7 +9,7 @@ import UIKit import WebKit -// swiftlint:disable:next colon type_body_length +// swiftlint:disable:next colon final class CheckoutV3ViewController: UIViewController, UIAdaptivePresentationControllerDelegate, @@ -278,35 +278,6 @@ final class CheckoutV3ViewController: self.completion(.success(data: result)) } - private func createMerchantReferenceUpdateRequest() -> URLRequest { - var request = ApiV3.request(from: configuration.v3CheckoutUrl) - request.httpMethod = "PUT" - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - return request - } - - private func createCancellationRequest() -> URLRequest { - guard - let token = self.token, - let singleUseCardToken = self.singleUseCardToken, - let ppaConfirmToken = self.ppaConfirmToken, - let data = try? JSONEncoder().encode(CancellationV3.Request( - token: token, - ppaConfirmToken: ppaConfirmToken, - singleUseCardToken: singleUseCardToken - )) - else { - fatalError("`token` or `singleUseToken` was nil") - } - var request = ApiV3.request(from: self.configuration.v3CheckoutCancellationUrl) - request.httpMethod = "POST" - request.httpBody = data - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - return request - } - private func createCheckoutRequest() -> URLRequest { let data = try! JSONEncoder().encode(self.checkout) // swiftlint:disable:this force_try From 52b36fa64f1e895da7c7b4f5d32144e1479501d3 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Thu, 15 Jul 2021 10:56:34 +1000 Subject: [PATCH 42/81] Remove V3references to `merchantPublicKey` This property is actually `shopDirectoryId` --- Example/Example/Purchase/PurchaseFlowController.swift | 1 - Sources/Afterpay/AfterpayV3.swift | 3 --- Sources/Afterpay/Checkout/CheckoutV3.swift | 2 -- 3 files changed, 6 deletions(-) diff --git a/Example/Example/Purchase/PurchaseFlowController.swift b/Example/Example/Purchase/PurchaseFlowController.swift index 8f57108b..01b8031c 100644 --- a/Example/Example/Purchase/PurchaseFlowController.swift +++ b/Example/Example/Purchase/PurchaseFlowController.swift @@ -47,7 +47,6 @@ final class PurchaseFlowController: UIViewController { Afterpay.setV3Configuration(.init( shopDirectoryId: "cd6b7914412b407d80aaf81d855d1105", shopDirectoryMerchantId: "822ce7ffc2fa41258904baad1d0fe07351e89375108949e8bd951d387ef0e932", - merchantPublicKey: "", region: .US, environment: .sandbox )) diff --git a/Sources/Afterpay/AfterpayV3.swift b/Sources/Afterpay/AfterpayV3.swift index 06619194..05834d0d 100644 --- a/Sources/Afterpay/AfterpayV3.swift +++ b/Sources/Afterpay/AfterpayV3.swift @@ -152,20 +152,17 @@ public func getV3Configuration() -> CheckoutV3Configuration? { public struct CheckoutV3Configuration { let shopDirectoryId: String let shopDirectoryMerchantId: String - let merchantPublicKey: String let region: Region let environment: Environment public init( shopDirectoryId: String, shopDirectoryMerchantId: String, - merchantPublicKey: String, region: Region, environment: Environment ) { self.shopDirectoryId = shopDirectoryId self.shopDirectoryMerchantId = shopDirectoryMerchantId - self.merchantPublicKey = merchantPublicKey self.region = region self.environment = environment } diff --git a/Sources/Afterpay/Checkout/CheckoutV3.swift b/Sources/Afterpay/Checkout/CheckoutV3.swift index 5c7e6697..fc504faa 100644 --- a/Sources/Afterpay/Checkout/CheckoutV3.swift +++ b/Sources/Afterpay/Checkout/CheckoutV3.swift @@ -14,7 +14,6 @@ enum CheckoutV3 { struct Request: Encodable { let shopDirectoryId: String let shopDirectoryMerchantId: String - let merchantPublicKey: String let amount: Money let shippingAmount: Money? @@ -34,7 +33,6 @@ enum CheckoutV3 { ) { self.shopDirectoryId = configuration.shopDirectoryId self.shopDirectoryMerchantId = configuration.shopDirectoryMerchantId - self.merchantPublicKey = configuration.merchantPublicKey self.amount = Money( amount: configuration.region.formatted(currency: orderTotal.total), From 0ff0178b1b3c517b18b7c807a950a1396edb18b7 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Thu, 15 Jul 2021 13:30:25 +1000 Subject: [PATCH 43/81] Make shopDirectoryId hard coded for sandbox and production --- .../Example/Purchase/PurchaseFlowController.swift | 1 - Sources/Afterpay/AfterpayV3.swift | 12 +++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/Example/Example/Purchase/PurchaseFlowController.swift b/Example/Example/Purchase/PurchaseFlowController.swift index 01b8031c..a18f0b38 100644 --- a/Example/Example/Purchase/PurchaseFlowController.swift +++ b/Example/Example/Purchase/PurchaseFlowController.swift @@ -45,7 +45,6 @@ final class PurchaseFlowController: UIViewController { // This configuration object may also be passed directly into calls to // `Afterpay.presentCheckoutV3Modally` and `Afterpay.fetchMerchantConfiguration` Afterpay.setV3Configuration(.init( - shopDirectoryId: "cd6b7914412b407d80aaf81d855d1105", shopDirectoryMerchantId: "822ce7ffc2fa41258904baad1d0fe07351e89375108949e8bd951d387ef0e932", region: .US, environment: .sandbox diff --git a/Sources/Afterpay/AfterpayV3.swift b/Sources/Afterpay/AfterpayV3.swift index 05834d0d..a0bbeb8b 100644 --- a/Sources/Afterpay/AfterpayV3.swift +++ b/Sources/Afterpay/AfterpayV3.swift @@ -150,18 +150,15 @@ public func getV3Configuration() -> CheckoutV3Configuration? { /// A collection of options and values required to interact with the Afterpay API. public struct CheckoutV3Configuration { - let shopDirectoryId: String let shopDirectoryMerchantId: String let region: Region let environment: Environment public init( - shopDirectoryId: String, shopDirectoryMerchantId: String, region: Region, environment: Environment ) { - self.shopDirectoryId = shopDirectoryId self.shopDirectoryMerchantId = shopDirectoryMerchantId self.region = region self.environment = environment @@ -169,6 +166,15 @@ public struct CheckoutV3Configuration { // MARK: - Computed properties + var shopDirectoryId: String { + switch (region, environment) { + case (.US, .sandbox): + return "cd6b7914412b407d80aaf81d855d1105" + case (.US, .production): + return "e1e5632bebe64cee8e5daff8588e8f2f05ca4ed6ac524c76824c04e09033badc" + } + } + var v3CheckoutUrl: URL { switch (region, environment) { case (.US, .sandbox): From ab52fd448471697fda747e8ff45339f13a3d43c3 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Thu, 15 Jul 2021 15:39:37 +1000 Subject: [PATCH 44/81] Set Afterpay.configuration --- Example/Example/Purchase/PurchaseFlowController.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Example/Example/Purchase/PurchaseFlowController.swift b/Example/Example/Purchase/PurchaseFlowController.swift index a18f0b38..ae94389c 100644 --- a/Example/Example/Purchase/PurchaseFlowController.swift +++ b/Example/Example/Purchase/PurchaseFlowController.swift @@ -71,8 +71,7 @@ final class PurchaseFlowController: UIViewController { Afterpay.fetchMerchantConfiguration { result in switch result { case .success(let configuration): - // Do something with the configuration object here - print(configuration) + Afterpay.setConfiguration(configuration) case .failure(let error): let alert = AlertFactory.alert(for: error.localizedDescription) self.present(alert, animated: true) From 43536344c949a33170fcc6ad8264038aa84da4fa Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Fri, 16 Jul 2021 06:16:05 +1000 Subject: [PATCH 45/81] Make `OrderTotal` explicit about passing in the full amount as `total` Previously, there was a certain level of ambiguity. Now, documentation informs the user that the `total` is required to be inclusive of `shipping` and `tax` --- .../Purchase/PurchaseFlowController.swift | 2 +- Sources/Afterpay/AfterpayV3.swift | 4 +++ Sources/Afterpay/Checkout/CheckoutV3.swift | 25 +++++++------------ Sources/Afterpay/Model/OrderTotal.swift | 21 +++++++++------- 4 files changed, 26 insertions(+), 26 deletions(-) diff --git a/Example/Example/Purchase/PurchaseFlowController.swift b/Example/Example/Purchase/PurchaseFlowController.swift index ae94389c..a4787854 100644 --- a/Example/Example/Purchase/PurchaseFlowController.swift +++ b/Example/Example/Purchase/PurchaseFlowController.swift @@ -135,7 +135,7 @@ final class PurchaseFlowController: UIViewController { Afterpay.presentCheckoutV3Modally( over: ownedNavigationController, consumer: consumer, - orderTotal: OrderTotal(subtotal: cart.total, shipping: nil, tax: 9.999), + orderTotal: OrderTotal(total: cart.total, shipping: 0, tax: 0), items: cart.products, buyNow: cart.checkoutV2Options.buyNow ?? false, requestHandler: APIClient.live.session.dataTask diff --git a/Sources/Afterpay/AfterpayV3.swift b/Sources/Afterpay/AfterpayV3.swift index a0bbeb8b..139bdd04 100644 --- a/Sources/Afterpay/AfterpayV3.swift +++ b/Sources/Afterpay/AfterpayV3.swift @@ -154,6 +154,10 @@ public struct CheckoutV3Configuration { let region: Region let environment: Environment + /// - Parameters: + /// - shopDirectoryMerchantId: A unique merchant identifier + /// - region: The region serviced by the merchant + /// - environment: The environment. Use `sandbox` for development purposes. public init( shopDirectoryMerchantId: String, region: Region, diff --git a/Sources/Afterpay/Checkout/CheckoutV3.swift b/Sources/Afterpay/Checkout/CheckoutV3.swift index fc504faa..e3eb46fa 100644 --- a/Sources/Afterpay/Checkout/CheckoutV3.swift +++ b/Sources/Afterpay/Checkout/CheckoutV3.swift @@ -39,22 +39,15 @@ enum CheckoutV3 { currency: configuration.region.currencyCode ) - if let shipping = orderTotal.shipping { - self.shippingAmount = Money( - amount: configuration.region.formatted(currency: shipping), - currency: configuration.region.currencyCode - ) - } else { - self.shippingAmount = nil - } - if let tax = orderTotal.tax { - self.taxAmount = Money( - amount: configuration.region.formatted(currency: tax), - currency: configuration.region.currencyCode - ) - } else { - self.taxAmount = nil - } + self.shippingAmount = Money( + amount: configuration.region.formatted(currency: orderTotal.shipping), + currency: configuration.region.currencyCode + ) + + self.taxAmount = Money( + amount: configuration.region.formatted(currency: orderTotal.tax), + currency: configuration.region.currencyCode + ) self.items = items.map { Item($0, configuration.region) } diff --git a/Sources/Afterpay/Model/OrderTotal.swift b/Sources/Afterpay/Model/OrderTotal.swift index 3b53c718..3315637e 100644 --- a/Sources/Afterpay/Model/OrderTotal.swift +++ b/Sources/Afterpay/Model/OrderTotal.swift @@ -14,16 +14,19 @@ import Foundation /// - Including the currency code as provided by `CheckoutV3Configuration.Region`. public struct OrderTotal { - public var subtotal: Decimal - public var shipping: Decimal? - public var tax: Decimal? + /// Amount to be charged to consumer, inclusive of `shipping` and `tax`. + public var total: Decimal + /// The shipping amount, included for fraud detection purposes. + public var shipping: Decimal + /// The tax amount, included for fraud detection purposes. + public var tax: Decimal - public var total: Decimal { - subtotal + (shipping ?? 0) + (tax ?? 0) - } - - public init(subtotal: Decimal, shipping: Decimal? = nil, tax: Decimal? = nil) { - self.subtotal = subtotal + /// - Parameters: + /// - total: Amount to be charged to consumer, inclusive of `shipping` and `tax`. + /// - shipping: The shipping amount, included for fraud detection purposes. + /// - tax: The tax amount, included for fraud detection purposes. + public init(total: Decimal, shipping: Decimal, tax: Decimal) { + self.total = total self.shipping = shipping self.tax = tax } From 48433202f7d3751ee3395e6a1c383edcdc1e8e28 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Fri, 16 Jul 2021 06:20:14 +1000 Subject: [PATCH 46/81] Rename updateMerchantReference to updateMerchantReferenceV3 --- .../Purchase/SingleUseCardResultViewController.swift | 2 +- Sources/Afterpay/AfterpayV3.swift | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/Example/Example/Purchase/SingleUseCardResultViewController.swift b/Example/Example/Purchase/SingleUseCardResultViewController.swift index 8158816f..526c8648 100644 --- a/Example/Example/Purchase/SingleUseCardResultViewController.swift +++ b/Example/Example/Purchase/SingleUseCardResultViewController.swift @@ -119,7 +119,7 @@ final class SingleUseCardResultViewController: UIViewController { @objc func update() { updateButton.setTitle("Updating ...", for: .normal) let newId = UUID().uuidString - Afterpay.updateMerchantReference(with: newId, tokens: data.tokens) { [weak self] result in + Afterpay.updateMerchantReferenceV3(with: newId, tokens: data.tokens) { [weak self] result in switch result { case .success: // This endpoint returns a 204, so no response body self?.updateButton.setTitle("Merchant reference updated!", for: .normal) diff --git a/Sources/Afterpay/AfterpayV3.swift b/Sources/Afterpay/AfterpayV3.swift index 139bdd04..d7f8a0bc 100644 --- a/Sources/Afterpay/AfterpayV3.swift +++ b/Sources/Afterpay/AfterpayV3.swift @@ -18,7 +18,7 @@ import UIKit /// - configuration: A collection of options and values required to interact with the Afterpay API. /// - requestHandler: A function that takes a `URLRequest` and a closure to handle the result. /// - completion: The result of the user's completion (a success or cancellation). Returns on the main thread. -public func updateMerchantReference( +public func updateMerchantReferenceV3( with merchantReference: String, tokens: CheckoutV3Tokens, configuration: CheckoutV3Configuration? = getV3Configuration(), @@ -27,8 +27,8 @@ public func updateMerchantReference( ) { guard let configuration = configuration else { return assertionFailure( - "For updateMerchantReference to function you must provide a `configuration` object via either " - + "`Afterpay.updateMerchantReference` or `Afterpay.setV3Configuration`" + "For updateMerchantReferenceV3 to function you must provide a `configuration` object via either " + + "`Afterpay.updateMerchantReferenceV3` or `Afterpay.setV3Configuration`" ) } do { @@ -154,9 +154,10 @@ public struct CheckoutV3Configuration { let region: Region let environment: Environment + /// Creates a collection of options and values required to interact with the Afterpay API. /// - Parameters: - /// - shopDirectoryMerchantId: A unique merchant identifier - /// - region: The region serviced by the merchant + /// - shopDirectoryMerchantId: A unique merchant identifier. + /// - region: The region serviced by the merchant. /// - environment: The environment. Use `sandbox` for development purposes. public init( shopDirectoryMerchantId: String, From 922ad824653f8671d037f966297e55901c819107 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Tue, 20 Jul 2021 16:22:28 +1000 Subject: [PATCH 47/81] Remove most express options from cart --- Example/Example/Purchase/CheckoutOptionsCell.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Example/Example/Purchase/CheckoutOptionsCell.swift b/Example/Example/Purchase/CheckoutOptionsCell.swift index 987f9ddc..982fc842 100644 --- a/Example/Example/Purchase/CheckoutOptionsCell.swift +++ b/Example/Example/Purchase/CheckoutOptionsCell.swift @@ -44,11 +44,11 @@ final class CheckoutOptionsCell: UITableViewCell { let verticalStack = UIStackView( arrangedSubviews: [ - UIStackView(arrangedSubviews: [expressLabel, expressSwitch]), - checkoutOptionsTitle, +// UIStackView(arrangedSubviews: [expressLabel, expressSwitch]), +// checkoutOptionsTitle, UIStackView(arrangedSubviews: [buyNowLabel, buyNowSwitch]), - UIStackView(arrangedSubviews: [pickupLabel, pickupSwitch]), - UIStackView(arrangedSubviews: [shippingOptionRequiredLabel, shippingOptionRequiredSwitch]), +// UIStackView(arrangedSubviews: [pickupLabel, pickupSwitch]), +// UIStackView(arrangedSubviews: [shippingOptionRequiredLabel, shippingOptionRequiredSwitch]), ] ) From 110edc035996f6e711bca9f6c4d9822cbd2b6e43 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Tue, 20 Jul 2021 16:22:46 +1000 Subject: [PATCH 48/81] Replace failing retry url from V3 Checkout --- Sources/Afterpay/Checkout/CheckoutV3ViewController.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Sources/Afterpay/Checkout/CheckoutV3ViewController.swift b/Sources/Afterpay/Checkout/CheckoutV3ViewController.swift index 26666c82..1357da12 100644 --- a/Sources/Afterpay/Checkout/CheckoutV3ViewController.swift +++ b/Sources/Afterpay/Checkout/CheckoutV3ViewController.swift @@ -188,7 +188,7 @@ final class CheckoutV3ViewController: withError error: Error ) { let alert = Alerts.failedToLoad( - retry: { [url = configuration.environment.checkoutBootstrapURL] in + retry: { [url = configuration.v3CheckoutUrl] in webView.load(URLRequest(url: url)) }, cancel: { @@ -300,8 +300,7 @@ final class CheckoutV3ViewController: let confirmation = ConfirmationV3.Request( token: token, - singleUseCardToken: - singleUseCardToken, + singleUseCardToken: singleUseCardToken, ppaConfirmToken: ppaConfirmToken ) From afcffe2d50c48748e0c2fe7e8ceb4e541d876c2d Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Wed, 21 Jul 2021 05:49:38 +1000 Subject: [PATCH 49/81] Update instruction text on SingleUseCardResultViewController --- .../Example/Purchase/SingleUseCardResultViewController.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Example/Example/Purchase/SingleUseCardResultViewController.swift b/Example/Example/Purchase/SingleUseCardResultViewController.swift index 526c8648..3389730f 100644 --- a/Example/Example/Purchase/SingleUseCardResultViewController.swift +++ b/Example/Example/Purchase/SingleUseCardResultViewController.swift @@ -74,6 +74,8 @@ final class SingleUseCardResultViewController: UIViewController { label.text = "After the user has completed their Afterpay checkout, " + "you will have until the `virtual card expiry` to perform an authorisation on the card. " + "This completes the purchase." + + "\nOnce a stable, unique id has been generated for the transaction, " + + "the Afterpay merchant reference must be updated." return label }() From d7deca21ac34d050baa09fe94e7e373ec53f8719 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Tue, 17 Aug 2021 06:18:05 +1000 Subject: [PATCH 50/81] Rename PaymentButton.configuration to avoid name collision Previously, PaymentButton had a private `configuration` property which contained an SVGConfiguration. That property name is used in iOS 15, so we cannot use it for our own private one. We can rename it to something unused, instead. We have renamed it to `svgConfiguration`. --- Sources/Afterpay/Views/PaymentButton.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/Afterpay/Views/PaymentButton.swift b/Sources/Afterpay/Views/PaymentButton.swift index 511fddc3..5adb5e80 100644 --- a/Sources/Afterpay/Views/PaymentButton.swift +++ b/Sources/Afterpay/Views/PaymentButton.swift @@ -19,7 +19,7 @@ public final class PaymentButton: UIButton { didSet { updateImage() } } - private var configuration: SVGConfiguration { + private var svgConfiguration: SVGConfiguration { PaymentButtonConfiguration(colorScheme: colorScheme, buttonKind: buttonKind) } @@ -40,14 +40,14 @@ public final class PaymentButton: UIButton { private func sharedInit() { let locale = getLocale() - let svg = configuration.svg(localizedFor: locale, withTraits: traitCollection) + let svg = svgConfiguration.svg(localizedFor: locale, withTraits: traitCollection) NSLayoutConstraint.activate([ heightAnchor.constraint(equalTo: widthAnchor, multiplier: svg.aspectRatio), widthAnchor.constraint(greaterThanOrEqualToConstant: svg.minimumWidth), ]) - accessibilityLabel = configuration.accessibilityLabel(localizedFor: locale) + accessibilityLabel = svgConfiguration.accessibilityLabel(localizedFor: locale) translatesAutoresizingMaskIntoConstraints = false adjustsImageWhenHighlighted = true adjustsImageWhenDisabled = true @@ -64,8 +64,8 @@ public final class PaymentButton: UIButton { public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) - let svgForTraits = { [configuration] traitCollection in - configuration.svg(localizedFor: getLocale(), withTraits: traitCollection) + let svgForTraits = { [svgConfiguration] traitCollection in + svgConfiguration.svg(localizedFor: getLocale(), withTraits: traitCollection) } if previousTraitCollection.map(svgForTraits) != svgForTraits(traitCollection) { @@ -74,7 +74,7 @@ public final class PaymentButton: UIButton { } private func updateImage() { - let svgView = SVGView(svgConfiguration: configuration) + let svgView = SVGView(svgConfiguration: svgConfiguration) svgView.frame = bounds let renderer = UIGraphicsImageRenderer(size: svgView.bounds.size) From 13b2938765e86169f68724f691ad165b0fc0b90c Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Tue, 17 Aug 2021 06:18:25 +1000 Subject: [PATCH 51/81] Update `TokenizedCard` model to match API --- AfterpayTests/VirtualCardDeserializationTests.swift | 3 +-- .../Purchase/SingleUseCardResultViewController.swift | 2 +- Sources/Afterpay/Model/CheckoutV3Card.swift | 8 +++----- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/AfterpayTests/VirtualCardDeserializationTests.swift b/AfterpayTests/VirtualCardDeserializationTests.swift index 7c7699d8..b6324ab0 100644 --- a/AfterpayTests/VirtualCardDeserializationTests.swift +++ b/AfterpayTests/VirtualCardDeserializationTests.swift @@ -27,9 +27,8 @@ final class VirtualCardDeserializationTests: XCTestCase { func testTokenizedCardIsDeserializedProperly() throws { let json = """ { -"cardType": "VISA", +"paymentGateway": "Braintree", "cardToken": "magical string", -"cvc": "999", "expiry": "2024-02" } """.data(using: .utf8)! diff --git a/Example/Example/Purchase/SingleUseCardResultViewController.swift b/Example/Example/Purchase/SingleUseCardResultViewController.swift index 3389730f..c0c678fd 100644 --- a/Example/Example/Purchase/SingleUseCardResultViewController.swift +++ b/Example/Example/Purchase/SingleUseCardResultViewController.swift @@ -37,8 +37,8 @@ final class SingleUseCardResultViewController: UIViewController { ], at: 0) case .tokenized(let card): strings.insert(contentsOf: [ + "Payment gateway: \(card.paymentGateway)", "Card token: \(card.cardToken)", - "CVC: \(card.cvc)", "Expiration: \(card.expiryMonth)/\(card.expiryYear)", ], at: 0) } diff --git a/Sources/Afterpay/Model/CheckoutV3Card.swift b/Sources/Afterpay/Model/CheckoutV3Card.swift index ef755aeb..3a4bab69 100644 --- a/Sources/Afterpay/Model/CheckoutV3Card.swift +++ b/Sources/Afterpay/Model/CheckoutV3Card.swift @@ -91,14 +91,13 @@ public struct Card: Decodable { } public struct TokenizedCard: Decodable { - public let cardType: String + public let paymentGateway: String public let cardToken: String - public let cvc: String public let expiryMonth: Int public let expiryYear: Int private enum CodingKeys: String, CodingKey { - case cardType, cardToken, cvc, expiry + case paymentGateway, cardToken, expiry } public init(from decoder: Decoder) throws { @@ -129,7 +128,6 @@ public struct TokenizedCard: Decodable { self.expiryYear = year self.expiryMonth = month self.cardToken = try container.decode(String.self, forKey: .cardToken) - self.cvc = try container.decode(String.self, forKey: .cvc) - self.cardType = try container.decode(String.self, forKey: .cardType) + self.paymentGateway = try container.decode(String.self, forKey: .paymentGateway) } } From 097bc8a849c159e604bba7cf99c1b79ce116ba7d Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Tue, 17 Aug 2021 06:51:33 +1000 Subject: [PATCH 52/81] Update Response.PaymentDetails model to support different keys --- Afterpay.xcodeproj/project.pbxproj | 4 -- .../VirtualCardDeserializationTests.swift | 64 ------------------- .../Checkout/CheckoutV3ViewController.swift | 5 +- .../Afterpay/Checkout/ConfirmationV3.swift | 3 +- Sources/Afterpay/Model/CheckoutV3Card.swift | 35 ++-------- 5 files changed, 11 insertions(+), 100 deletions(-) delete mode 100644 AfterpayTests/VirtualCardDeserializationTests.swift diff --git a/Afterpay.xcodeproj/project.pbxproj b/Afterpay.xcodeproj/project.pbxproj index 31ed6e3e..9db9684c 100644 --- a/Afterpay.xcodeproj/project.pbxproj +++ b/Afterpay.xcodeproj/project.pbxproj @@ -68,7 +68,6 @@ 83F7DEF6269EB4CA00F9FB75 /* CheckoutV3Protocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEF5269EB4CA00F9FB75 /* CheckoutV3Protocols.swift */; }; 83F7DEF8269EC4DB00F9FB75 /* OrderTotal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEF7269EC4DB00F9FB75 /* OrderTotal.swift */; }; 83F7DEFA269FA8C400F9FB75 /* CheckoutV3Card.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEF9269FA8C400F9FB75 /* CheckoutV3Card.swift */; }; - 83F7DEFC269FAA3A00F9FB75 /* VirtualCardDeserializationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F7DEFB269FAA3A00F9FB75 /* VirtualCardDeserializationTests.swift */; }; 946388FE24DD077F00A1227A /* InfoWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 946388FD24DD077F00A1227A /* InfoWebViewController.swift */; }; /* End PBXBuildFile section */ @@ -158,7 +157,6 @@ 83F7DEF5269EB4CA00F9FB75 /* CheckoutV3Protocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckoutV3Protocols.swift; sourceTree = ""; }; 83F7DEF7269EC4DB00F9FB75 /* OrderTotal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderTotal.swift; sourceTree = ""; }; 83F7DEF9269FA8C400F9FB75 /* CheckoutV3Card.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckoutV3Card.swift; sourceTree = ""; }; - 83F7DEFB269FAA3A00F9FB75 /* VirtualCardDeserializationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VirtualCardDeserializationTests.swift; sourceTree = ""; }; 946388FD24DD077F00A1227A /* InfoWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoWebViewController.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -255,7 +253,6 @@ 550D481A26255D8600C0B0C6 /* WidgetEventTests.swift */, 550D48142625539900C0B0C6 /* WidgetStatusTests.swift */, 557511BA264259C30040CC51 /* CombineWrapperTests.swift */, - 83F7DEFB269FAA3A00F9FB75 /* VirtualCardDeserializationTests.swift */, ); path = AfterpayTests; sourceTree = ""; @@ -559,7 +556,6 @@ 551BEDF125F98FA800FDF9EE /* FeaturesTests.swift in Sources */, 6635B95F24CAA9F000EBB3A6 /* ConfigurationTests.swift in Sources */, 66DAAC8D24E109D200127460 /* PriceBreakdownTests.swift in Sources */, - 83F7DEFC269FAA3A00F9FB75 /* VirtualCardDeserializationTests.swift in Sources */, 550D481B26255D8600C0B0C6 /* WidgetEventTests.swift in Sources */, 550D48152625539900C0B0C6 /* WidgetStatusTests.swift in Sources */, 557511BB264259C30040CC51 /* CombineWrapperTests.swift in Sources */, diff --git a/AfterpayTests/VirtualCardDeserializationTests.swift b/AfterpayTests/VirtualCardDeserializationTests.swift deleted file mode 100644 index b6324ab0..00000000 --- a/AfterpayTests/VirtualCardDeserializationTests.swift +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright 2021 Itty Bitty Apps Pty Ltd - -import XCTest -@testable import Afterpay - -// swiftlint:disable indentation_width -final class VirtualCardDeserializationTests: XCTestCase { - - func testPlainCardIsDeserializedProperly() throws { - let json = """ -{ -"cardType": "VISA", -"cardNumber": "4444444444444448", -"cvc": "999", -"expiry": "2024-02" -} -""".data(using: .utf8)! - - let model = try JSONDecoder().decode(VirtualCard.self, from: json) - - guard case .card = model else { - XCTFail("Expected VirtualCard.card!") - return - } - } - - func testTokenizedCardIsDeserializedProperly() throws { - let json = """ -{ -"paymentGateway": "Braintree", -"cardToken": "magical string", -"expiry": "2024-02" -} -""".data(using: .utf8)! - - let model = try JSONDecoder().decode(VirtualCard.self, from: json) - - guard case .tokenized = model else { - XCTFail("Expected VirtualCard.card!") - return - } - } - - func testUnknownCardFailsWithProperDescription() throws { - let json = """ -{ -"cardType": "VISA", -"cardWhatNow": "Didn't expect you here", -"cvc": "999", -"expiry": "2024-02" -} -""".data(using: .utf8)! - - do { - _ = try JSONDecoder().decode(VirtualCard.self, from: json) - XCTFail("Expected error to be thrown") - } catch is VirtualCard.Error { - // The correct error was thrown - } catch { - XCTFail("Expected a `VirtualCard.Error`") - } - } - -} diff --git a/Sources/Afterpay/Checkout/CheckoutV3ViewController.swift b/Sources/Afterpay/Checkout/CheckoutV3ViewController.swift index 1357da12..af89da88 100644 --- a/Sources/Afterpay/Checkout/CheckoutV3ViewController.swift +++ b/Sources/Afterpay/Checkout/CheckoutV3ViewController.swift @@ -260,13 +260,14 @@ final class CheckoutV3ViewController: guard let token = self.token, let ppaConfirmToken = self.ppaConfirmToken, - let singleUseCardToken = self.singleUseCardToken + let singleUseCardToken = self.singleUseCardToken, + let cardDetails = VirtualCard(paymentDetails: response.paymentDetails) else { return } let result = CheckoutV3.ResultData( - cardDetails: response.paymentDetails.virtualCard, + cardDetails: cardDetails, cardValidUntil: response.cardValidUntil, tokens: CheckoutV3.ResultTokens( token: token, diff --git a/Sources/Afterpay/Checkout/ConfirmationV3.swift b/Sources/Afterpay/Checkout/ConfirmationV3.swift index f910f7ac..49ceb73d 100644 --- a/Sources/Afterpay/Checkout/ConfirmationV3.swift +++ b/Sources/Afterpay/Checkout/ConfirmationV3.swift @@ -23,7 +23,8 @@ enum ConfirmationV3 { let authToken: String struct PaymentDetails: Decodable { - let virtualCard: VirtualCard + let virtualCard: Card? + let virtualCardToken: TokenizedCard? } } diff --git a/Sources/Afterpay/Model/CheckoutV3Card.swift b/Sources/Afterpay/Model/CheckoutV3Card.swift index 3a4bab69..0151d35b 100644 --- a/Sources/Afterpay/Model/CheckoutV3Card.swift +++ b/Sources/Afterpay/Model/CheckoutV3Card.swift @@ -8,41 +8,18 @@ import Foundation -public enum VirtualCard: Decodable { +public enum VirtualCard { case card(Card) case tokenized(TokenizedCard) - public init(from decoder: Decoder) throws { - if let card = try? Card(from: decoder) { + init?(paymentDetails: ConfirmationV3.Response.PaymentDetails) { + if let card = paymentDetails.virtualCard { self = .card(card) return + } else if let tokenized = paymentDetails.virtualCardToken { + self = .tokenized(tokenized) } - do { - let tokenizedCard = try TokenizedCard(from: decoder) - self = .tokenized(tokenizedCard) - } catch { - guard error is DecodingError else { - throw error - } - throw Error(message: - "Could not parse expected `\(VirtualCard.self)` as `\(Card.self)` or `\(TokenizedCard.self)`" - ) - } - } - - public struct Error: LocalizedError { - let message: String - - public var failureReason: String? { - NSLocalizedString(message, comment: "Failure reason") - } - - public var recoverySuggestion: String? { - NSLocalizedString( - "Please contact Afterpay", - comment: "Recovery suggestion for a `VirtualCard` parsing error" - ) - } + return nil } } From 54ce28fdaf6dee3e98ffd1c643a5803f86899eec Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Tue, 17 Aug 2021 08:38:25 +1000 Subject: [PATCH 53/81] Add missing return statement to CheckoutV3Card --- Sources/Afterpay/Model/CheckoutV3Card.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/Afterpay/Model/CheckoutV3Card.swift b/Sources/Afterpay/Model/CheckoutV3Card.swift index 0151d35b..cf681e15 100644 --- a/Sources/Afterpay/Model/CheckoutV3Card.swift +++ b/Sources/Afterpay/Model/CheckoutV3Card.swift @@ -18,6 +18,7 @@ public enum VirtualCard { return } else if let tokenized = paymentDetails.virtualCardToken { self = .tokenized(tokenized) + return } return nil } From 96a7694eb096e92f99ea7ca7bb47019ad2aa7720 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Fri, 27 Aug 2021 07:37:34 +1000 Subject: [PATCH 54/81] Add support for Canadian locale to Button API --- Sources/Afterpay/AfterpayV3.swift | 44 ++++++++++++++++++------------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/Sources/Afterpay/AfterpayV3.swift b/Sources/Afterpay/AfterpayV3.swift index d7f8a0bc..71b59d68 100644 --- a/Sources/Afterpay/AfterpayV3.swift +++ b/Sources/Afterpay/AfterpayV3.swift @@ -173,40 +173,40 @@ public struct CheckoutV3Configuration { var shopDirectoryId: String { switch (region, environment) { - case (.US, .sandbox): + case (_, .sandbox): return "cd6b7914412b407d80aaf81d855d1105" - case (.US, .production): + case (_, .production): return "e1e5632bebe64cee8e5daff8588e8f2f05ca4ed6ac524c76824c04e09033badc" } } - var v3CheckoutUrl: URL { + var v3BaseUrl: URL { switch (region, environment) { case (.US, .sandbox): return URL(string: "https://api-plus.us-sandbox.afterpay.com/v3/button")! case (.US, .production): return URL(string: "https://api-plus.us.afterpay.com/v3/button")! + // Currently the same URL as the US region + case (.CA, .sandbox): + return URL(string: "https://api-plus.us-sandbox.afterpay.com/v3/button")! + case (.CA, .production): + return URL(string: "https://api-plus.us.afterpay.com/v3/button")! } } + var v3CheckoutUrl: URL { + self.v3BaseUrl + } + var v3CheckoutConfirmationUrl: URL { - switch (region, environment) { - case (.US, .sandbox): - return URL(string: "https://api-plus.us-sandbox.afterpay.com/v3/button/confirm")! - case (.US, .production): - return URL(string: "https://api-plus.us.afterpay.com/v3/button/confirm")! - } + v3BaseUrl.appendingPathComponent("confirm") } var v3ConfigurationUrl: URL { - var url: URL - switch (region, environment) { - case (.US, .sandbox): - url = URL(string: "https://api-plus.us-sandbox.afterpay.com/v3/button/merchant/config")! - case (.US, .production): - url = URL(string: "https://api-plus.us.afterpay.com/v3/button/merchant/config")! - } - var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + var components = URLComponents( + url: v3BaseUrl.appendingPathComponent("merchant/config"), + resolvingAgainstBaseURL: false + ) components?.queryItems = [ URLQueryItem(name: "shopDirectoryId", value: shopDirectoryId), URLQueryItem(name: "shopDirectoryMerchantId", value: shopDirectoryMerchantId), @@ -222,17 +222,23 @@ public struct CheckoutV3Configuration { /// Regions supporting V3 checkouts public enum Region { case US + case CA var locale: Locale { switch self { case .US: return Locales.unitedStates + case .CA: return Locales.canada } } var currencyCode: String { - switch self { - case .US: return "USD" + guard let currencyCode = self.locale.currencyCode else { + switch self { + case .US: return "USD" + case .CA: return "CAD" + } } + return currencyCode } private static var formatter: NumberFormatter = { From 121ae1ec23ef8722b81c112a5fa37d1f19dbc641 Mon Sep 17 00:00:00 2001 From: Chris Kolbu Date: Fri, 27 Aug 2021 08:26:53 +1000 Subject: [PATCH 55/81] Update unit test relying on server configuration --- Example/ExampleUITests/ExampleUITests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Example/ExampleUITests/ExampleUITests.swift b/Example/ExampleUITests/ExampleUITests.swift index e29ecf2f..84a96bb3 100644 --- a/Example/ExampleUITests/ExampleUITests.swift +++ b/Example/ExampleUITests/ExampleUITests.swift @@ -48,14 +48,14 @@ final class ExampleUITests: XCTestCase { XCTAssertTrue(webViewText.label.contains(#"token":null"#)) XCTAssertTrue(webViewText.label.contains(#"amount":"200.00"#)) - XCTAssertTrue(webViewText.label.contains(#"currency":"AUD"#)) + XCTAssertTrue(webViewText.label.contains(#"currency":"USD"#)) let textField = app.textFields.firstMatch textField.tap() textField.typeText("444") app.buttons["Update"].tap() - XCTAssertTrue(webViewText.label.contains(#"{"amount":"444","currency":"AUD"}"#)) + XCTAssertTrue(webViewText.label.contains(#"{"amount":"444","currency":"USD"}"#)) } } From 4f50ae924289dac6ddcf39b5756d60a8f4568d22 Mon Sep 17 00:00:00 2001 From: Austin Pederson Date: Wed, 29 Sep 2021 10:12:52 -0700 Subject: [PATCH 56/81] fixed issue where SWXMLHash was being updated for cocoapods users above v5.0.1 --- Afterpay.podspec | 1 + 1 file changed, 1 insertion(+) diff --git a/Afterpay.podspec b/Afterpay.podspec index db4f473c..10d5317b 100644 --- a/Afterpay.podspec +++ b/Afterpay.podspec @@ -16,4 +16,5 @@ Pod::Spec.new do |spec| spec.framework = "UIKit" spec.dependency "Macaw", "0.9.7" + spec.dependency "SWXMLHash", "5.0.1" end From a47d36bc139efb343a1e0daa685367086297a716 Mon Sep 17 00:00:00 2001 From: Scott Antonac Date: Fri, 1 Oct 2021 13:19:31 +1000 Subject: [PATCH 57/81] bump Afterpay version to 3.0.5 --- Configurations/Afterpay-Shared.xcconfig | 2 +- README.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Configurations/Afterpay-Shared.xcconfig b/Configurations/Afterpay-Shared.xcconfig index 9c8843ab..fcd8bf17 100644 --- a/Configurations/Afterpay-Shared.xcconfig +++ b/Configurations/Afterpay-Shared.xcconfig @@ -169,4 +169,4 @@ TARGETED_DEVICE_FAMILY = 1,2 // This setting defines the user-visible version of the project. The value corresponds to // the `CFBundleShortVersionString` key in your app's Info.plist. -MARKETING_VERSION = 3.0.1 +MARKETING_VERSION = 3.0.5 diff --git a/README.md b/README.md index fc5f3162..f34789b0 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ This is the recommended integration method. ``` dependencies: [ - .package(url: "https://github.com/afterpay/sdk-ios.git", .upToNextMajor(from: "3.0.1")) + .package(url: "https://github.com/afterpay/sdk-ios.git", .upToNextMajor(from: "3.0.5")) ] ``` @@ -109,7 +109,7 @@ Add the Afterpay SDK as a [git submodule][git-submodule] by navigating to the ro ``` git submodule add https://github.com/afterpay/sdk-ios.git Afterpay cd Afterpay -git checkout 3.0.1 +git checkout 5 ``` #### Project / Workspace Integration From 679522fc04bf62b5f6292a71497cf58ebeed09dd Mon Sep 17 00:00:00 2001 From: Scott Antonac Date: Fri, 1 Oct 2021 09:16:40 +1000 Subject: [PATCH 58/81] fix readme typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f34789b0..e2f2caf0 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ Add the Afterpay SDK as a [git submodule][git-submodule] by navigating to the ro ``` git submodule add https://github.com/afterpay/sdk-ios.git Afterpay cd Afterpay -git checkout 5 +git checkout 3.0.5 ``` #### Project / Workspace Integration From cd8dad25d1c1ea49feac8ea2d8a4fa4137880154 Mon Sep 17 00:00:00 2001 From: Huw Rowlands Date: Fri, 1 Oct 2021 10:12:21 +1000 Subject: [PATCH 59/81] Update GitHub Actions Xcode to 12.5.1 GitHub Actions no longer supports Xcode 12.0. We will now use 12.5.1. Available options at the time of writing are: 13.0 (beta), 13.0, 12.5.1, 12.5, 12.4, 11.7. We chose 12.5.1 because 13 has been having stability issues for many people. --- .github/workflows/build-and-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 43b05175..a4a387f2 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -2,7 +2,7 @@ name: Build and Test env: afterpay-scheme: Afterpay - DEVELOPER_DIR: /Applications/Xcode_12.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_12.5.1.app/Contents/Developer on: push: From 95035a445d7bd216c65a85ac3adb3f837cd6ec20 Mon Sep 17 00:00:00 2001 From: Huw Rowlands Date: Fri, 1 Oct 2021 10:24:53 +1000 Subject: [PATCH 60/81] Bump simulator version to 14.5 --- .github/workflows/build-and-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index a4a387f2..cb565448 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -17,7 +17,7 @@ jobs: runs-on: macos-latest env: - destination: platform=iOS Simulator,name=iPhone 11,OS=14.0 + destination: platform=iOS Simulator,name=iPhone 11,OS=14.5 example-scheme: Example example-ui-test-scheme: ExampleUITests workspace: Afterpay.xcworkspace From 9f27ef02e40188092bdcab49417d1285d5819d60 Mon Sep 17 00:00:00 2001 From: Scott Antonac Date: Fri, 18 Mar 2022 09:48:45 +1100 Subject: [PATCH 61/81] update package.resolved --- Afterpay.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Afterpay.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Afterpay.xcworkspace/xcshareddata/swiftpm/Package.resolved index acf5dbb7..d7532936 100644 --- a/Afterpay.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Afterpay.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/datatheorem/TrustKit", "state" : { - "revision" : "714fd3fcdcada5b107d91bf6caaaefb00f792730", - "version" : "1.6.5" + "revision" : "3c953558d61fdd9b136d981764e3242bd92b2648", + "version" : "1.7.0" } } ], From 237adcf5994dfc0d448d8fbed9a21369bff474c9 Mon Sep 17 00:00:00 2001 From: Scott Antonac Date: Wed, 6 Apr 2022 15:15:20 +1000 Subject: [PATCH 62/81] update package resolved with version 1 --- .../xcshareddata/swiftpm/Package.resolved | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/Afterpay.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Afterpay.xcworkspace/xcshareddata/swiftpm/Package.resolved index d7532936..db90ae44 100644 --- a/Afterpay.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Afterpay.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,14 +1,16 @@ { - "pins" : [ - { - "identity" : "trustkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/datatheorem/TrustKit", - "state" : { - "revision" : "3c953558d61fdd9b136d981764e3242bd92b2648", - "version" : "1.7.0" + "object": { + "pins": [ + { + "package": "TrustKit", + "repositoryURL": "https://github.com/datatheorem/TrustKit", + "state": { + "branch": null, + "revision": "3c953558d61fdd9b136d981764e3242bd92b2648", + "version": "1.7.0" + } } - } - ], - "version" : 2 + ] + }, + "version": 1 } From a552c4ad63b427637a7bf58c644631b0638f4734 Mon Sep 17 00:00:00 2001 From: Scott Antonac Date: Tue, 26 Apr 2022 09:24:37 +1000 Subject: [PATCH 63/81] remove grouping separator in sent amount for v3 --- AfterpayTests/CurrencyFormatterTests.swift | 4 ++++ Sources/Afterpay/AfterpayV3.swift | 1 + 2 files changed, 5 insertions(+) diff --git a/AfterpayTests/CurrencyFormatterTests.swift b/AfterpayTests/CurrencyFormatterTests.swift index f033568f..d94381d7 100644 --- a/AfterpayTests/CurrencyFormatterTests.swift +++ b/AfterpayTests/CurrencyFormatterTests.swift @@ -110,6 +110,10 @@ class CurrencyFormatterTests: XCTestCase { XCTAssertEqual(region.formatted(currency: 9.999), "10") XCTAssertEqual(region.formatted(currency: 9.995), "9.99") XCTAssertEqual(region.formatted(currency: 9.996), "10") + // This test was added to make sure that the grouping seperator + // was omitted from the formatted currency + // ie 1196.996 should not return 1,197 but 1197 + XCTAssertEqual(region.formatted(currency: 1196.996), "1197") } } diff --git a/Sources/Afterpay/AfterpayV3.swift b/Sources/Afterpay/AfterpayV3.swift index 71b59d68..f5dcbaad 100644 --- a/Sources/Afterpay/AfterpayV3.swift +++ b/Sources/Afterpay/AfterpayV3.swift @@ -247,6 +247,7 @@ public struct CheckoutV3Configuration { // ISO 4217 specifies 2 decimal points formatter.maximumFractionDigits = 2 formatter.roundingMode = .halfEven // Banker's rounding + formatter.groupingSeparator = "" return formatter }() From 2fd2f07077efdabbba3048b207e1c8100e30d871 Mon Sep 17 00:00:00 2001 From: Scott Antonac Date: Mon, 25 Jul 2022 18:43:01 +1000 Subject: [PATCH 64/81] change naming of locales --- Sources/Afterpay/AfterpayV3.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Afterpay/AfterpayV3.swift b/Sources/Afterpay/AfterpayV3.swift index f5dcbaad..45692e17 100644 --- a/Sources/Afterpay/AfterpayV3.swift +++ b/Sources/Afterpay/AfterpayV3.swift @@ -226,8 +226,8 @@ public struct CheckoutV3Configuration { var locale: Locale { switch self { - case .US: return Locales.unitedStates - case .CA: return Locales.canada + case .US: return Locales.enUS + case .CA: return Locales.enCA } } From 0852ba6b3f25f00484ceb2792c72798ac96711c1 Mon Sep 17 00:00:00 2001 From: Scott Antonac Date: Thu, 8 Sep 2022 11:21:01 +1000 Subject: [PATCH 65/81] docs: add v3 specifics to readme.md --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.md b/README.md index e92aa215..9eddfb9b 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,26 @@ # Documentation Documentation for usage can be found [here][docs], including the [getting started][docs-getting-started] guide and [UI component][docs-ui] docs. +# Checkout V3 + +```swift +Afterpay.presentCheckoutV3Modally(over:consumer:total:items:animated:configuration:requestHandler:completion:) +``` + +Checkout version 3 returns a unique single use card for you to use in your existing checkout flow. + +The configuration object may be set using `setV3Configuration`, or passed into the checkout call. + +## Configuration + +```swift +Afterpay.fetchMerchantConfiguration(configuration:requestHandler:completion:) +``` + +As v3 removes the need for merchant integration with the Afterpay API, the `Configuration` — providing information about minimum and maximum order amounts — is now available through the SDK. + +The configuration object may be set using `setV3Configuration`, or passed into the checkout call. + # Contributing Contributions are welcome! Please read our [contributing guidelines][contributing]. From 7edf9f555307c70fe1cc2fb00d159ffdff883850 Mon Sep 17 00:00:00 2001 From: Scott Antonac Date: Thu, 6 Jul 2023 08:59:11 +1000 Subject: [PATCH 66/81] fix missing import --- Example/Example/Purchase/CheckoutOptionsCell.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Example/Example/Purchase/CheckoutOptionsCell.swift b/Example/Example/Purchase/CheckoutOptionsCell.swift index 982fc842..0e08774d 100644 --- a/Example/Example/Purchase/CheckoutOptionsCell.swift +++ b/Example/Example/Purchase/CheckoutOptionsCell.swift @@ -8,6 +8,7 @@ import Afterpay import Foundation +import UIKit final class CheckoutOptionsCell: UITableViewCell { From 4f6839637401a0641a4cde5b7265ca537573f6d1 Mon Sep 17 00:00:00 2001 From: Mark Mroz Date: Fri, 31 May 2024 15:36:28 -0400 Subject: [PATCH 67/81] Button with Cash App Pay --- Afterpay.xcodeproj/project.pbxproj | 8 ++ Sources/Afterpay/AfterpayV3.swift | 100 ++++++++++++++++++ .../Afterpay/CashApp/CashAppPayCheckout.swift | 77 ++++++++++++++ .../CashApp/CheckoutV3CashAppPayResult.swift | 21 ++++ .../CashApp/ConfirmationV3+CashAppPay.swift | 27 +++++ Sources/Afterpay/Checkout/CheckoutV3.swift | 5 + .../Afterpay/Checkout/ConfirmationV3.swift | 10 +- 7 files changed, 243 insertions(+), 5 deletions(-) create mode 100644 Sources/Afterpay/CashApp/CheckoutV3CashAppPayResult.swift create mode 100644 Sources/Afterpay/CashApp/ConfirmationV3+CashAppPay.swift diff --git a/Afterpay.xcodeproj/project.pbxproj b/Afterpay.xcodeproj/project.pbxproj index e450de79..2e96e84b 100644 --- a/Afterpay.xcodeproj/project.pbxproj +++ b/Afterpay.xcodeproj/project.pbxproj @@ -48,6 +48,8 @@ 557511BF2644CAA50040CC51 /* WKWebView+Cache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 557511BE2644CAA50040CC51 /* WKWebView+Cache.swift */; }; 557511C12644D0890040CC51 /* WKWebViewConfiguration+UserAgent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 557511C02644D0890040CC51 /* WKWebViewConfiguration+UserAgent.swift */; }; 55A2D307261BB36C00D8E23A /* Money.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55A2D306261BB36C00D8E23A /* Money.swift */; }; + 5FB958DA2C0A526800137468 /* CheckoutV3CashAppPayResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FB958D92C0A526800137468 /* CheckoutV3CashAppPayResult.swift */; }; + 5FB958DC2C0A528300137468 /* ConfirmationV3+CashAppPay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FB958DB2C0A528300137468 /* ConfirmationV3+CashAppPay.swift */; }; 6602EF0F25358A8000A0468C /* ColorScheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6602EF0E25358A8000A0468C /* ColorScheme.swift */; }; 6605666324E5199500DA588E /* Locales.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6605666224E5199500DA588E /* Locales.swift */; }; 66169312257A06B200DF6CF4 /* CheckoutV2Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66169311257A06B200DF6CF4 /* CheckoutV2Message.swift */; }; @@ -140,6 +142,8 @@ 557511BE2644CAA50040CC51 /* WKWebView+Cache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WKWebView+Cache.swift"; sourceTree = ""; }; 557511C02644D0890040CC51 /* WKWebViewConfiguration+UserAgent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WKWebViewConfiguration+UserAgent.swift"; sourceTree = ""; }; 55A2D306261BB36C00D8E23A /* Money.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Money.swift; sourceTree = ""; }; + 5FB958D92C0A526800137468 /* CheckoutV3CashAppPayResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckoutV3CashAppPayResult.swift; sourceTree = ""; }; + 5FB958DB2C0A528300137468 /* ConfirmationV3+CashAppPay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConfirmationV3+CashAppPay.swift"; sourceTree = ""; }; 6602EF0E25358A8000A0468C /* ColorScheme.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ColorScheme.swift; sourceTree = ""; }; 6605666224E5199500DA588E /* Locales.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Locales.swift; sourceTree = ""; }; 66169311257A06B200DF6CF4 /* CheckoutV2Message.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckoutV2Message.swift; sourceTree = ""; }; @@ -233,6 +237,8 @@ 4509D358294017C500952DAD /* CashAppSigningResponse.swift */, 4509D356293FFB5200952DAD /* CashAppPayCheckout.swift */, 458E7D36296E1F9D001B696F /* CashAppSigningResult.swift */, + 5FB958D92C0A526800137468 /* CheckoutV3CashAppPayResult.swift */, + 5FB958DB2C0A528300137468 /* ConfirmationV3+CashAppPay.swift */, ); path = CashApp; sourceTree = ""; @@ -633,8 +639,10 @@ 6602EF0F25358A8000A0468C /* ColorScheme.swift in Sources */, 42087EE727A746F700BE5442 /* MoreInfoOptions.swift in Sources */, 66B5458C256B65B7002B3DD5 /* CheckoutHost.swift in Sources */, + 5FB958DA2C0A526800137468 /* CheckoutV3CashAppPayResult.swift in Sources */, 66169312257A06B200DF6CF4 /* CheckoutV2Message.swift in Sources */, 45144E7427FD11470061EBE8 /* AfterpayBundleFinder.swift in Sources */, + 5FB958DC2C0A528300137468 /* ConfirmationV3+CashAppPay.swift in Sources */, 666818202591CB9800A2003E /* Alerts.swift in Sources */, 45144E7027FCEFA30061EBE8 /* LockupView.swift in Sources */, 6689536C24C96CB5005090B4 /* Configuration.swift in Sources */, diff --git a/Sources/Afterpay/AfterpayV3.swift b/Sources/Afterpay/AfterpayV3.swift index 45692e17..ed9014e5 100644 --- a/Sources/Afterpay/AfterpayV3.swift +++ b/Sources/Afterpay/AfterpayV3.swift @@ -9,6 +9,104 @@ import Foundation import UIKit +// MARK: - Checkout V3 with Cash App Pay + +/// Creates a Button Checkout and signs the order for Cash App Pay. +/// - Parameters: +/// - consumer: The personal details of the customer, including shipping and billing addresses. +/// - orderTotal: The order total: `Decimal`s representing the subtotal, tax and shipping. +/// - items: An optional array of items that will be added to the checkout. +/// These are not used to calculate the total amount due. +/// - animated: Pass `true` to animate the presentation; otherwise, pass false. +/// - configuration: A collection of options and values required to interact with the Afterpay API. +/// Defaults to the current V3Configuration. +/// - requestHandler: A function that takes a `URLRequest` and a closure to handle the result, +/// and returns a `URLSessionDataTask`. Defaults to `URLSession.shared.dataTask`. +/// - completion: The `CheckoutV3CashAppPayResult` on the main thread. +public func checkoutV3WithCashAppPay( + consumer: CheckoutV3Consumer, + orderTotal: OrderTotal, + items: [CheckoutV3Item] = [], + configuration: CheckoutV3Configuration? = getV3Configuration(), + requestHandler: @escaping URLRequestHandler = URLSession.shared.dataTask, + completion: @escaping (_ result: CheckoutV3CashAppPayResult) -> Void +) { + guard let configuration = configuration else { + return assertionFailure( + "For checkoutV3WithCashAppPay to function you must provide a `configuration` object via either " + + "`Afterpay.fetchMerchantConfiguration` or `Afterpay.setV3Configuration`" + ) + } + + CashAppPayCheckout.checkoutV3( + consumer: consumer, + orderTotal: orderTotal, + items: items, + configuration: configuration, + requestHandler: requestHandler) { checkoutResult in + switch checkoutResult { + case .success(let checkout): + signCashAppOrderToken(checkout.token) { signingResult in + switch signingResult { + case .success(let signingData): + let response = CheckoutV3CashAppPayPayload( + token: checkout.token, + singleUseCardToken: checkout.singleUseCardToken, + cashAppSigningData: signingData + ) + completion(.success(data: response)) + case .failed(let reason): + completion(.cancelled(reason: reason)) + } + } + case .failure(let error): + completion(.failure(error: error)) + } + } +} + +// MARK: - Confirm Checkout V3 with Cash App Pay + +/// Confirm the payment with the Cash App Pay CustomerID and GrantID. +/// - Parameters: +/// - consumer: The personal details of the customer, including shipping and billing addresses. +/// - orderTotal: The order total: `Decimal`s representing the subtotal, tax and shipping. +/// - items: An optional array of items that will be added to the checkout. +/// These are not used to calculate the total amount due. +/// - animated: Pass `true` to animate the presentation; otherwise, pass false. +/// - configuration: A collection of options and values required to interact with the Afterpay API. +/// - requestHandler: A function that takes a `URLRequest` and a closure to handle the result, +/// and returns a `URLSessionDataTask`. Defaults to `URLSession.shared.dataTask`. +/// - completion: The `ConfirmationV3.Response` on the main thread. +// swiftlint:disable:next function_parameter_count +public func checkoutV3ConfirmForCashAppPay( + token: String, + singleUseCardToken: String, + cashAppPayCustomerID: String, + cashAppPayGrantID: String, + jwt: String, + configuration: CheckoutV3Configuration? = getV3Configuration(), + requestHandler: @escaping URLRequestHandler = URLSession.shared.dataTask, + completion: @escaping (Result) -> Void +) { + guard let configuration = configuration else { + return assertionFailure( + "For fetchMerchantConfiguration to function you must provide a `configuration` object via either " + + "`Afterpay.fetchMerchantConfiguration` or `Afterpay.setV3Configuration`" + ) + } + + CashAppPayCheckout.checkoutV3Confirm( + token: token, + singleUseCardToken: singleUseCardToken, + cashAppPayCustomerID: cashAppPayCustomerID, + cashAppPayGrantID: cashAppPayGrantID, + jwt: jwt, + configuration: configuration, + requestHandler: requestHandler, + completion: completion) +} + // MARK: - Update merchant reference /// Updates Afterpay's merchant reference for the transaction represented by the provided `tokens`. @@ -138,6 +236,8 @@ public func presentCheckoutV3Modally( viewController.present(viewControllerToPresent, animated: animated, completion: nil) } +// MARK: - Configuration + private var checkoutV3Configuration: CheckoutV3Configuration? public func setV3Configuration(_ configuration: CheckoutV3Configuration) { diff --git a/Sources/Afterpay/CashApp/CashAppPayCheckout.swift b/Sources/Afterpay/CashApp/CashAppPayCheckout.swift index 57642879..4746d1ad 100644 --- a/Sources/Afterpay/CashApp/CashAppPayCheckout.swift +++ b/Sources/Afterpay/CashApp/CashAppPayCheckout.swift @@ -186,3 +186,80 @@ class CashAppPayCheckout { }.resume() } } + +// MARK: - CheckoutV3 + +internal extension CashAppPayCheckout { + // swiftlint:disable:next function_parameter_count + static func checkoutV3Confirm( + token: String, + singleUseCardToken: String, + cashAppPayCustomerID: String, + cashAppPayGrantID: String, + jwt: String, + configuration: CheckoutV3Configuration, + requestHandler: @escaping URLRequestHandler, + completion: @escaping (Result) -> Void + ) { + let parameters = ConfirmationV3.CashAppPayRequest( + token: token, + singleUseCardToken: singleUseCardToken, + cashAppPspInfo: ConfirmationV3.CashAppPayRequest.CashAppPspInfo( + externalCustomerId: cashAppPayCustomerID, + externalGrantId: cashAppPayGrantID, + jwt: jwt + ) + ) + + var request = ApiV3.request(from: configuration.v3CheckoutConfirmationUrl) + do { + request.httpBody = try JSONEncoder().encode(parameters) + } catch { + completion(.failure(error)) + } + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + ApiV3.request( + requestHandler, request, + type: ConfirmationV3.CashAppPayResponse.self, + completion: completion + ).resume() + } + + // swiftlint:disable:next function_parameter_count + static func checkoutV3( + consumer: CheckoutV3Consumer, + orderTotal: OrderTotal, + items: [CheckoutV3Item] = [], + configuration: CheckoutV3Configuration, + requestHandler: @escaping URLRequestHandler, + completion: @escaping (Result) -> Void + ) { + let parameters = CheckoutV3.Request( + consumer: consumer, + orderTotal: orderTotal, + items: items, + isCashAppPay: true, + configuration: configuration + ) + + var request = ApiV3.request(from: configuration.v3CheckoutUrl) + do { + request.httpBody = try JSONEncoder().encode(parameters) + } catch { + completion(.failure(error)) + return + } + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + ApiV3.request( + requestHandler, request, + type: CheckoutV3.Response.self, + completion: completion + ).resume() + } +} diff --git a/Sources/Afterpay/CashApp/CheckoutV3CashAppPayResult.swift b/Sources/Afterpay/CashApp/CheckoutV3CashAppPayResult.swift new file mode 100644 index 00000000..81fe1c06 --- /dev/null +++ b/Sources/Afterpay/CashApp/CheckoutV3CashAppPayResult.swift @@ -0,0 +1,21 @@ +// +// CheckoutV3CashAppPayResult.swift +// Afterpay +// +// Created by Mark Mroz on 2024-05-31. +// Copyright © 2024 Afterpay. All rights reserved. +// + +import Foundation + +public struct CheckoutV3CashAppPayPayload { + public let token: Token + public let singleUseCardToken: String + public let cashAppSigningData: CashAppSigningData +} + +@frozen public enum CheckoutV3CashAppPayResult { + case success(data: CheckoutV3CashAppPayPayload) + case cancelled(reason: CashAppSigningResult.CashAppSigningCancellationReason) + case failure(error: Error) +} diff --git a/Sources/Afterpay/CashApp/ConfirmationV3+CashAppPay.swift b/Sources/Afterpay/CashApp/ConfirmationV3+CashAppPay.swift new file mode 100644 index 00000000..3a4006d1 --- /dev/null +++ b/Sources/Afterpay/CashApp/ConfirmationV3+CashAppPay.swift @@ -0,0 +1,27 @@ +// +// ConfirmationV3+CashAppPay.swift .swift +// Afterpay +// +// Created by Mark Mroz on 2024-05-31. +// Copyright © 2024 Afterpay. All rights reserved. +// + +import Foundation + +extension ConfirmationV3 { + struct CashAppPayRequest: Encodable { + struct CashAppPspInfo: Encodable { + let externalCustomerId: String + let externalGrantId: String + let jwt: String + } + let token: String + let singleUseCardToken: String + let cashAppPspInfo: CashAppPspInfo + } + + public struct CashAppPayResponse: Decodable { + public let paymentDetails: ConfirmationV3.Response.PaymentDetails + public let cardValidUntil: Date? + } +} diff --git a/Sources/Afterpay/Checkout/CheckoutV3.swift b/Sources/Afterpay/Checkout/CheckoutV3.swift index e3eb46fa..dc23c66a 100644 --- a/Sources/Afterpay/Checkout/CheckoutV3.swift +++ b/Sources/Afterpay/Checkout/CheckoutV3.swift @@ -25,10 +25,13 @@ enum CheckoutV3 { let shipping: Contact? let billing: Contact? + let isCashAppPay: Bool + init( consumer: CheckoutV3Consumer, orderTotal: OrderTotal, items: [CheckoutV3Item] = [], + isCashAppPay: Bool = false, configuration: CheckoutV3Configuration ) { self.shopDirectoryId = configuration.shopDirectoryId @@ -60,6 +63,8 @@ enum CheckoutV3 { self.shipping = Contact(consumer.shippingInformation) self.billing = Contact(consumer.billingInformation) + + self.isCashAppPay = isCashAppPay } // MARK: - Inner types diff --git a/Sources/Afterpay/Checkout/ConfirmationV3.swift b/Sources/Afterpay/Checkout/ConfirmationV3.swift index 49ceb73d..e2840ff5 100644 --- a/Sources/Afterpay/Checkout/ConfirmationV3.swift +++ b/Sources/Afterpay/Checkout/ConfirmationV3.swift @@ -9,7 +9,7 @@ import Foundation // swiftlint:disable nesting -enum ConfirmationV3 { +public enum ConfirmationV3 { struct Request: Encodable { let token: String @@ -17,14 +17,14 @@ enum ConfirmationV3 { let ppaConfirmToken: String } - struct Response: Decodable { + public struct Response: Decodable { let paymentDetails: PaymentDetails let cardValidUntil: Date? let authToken: String - struct PaymentDetails: Decodable { - let virtualCard: Card? - let virtualCardToken: TokenizedCard? + public struct PaymentDetails: Decodable { + public let virtualCard: Card? + public let virtualCardToken: TokenizedCard? } } From 38d4d4a3dc77ae0ebe58fed426158105073b7611 Mon Sep 17 00:00:00 2001 From: Mark Mroz Date: Mon, 3 Jun 2024 13:44:07 -0400 Subject: [PATCH 68/81] Added tests for Afterpay --- Afterpay.xcodeproj/project.pbxproj | 16 ++ AfterpayTests/AfterpayV3Tests.swift | 180 ++++++++++++++++++ AfterpayTests/Mocks/URLSessionMock.swift | 62 ++++++ Sources/Afterpay/Afterpay.swift | 2 + Sources/Afterpay/AfterpayV3.swift | 36 ++-- .../Afterpay/CashApp/CashAppPayCheckout.swift | 6 +- .../CashApp/ConfirmationV3+CashAppPay.swift | 13 +- .../Checkout/CheckoutV2ViewController.swift | 3 +- .../Checkout/CheckoutV3ViewController.swift | 3 +- .../Checkout/CheckoutWebViewController.swift | 3 +- 10 files changed, 294 insertions(+), 30 deletions(-) create mode 100644 AfterpayTests/AfterpayV3Tests.swift create mode 100644 AfterpayTests/Mocks/URLSessionMock.swift diff --git a/Afterpay.xcodeproj/project.pbxproj b/Afterpay.xcodeproj/project.pbxproj index 2e96e84b..dbcaac01 100644 --- a/Afterpay.xcodeproj/project.pbxproj +++ b/Afterpay.xcodeproj/project.pbxproj @@ -50,6 +50,8 @@ 55A2D307261BB36C00D8E23A /* Money.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55A2D306261BB36C00D8E23A /* Money.swift */; }; 5FB958DA2C0A526800137468 /* CheckoutV3CashAppPayResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FB958D92C0A526800137468 /* CheckoutV3CashAppPayResult.swift */; }; 5FB958DC2C0A528300137468 /* ConfirmationV3+CashAppPay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FB958DB2C0A528300137468 /* ConfirmationV3+CashAppPay.swift */; }; + 5FB958DE2C0E00DC00137468 /* AfterpayV3Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FB958DD2C0E00DC00137468 /* AfterpayV3Tests.swift */; }; + 5FB958E32C0E126200137468 /* URLSessionMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FB958E22C0E126200137468 /* URLSessionMock.swift */; }; 6602EF0F25358A8000A0468C /* ColorScheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6602EF0E25358A8000A0468C /* ColorScheme.swift */; }; 6605666324E5199500DA588E /* Locales.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6605666224E5199500DA588E /* Locales.swift */; }; 66169312257A06B200DF6CF4 /* CheckoutV2Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66169311257A06B200DF6CF4 /* CheckoutV2Message.swift */; }; @@ -144,6 +146,8 @@ 55A2D306261BB36C00D8E23A /* Money.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Money.swift; sourceTree = ""; }; 5FB958D92C0A526800137468 /* CheckoutV3CashAppPayResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckoutV3CashAppPayResult.swift; sourceTree = ""; }; 5FB958DB2C0A528300137468 /* ConfirmationV3+CashAppPay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConfirmationV3+CashAppPay.swift"; sourceTree = ""; }; + 5FB958DD2C0E00DC00137468 /* AfterpayV3Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AfterpayV3Tests.swift; sourceTree = ""; }; + 5FB958E22C0E126200137468 /* URLSessionMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionMock.swift; sourceTree = ""; }; 6602EF0E25358A8000A0468C /* ColorScheme.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ColorScheme.swift; sourceTree = ""; }; 6605666224E5199500DA588E /* Locales.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Locales.swift; sourceTree = ""; }; 66169311257A06B200DF6CF4 /* CheckoutV2Message.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckoutV2Message.swift; sourceTree = ""; }; @@ -252,6 +256,14 @@ path = Widget; sourceTree = ""; }; + 5FB958E12C0E124E00137468 /* Mocks */ = { + isa = PBXGroup; + children = ( + 5FB958E22C0E126200137468 /* URLSessionMock.swift */, + ); + path = Mocks; + sourceTree = ""; + }; 661B233024DA87EA0010EBCD /* Views */ = { isa = PBXGroup; children = ( @@ -299,6 +311,7 @@ 665FC5782488766C00A5A93E /* AfterpayTests */ = { isa = PBXGroup; children = ( + 5FB958E12C0E124E00137468 /* Mocks */, 556DEBF92653531000BFC277 /* mock-widget-bootstrap.js */, 6635B95E24CAA9F000EBB3A6 /* ConfigurationTests.swift */, 66C3F7FA25397A810086DD0A /* CurrencyFormatterTests.swift */, @@ -309,6 +322,7 @@ 550D48142625539900C0B0C6 /* WidgetStatusTests.swift */, 557511BA264259C30040CC51 /* CombineWrapperTests.swift */, 4573E4B32A4D4DCB00F5CEAA /* LocaleTests.swift */, + 5FB958DD2C0E00DC00137468 /* AfterpayV3Tests.swift */, ); path = AfterpayTests; sourceTree = ""; @@ -616,8 +630,10 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 5FB958E32C0E126200137468 /* URLSessionMock.swift in Sources */, 551BEDF125F98FA800FDF9EE /* FeaturesTests.swift in Sources */, 6635B95F24CAA9F000EBB3A6 /* ConfigurationTests.swift in Sources */, + 5FB958DE2C0E00DC00137468 /* AfterpayV3Tests.swift in Sources */, 66DAAC8D24E109D200127460 /* PriceBreakdownTests.swift in Sources */, 550D481B26255D8600C0B0C6 /* WidgetEventTests.swift in Sources */, 550D48152625539900C0B0C6 /* WidgetStatusTests.swift in Sources */, diff --git a/AfterpayTests/AfterpayV3Tests.swift b/AfterpayTests/AfterpayV3Tests.swift new file mode 100644 index 00000000..f6e51bae --- /dev/null +++ b/AfterpayTests/AfterpayV3Tests.swift @@ -0,0 +1,180 @@ +// +// AfterpayV3Tests.swift +// AfterpayTests +// +// Created by Mark Mroz on 2024-06-03. +// Copyright © 2024 Afterpay. All rights reserved. +// + +import XCTest +@testable import Afterpay + +final class AfterpayV3Tests: XCTestCase { + + private var v3Configuration: CheckoutV3Configuration! + + override func setUpWithError() throws { + try super.setUpWithError() + v3Configuration = CheckoutV3Configuration( + shopDirectoryMerchantId: "merchant_id", + region: .US, + environment: .production + ) + + let v2Configuration = try Configuration( + minimumAmount: nil, + maximumAmount: "150", + currencyCode: "USD", + locale: Locale(identifier: "en_US"), + environment: .production + ) + Afterpay.setConfiguration(v2Configuration) + } + + override func tearDown() { + v3Configuration = nil + Afterpay.setConfiguration(nil) + super.tearDown() + } + + func testCheckoutV3WithCashAppPay() throws { + let checkoutV3Expectation = self.expectation(description: "Checkout V3") + let checkoutHandler: URLRequestHandler = { (request, response) in + URLSessionMock(dataTaskHandler: { url in + XCTAssertEqual(url.absoluteString, "https://api-plus.us.afterpay.com/v3/button") + return (Fixtures.checkoutV3WithCashAppPayResponse, nil, nil) + }).dataTask(with: request.url!) { data, urlResponse, error in + checkoutV3Expectation.fulfill() + response(data, urlResponse, error) + } + } + + let signTokenExpectation = self.expectation(description: "Sign Token") + let signHandler = URLSessionMock(requestDataTaskHandler: { request in + XCTAssertEqual(request.url?.absoluteString, "https://api-plus.us.afterpay.com/v2/payments/sign-payment") + signTokenExpectation.fulfill() + return (Fixtures.signPaymentResponse, HTTPURLResponse(), nil) + }) + + let checkoutExpectation = self.expectation(description: "Completed Checkout") + Afterpay.checkoutV3WithCashAppPay( + consumer: Consumer(email: "jack@xyz.com"), + orderTotal: OrderTotal(total: 10, shipping: 0, tax: 0), + items: [Product(name: "Coffee", quantity: 1, price: 10)], + configuration: v3Configuration, + urlSession: signHandler, + requestHandler: checkoutHandler + ) { result in + switch result { + case .success(let data): + XCTAssertEqual(data.singleUseCardToken, "AQI") + XCTAssertEqual(data.token, "002.x") + XCTAssertEqual(data.cashAppSigningData.amount, 5000) + XCTAssertEqual(data.cashAppSigningData.brandId, "BRAND_ID") + XCTAssertEqual(data.cashAppSigningData.merchantId, "MMI_6nvgu9voweagwt5dn0kdteaio") + XCTAssertEqual( + data.cashAppSigningData.redirectUri.absoluteString, + "https://static-us.afterpay.com/javascript/button/index.html" + ) + case .cancelled, .failure: + XCTFail("Expected success") + } + checkoutExpectation.fulfill() + } + waitForExpectations(timeout: 0.5) + } + + func testCheckoutV3ConfirmForCashAppPay() { + + let confirmRequestExpectation = self.expectation(description: "Confirmed") + let checkoutHandler: URLRequestHandler = { (request, response) in + URLSessionMock(dataTaskHandler: { url in + XCTAssertEqual(url.absoluteString, "https://api-plus.us.afterpay.com/v3/button/confirm") + return (Fixtures.confirmResponse, nil, nil) + }).dataTask(with: request.url!) { data, urlResponse, error in + confirmRequestExpectation.fulfill() + response(data, urlResponse, error) + } + } + + let confirmExpectation = self.expectation(description: "Confirmed") + Afterpay.checkoutV3ConfirmForCashAppPay( + token: "002.x", + singleUseCardToken: "AQI", + cashAppPayCustomerID: "CUST_ID", + cashAppPayGrantID: "GRR_ID", + jwt: "JWT", + configuration: v3Configuration, + requestHandler: checkoutHandler + ) { result in + switch result { + case .success(let data): + XCTAssertEqual(data.paymentDetails.virtualCard?.cardType, "VISA") + XCTAssertEqual(data.paymentDetails.virtualCard?.cardNumber, "4111111111111111") + XCTAssertEqual(data.paymentDetails.virtualCard?.cvc, "737") + XCTAssertEqual(data.paymentDetails.virtualCard?.expiryMonth, 3) + XCTAssertEqual(data.paymentDetails.virtualCard?.expiryYear, 30) + XCTAssertNotNil(data.cardValidUntil) + case .failure: + XCTFail("Expected success") + } + + confirmExpectation.fulfill() + } + + waitForExpectations(timeout: 0.5) + } +} + +// MARK: - Private + +private extension AfterpayV3Tests { + struct Product: CheckoutV3Item { + let name: String + let quantity: UInt + let price: Decimal + let sku: String? = nil + let pageUrl: URL? = nil + let imageUrl: URL? = nil + let categories: [[String]]? = nil + let estimatedShipmentDate: String? = nil + } +} + +// MARK: - Fixtures + +extension AfterpayV3Tests { + // swiftlint:disable line_length indentation_width + enum Fixtures { + static let checkoutV3WithCashAppPayResponse = """ + { + "token": "002.x", + "confirmMustBeCalledBefore": "2024-06-04T02:29:53.803Z", + "redirectCheckoutUrl": "https://portal.sandbox.afterpay.com/us/checkout/?token=002.x", + "singleUseCardToken": "AQI" + } + """.data(using: .utf8) + + static let signPaymentResponse = """ + { + "jwtToken": "eyJraWQiOiJrZXkxIiwiYWxnIjoiRVMyNTYiLCJ0dGwiOiIxNzE3NDM3Nzc1In0.eyJleHRlcm5hbE1lcmNoYW50SWQiOiJNTUlfNm52Z3U5dm93ZWFnd3Q1ZG4wa2R0ZWFpbyIsInRva2VuIjoiMDAyLmlscXFqZjJ1ZG11MnJwM3hjdTdjYjZtenVwNnRlZndiM2Q1eWs2Y3pyZnZqd3Nmb2NuIiwiYW1vdW50Ijp7ImFtb3VudCI6IjUwLjAwIiwiY3VycmVuY3kiOiJVU0QiLCJzeW1ib2wiOiIkIn0sInJlZGlyZWN0VXJsIjoiaHR0cHM6Ly9zdGF0aWMtdXMuYWZ0ZXJwYXkuY29tL2phdmFzY3JpcHQvYnV0dG9uL2luZGV4Lmh0bWwifQ.KRVxIHwrH_QPDTX2WF3Ei5wI7InE_v7xvPDDXFF2YBka2hUROkSX6ubdrFufIkE6yaFHyrlAGoQiS17VB80IDA", + "redirectUrl": "https://static-us.afterpay.com", + "externalBrandId": "BRAND_ID" + } + """.data(using: .utf8) + + static let confirmResponse = """ + { + "paymentDetails": { + "virtualCard": { + "cardType": "VISA", + "cardNumber": "4111111111111111", + "cvc": "737", + "expiry": "30-03" + } + }, + "cardValidUntil": "2024-06-03T18:31:32.096071575Z" + } + """.data(using: .utf8) + } +} diff --git a/AfterpayTests/Mocks/URLSessionMock.swift b/AfterpayTests/Mocks/URLSessionMock.swift new file mode 100644 index 00000000..74924dca --- /dev/null +++ b/AfterpayTests/Mocks/URLSessionMock.swift @@ -0,0 +1,62 @@ +// +// URLSessionMock.swift +// AfterpayTests +// +// Created by Mark Mroz on 2024-06-03. +// Copyright © 2024 Afterpay. All rights reserved. +// + +import Foundation + +// MARK: - URLSessionDataTaskMock + +final class URLSessionDataTaskMock: URLSessionDataTask { + private let resumeHandler: () -> Void + + init(resumeHandler: @escaping () -> Void) { + self.resumeHandler = resumeHandler + } + + override func resume() { + resumeHandler() + } +} + +// MARK: - URLSessionMock + +final class URLSessionMock: URLSession { + typealias RequestDataTaskHandler = (URLRequest) -> (Data?, URLResponse?, Error?) + typealias DataTaskHandler = (URL) -> (Data?, URLResponse?, Error?) + typealias CompletionHandler = (Data?, URLResponse?, Error?) -> Void + + private let dataTaskHandler: DataTaskHandler + private let requestDataTaskHandler: RequestDataTaskHandler + + init( + dataTaskHandler: @escaping DataTaskHandler = { _ in (nil, nil, nil) }, + requestDataTaskHandler: @escaping RequestDataTaskHandler = { _ in (nil, nil, nil) } + ) { + self.dataTaskHandler = dataTaskHandler + self.requestDataTaskHandler = requestDataTaskHandler + } + + override func dataTask( + with url: URL, + completionHandler: @escaping CompletionHandler + ) -> URLSessionDataTask { + let (data, response, error) = dataTaskHandler(url) + return URLSessionDataTaskMock { + completionHandler(data, response, error) + } + } + + override func dataTask( + with request: URLRequest, + completionHandler: @escaping CompletionHandler + ) -> URLSessionDataTask { + let (data, response, error) = requestDataTaskHandler(request) + return URLSessionDataTaskMock { + completionHandler(data, response, error) + } + } +} diff --git a/Sources/Afterpay/Afterpay.swift b/Sources/Afterpay/Afterpay.swift index 4cc4303e..061b474a 100644 --- a/Sources/Afterpay/Afterpay.swift +++ b/Sources/Afterpay/Afterpay.swift @@ -240,6 +240,7 @@ public func setCheckoutV2Handler(_ handler: CheckoutV2Handler?) { public func signCashAppOrderToken( _ token: Token, + urlSession: URLSession = .shared, completion: @escaping (_ result: CashAppSigningResult) -> Void ) { guard let configuration = getConfiguration() else { @@ -253,6 +254,7 @@ public func signCashAppOrderToken( } let cashAppCheckout = CashAppPayCheckout( + urlSession: urlSession, configuration: configuration, completion: completion ) diff --git a/Sources/Afterpay/AfterpayV3.swift b/Sources/Afterpay/AfterpayV3.swift index ed9014e5..4e6cff75 100644 --- a/Sources/Afterpay/AfterpayV3.swift +++ b/Sources/Afterpay/AfterpayV3.swift @@ -28,6 +28,7 @@ public func checkoutV3WithCashAppPay( orderTotal: OrderTotal, items: [CheckoutV3Item] = [], configuration: CheckoutV3Configuration? = getV3Configuration(), + urlSession: URLSession = .shared, requestHandler: @escaping URLRequestHandler = URLSession.shared.dataTask, completion: @escaping (_ result: CheckoutV3CashAppPayResult) -> Void ) { @@ -43,24 +44,25 @@ public func checkoutV3WithCashAppPay( orderTotal: orderTotal, items: items, configuration: configuration, - requestHandler: requestHandler) { checkoutResult in - switch checkoutResult { - case .success(let checkout): - signCashAppOrderToken(checkout.token) { signingResult in - switch signingResult { - case .success(let signingData): - let response = CheckoutV3CashAppPayPayload( - token: checkout.token, - singleUseCardToken: checkout.singleUseCardToken, - cashAppSigningData: signingData - ) - completion(.success(data: response)) - case .failed(let reason): - completion(.cancelled(reason: reason)) - } + requestHandler: requestHandler + ) { checkoutResult in + switch checkoutResult { + case .success(let checkout): + signCashAppOrderToken(checkout.token, urlSession: urlSession) { signingResult in + switch signingResult { + case .success(let signingData): + let response = CheckoutV3CashAppPayPayload( + token: checkout.token, + singleUseCardToken: checkout.singleUseCardToken, + cashAppSigningData: signingData + ) + completion(.success(data: response)) + case .failed(let reason): + completion(.cancelled(reason: reason)) } - case .failure(let error): - completion(.failure(error: error)) + } + case .failure(let error): + completion(.failure(error: error)) } } } diff --git a/Sources/Afterpay/CashApp/CashAppPayCheckout.swift b/Sources/Afterpay/CashApp/CashAppPayCheckout.swift index 4746d1ad..f00a8305 100644 --- a/Sources/Afterpay/CashApp/CashAppPayCheckout.swift +++ b/Sources/Afterpay/CashApp/CashAppPayCheckout.swift @@ -9,13 +9,16 @@ import Foundation class CashAppPayCheckout { + private let urlSession: URLSession private let configuration: Configuration private let completion: (_ result: CashAppSigningResult) -> Void public init( + urlSession: URLSession, configuration: Configuration, completion: @escaping (_ result: CashAppSigningResult) -> Void ) { + self.urlSession = urlSession self.configuration = configuration self.completion = completion } @@ -55,7 +58,7 @@ class CashAppPayCheckout { request: URLRequest, signingCompletion: @escaping (_ jwt: CashAppSigningResult) -> Void ) { - URLSession.shared.dataTask(with: request) { [weak self] data, response, error in + urlSession.dataTask(with: request) { [weak self] data, response, error in if error != nil { signingCompletion(CashAppSigningResult.failed(reason: .error(error: error!))) return @@ -228,7 +231,6 @@ internal extension CashAppPayCheckout { ).resume() } - // swiftlint:disable:next function_parameter_count static func checkoutV3( consumer: CheckoutV3Consumer, orderTotal: OrderTotal, diff --git a/Sources/Afterpay/CashApp/ConfirmationV3+CashAppPay.swift b/Sources/Afterpay/CashApp/ConfirmationV3+CashAppPay.swift index 3a4006d1..a690d50d 100644 --- a/Sources/Afterpay/CashApp/ConfirmationV3+CashAppPay.swift +++ b/Sources/Afterpay/CashApp/ConfirmationV3+CashAppPay.swift @@ -10,11 +10,6 @@ import Foundation extension ConfirmationV3 { struct CashAppPayRequest: Encodable { - struct CashAppPspInfo: Encodable { - let externalCustomerId: String - let externalGrantId: String - let jwt: String - } let token: String let singleUseCardToken: String let cashAppPspInfo: CashAppPspInfo @@ -25,3 +20,11 @@ extension ConfirmationV3 { public let cardValidUntil: Date? } } + +extension ConfirmationV3.CashAppPayRequest { + struct CashAppPspInfo: Encodable { + let externalCustomerId: String + let externalGrantId: String + let jwt: String + } +} diff --git a/Sources/Afterpay/Checkout/CheckoutV2ViewController.swift b/Sources/Afterpay/Checkout/CheckoutV2ViewController.swift index 1c61acf5..ba029812 100644 --- a/Sources/Afterpay/Checkout/CheckoutV2ViewController.swift +++ b/Sources/Afterpay/Checkout/CheckoutV2ViewController.swift @@ -17,8 +17,7 @@ final class CheckoutV2ViewController: UIAdaptivePresentationControllerDelegate, WKNavigationDelegate, WKScriptMessageHandler, - WKUIDelegate -{ // swiftlint:disable:this opening_brace + WKUIDelegate { private let configuration: Configuration private let options: CheckoutV2Options diff --git a/Sources/Afterpay/Checkout/CheckoutV3ViewController.swift b/Sources/Afterpay/Checkout/CheckoutV3ViewController.swift index af89da88..8bb491c2 100644 --- a/Sources/Afterpay/Checkout/CheckoutV3ViewController.swift +++ b/Sources/Afterpay/Checkout/CheckoutV3ViewController.swift @@ -13,8 +13,7 @@ import WebKit final class CheckoutV3ViewController: UIViewController, UIAdaptivePresentationControllerDelegate, - WKNavigationDelegate -{ // swiftlint:disable:this opening_brace + WKNavigationDelegate { private let checkout: CheckoutV3.Request private let buyNow: Bool diff --git a/Sources/Afterpay/Checkout/CheckoutWebViewController.swift b/Sources/Afterpay/Checkout/CheckoutWebViewController.swift index 232a9b95..0c19af09 100644 --- a/Sources/Afterpay/Checkout/CheckoutWebViewController.swift +++ b/Sources/Afterpay/Checkout/CheckoutWebViewController.swift @@ -13,8 +13,7 @@ import WebKit final class CheckoutWebViewController: UIViewController, UIAdaptivePresentationControllerDelegate, - WKNavigationDelegate -{ // swiftlint:disable:this opening_brace + WKNavigationDelegate { private let checkoutUrl: URL private let shouldLoadRedirectUrls: Bool From 459d48b91b40025e62119bbab6f14eacbfd898d7 Mon Sep 17 00:00:00 2001 From: Mark Mroz Date: Mon, 3 Jun 2024 13:49:39 -0400 Subject: [PATCH 69/81] method naming --- AfterpayTests/Mocks/URLSessionMock.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/AfterpayTests/Mocks/URLSessionMock.swift b/AfterpayTests/Mocks/URLSessionMock.swift index 74924dca..b23a70fd 100644 --- a/AfterpayTests/Mocks/URLSessionMock.swift +++ b/AfterpayTests/Mocks/URLSessionMock.swift @@ -26,14 +26,14 @@ final class URLSessionDataTaskMock: URLSessionDataTask { final class URLSessionMock: URLSession { typealias RequestDataTaskHandler = (URLRequest) -> (Data?, URLResponse?, Error?) - typealias DataTaskHandler = (URL) -> (Data?, URLResponse?, Error?) + typealias URLDataTaskHandler = (URL) -> (Data?, URLResponse?, Error?) typealias CompletionHandler = (Data?, URLResponse?, Error?) -> Void - private let dataTaskHandler: DataTaskHandler + private let dataTaskHandler: URLDataTaskHandler private let requestDataTaskHandler: RequestDataTaskHandler init( - dataTaskHandler: @escaping DataTaskHandler = { _ in (nil, nil, nil) }, + dataTaskHandler: @escaping URLDataTaskHandler = { _ in (nil, nil, nil) }, requestDataTaskHandler: @escaping RequestDataTaskHandler = { _ in (nil, nil, nil) } ) { self.dataTaskHandler = dataTaskHandler From 01a9ae1eab82b30d96eba65751f267fff6fc5a62 Mon Sep 17 00:00:00 2001 From: Mark Mroz Date: Mon, 3 Jun 2024 17:31:51 -0400 Subject: [PATCH 70/81] Added example code --- Example/Example.xcodeproj/project.pbxproj | 4 + .../Example/Purchase/CartViewController.swift | 65 ++++++++++- .../Example/Purchase/CheckoutPickerView.swift | 103 ++++++++++++++++++ .../Purchase/PurchaseFlowController.swift | 46 ++++++++ .../Purchase/PurchaseLogicController.swift | 71 ++++++++++-- Example/Example/Shared/AlertFactory.swift | 7 ++ 6 files changed, 285 insertions(+), 11 deletions(-) create mode 100644 Example/Example/Purchase/CheckoutPickerView.swift diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index 8d672d56..119cf2de 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 55432838263B7EC2005512E4 /* ExampleUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55432837263B7EC2005512E4 /* ExampleUITests.swift */; }; 55719BA926363ED800634B27 /* SwiftUIWidgetExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55719BA826363ED800634B27 /* SwiftUIWidgetExample.swift */; }; 55FA7270260025DC0006EFCB /* WidgetHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55FA726F260025DC0006EFCB /* WidgetHandler.swift */; }; + 5FB958E62C0E3D7B00137468 /* CheckoutPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FB958E52C0E3D7B00137468 /* CheckoutPickerView.swift */; }; 660072B724A1B55E00E9A2BC /* TextSettingCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 660072B624A1B55E00E9A2BC /* TextSettingCell.swift */; }; 6620B5D224934FB3004162BC /* AppFlowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6620B5D124934FB3004162BC /* AppFlowController.swift */; }; 663A228E249B030D0027C296 /* WidgetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 663A228D249B030D0027C296 /* WidgetViewController.swift */; }; @@ -100,6 +101,7 @@ 55432839263B7EC2005512E4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 55719BA826363ED800634B27 /* SwiftUIWidgetExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIWidgetExample.swift; sourceTree = ""; }; 55FA726F260025DC0006EFCB /* WidgetHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetHandler.swift; sourceTree = ""; }; + 5FB958E52C0E3D7B00137468 /* CheckoutPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckoutPickerView.swift; sourceTree = ""; }; 660072B624A1B55E00E9A2BC /* TextSettingCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextSettingCell.swift; sourceTree = ""; }; 6620B5D124934FB3004162BC /* AppFlowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppFlowController.swift; sourceTree = ""; }; 663A228D249B030D0027C296 /* WidgetViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetViewController.swift; sourceTree = ""; }; @@ -223,6 +225,7 @@ 83F7DEE2269D2BAA00F9FB75 /* SingleUseCardResultViewController.swift */, 66BD11B324ADB7EB00039DA6 /* TitleSubtitleCell.swift */, 55FA726F260025DC0006EFCB /* WidgetHandler.swift */, + 5FB958E52C0E3D7B00137468 /* CheckoutPickerView.swift */, ); path = Purchase; sourceTree = ""; @@ -503,6 +506,7 @@ files = ( 1535ACB725DBA8AD00727818 /* CheckoutHandler.swift in Sources */, 152224B124AAF7AE006E7D78 /* Objc.m in Sources */, + 5FB958E62C0E3D7B00137468 /* CheckoutPickerView.swift in Sources */, 6650F41C24BC03DC00B16A57 /* Colors.swift in Sources */, 6620B5D224934FB3004162BC /* AppFlowController.swift in Sources */, 664722A724A5D9B00079B1FB /* AlertFactory.swift in Sources */, diff --git a/Example/Example/Purchase/CartViewController.swift b/Example/Example/Purchase/CartViewController.swift index f27b0ba5..54e0225f 100644 --- a/Example/Example/Purchase/CartViewController.swift +++ b/Example/Example/Purchase/CartViewController.swift @@ -20,16 +20,25 @@ final class CartViewController: UIViewController, UITableViewDataSource { private let titleSubtitleCellIdentifier = String(describing: TitleSubtitleCell.self) private let eventHandler: (Event) -> Void + private var event: Event = .didTapSingleUseCardButton { + didSet { + updateViewState() + } + } + private lazy var cashButton = CashAppPayButton(size: .large) { [weak self] in self?.didTapCashAppPay() } + private let checkoutTypeTextField = UITextField() + enum Event { case didTapPay case didTapCashAppPay case cartDidLoad(CashAppPayButton) case optionsChanged(CheckoutOptionsCell.Event) case didTapSingleUseCardButton + case didTapSingleUseCardButtonWithCashAppPay } init(cart: CartDisplay, eventHandler: @escaping (Event) -> Void) { @@ -70,15 +79,22 @@ final class CartViewController: UIViewController, UITableViewDataSource { payButton.addTarget(self, action: #selector(didTapPay), for: .touchUpInside) view.addSubview(payButton) + view.addSubview(checkoutTypeTextField) cashButton.accessibilityIdentifier = "payWithCashApp" cashButton.translatesAutoresizingMaskIntoConstraints = false + checkoutTypeTextField.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(cashButton) NSLayoutConstraint.activate([ + checkoutTypeTextField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + checkoutTypeTextField.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + checkoutTypeTextField.bottomAnchor.constraint(equalTo: payButton.topAnchor), payButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), payButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + payButton.bottomAnchor.constraint(equalTo: cashButton.topAnchor), cashButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), cashButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), cashButton.topAnchor.constraint(equalTo: payButton.bottomAnchor, constant: 8), @@ -94,24 +110,51 @@ final class CartViewController: UIViewController, UITableViewDataSource { tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), tableViewBottomAnchor, ]) + + let editCheckoutButton = UIBarButtonItem( + title: "Edit", + style: .plain, + target: self, + action: #selector(presentPickerController) + ) + navigationItem.setRightBarButton(editCheckoutButton, animated: false) } override func viewDidLoad() { super.viewDidLoad() - eventHandler(.cartDidLoad(self.cashButton)) + + updateViewState() + } + + // MARK: - Private + + func updateViewState() { + switch event { + case .didTapPay, .didTapCashAppPay, .cartDidLoad, .optionsChanged: + break + case .didTapSingleUseCardButton: + checkoutTypeTextField.text = "Button Checkout V3" + case .didTapSingleUseCardButtonWithCashAppPay: + checkoutTypeTextField.text = "Button Checkout V3 with Cash App Pay" + } } // MARK: Actions @objc private func didTapPay() { - eventHandler(.didTapSingleUseCardButton) + eventHandler(event) } @objc private func didTapCashAppPay() { eventHandler(.didTapCashAppPay) } + @objc private func presentPickerController() { + let controller = CheckoutPickerViewController(delegate: self) + present(UINavigationController(rootViewController: controller), animated: true) + } + // MARK: UITableViewDataSource private enum Section: Int, CaseIterable { @@ -189,3 +232,21 @@ final class CartViewController: UIViewController, UITableViewDataSource { } } + +// MARK: - CheckoutPickerControllerDelegate + +extension CartViewController: CheckoutPickerControllerDelegate { + func didSelectCancel(_ controller: CheckoutPickerViewController) { + controller.dismiss(animated: true) + } + + func didSelectV3Checkout(_ controller: CheckoutPickerViewController) { + event = .didTapSingleUseCardButton + controller.dismiss(animated: true) + } + + func didSelectV3CheckoutWithCashAppPay(_ controller: CheckoutPickerViewController) { + event = .didTapSingleUseCardButtonWithCashAppPay + controller.dismiss(animated: true) + } +} diff --git a/Example/Example/Purchase/CheckoutPickerView.swift b/Example/Example/Purchase/CheckoutPickerView.swift new file mode 100644 index 00000000..f1b032c6 --- /dev/null +++ b/Example/Example/Purchase/CheckoutPickerView.swift @@ -0,0 +1,103 @@ +// +// CheckoutPickerView.swift +// Example +// +// Created by Mark Mroz on 2024-06-03. +// Copyright © 2024 Afterpay. All rights reserved. +// + +import SwiftUI + +protocol CheckoutPickerControllerDelegate: AnyObject { + func didSelectCancel(_ controller: CheckoutPickerViewController) + func didSelectV3Checkout(_ controller: CheckoutPickerViewController) + func didSelectV3CheckoutWithCashAppPay(_ controller: CheckoutPickerViewController) +} + +final class CheckoutPickerViewController: UIViewController { + + private lazy var viewModel = makePickerViewModel() + private lazy var pickerView = PickerView(viewModel: viewModel) + + private weak var delegate: CheckoutPickerControllerDelegate? + + init(delegate: CheckoutPickerControllerDelegate?) { + self.delegate = delegate + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + let container = UIView(frame: .zero) + let view = UIHostingController(rootView: pickerView).view! + container.addSubview(view) + container.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + container.leadingAnchor.constraint(equalTo: view.leadingAnchor), + container.trailingAnchor.constraint(equalTo: view.trailingAnchor), + container.topAnchor.constraint(equalTo: view.topAnchor), + container.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + self.view = view + } + + override func viewDidLoad() { + super.viewDidLoad() + let cancelButton = UIBarButtonItem( + title: "Close", + style: .plain, + target: self, + action: #selector(onCancelTapped) + ) + navigationItem.setRightBarButton(cancelButton, animated: false) + } + + // MARK: - Actions + + @objc private func onCancelTapped() { + delegate?.didSelectCancel(self) + } + + // MARK: - Private + + private func makePickerViewModel() -> PickerViewModel { + PickerViewModel( + onButtonTapped: { [weak self] in + guard let self else { return } + delegate?.didSelectV3Checkout(self) + }, + onButtonWithCashAppPayTapped: { [weak self] in + guard let self else { return } + delegate?.didSelectV3CheckoutWithCashAppPay(self) + } + ) + } +} + +extension CheckoutPickerViewController { + struct PickerViewModel { + let onButtonTapped: () -> Void + let onButtonWithCashAppPayTapped: () -> Void + } + + struct PickerView: View { + + private let viewModel: PickerViewModel + + init(viewModel: PickerViewModel) { + self.viewModel = viewModel + } + + var body: some View { + VStack(alignment: .leading) { + Button("Button Checkout", action: viewModel.onButtonTapped).padding() + Button("Button Checkout With Cash App Pay", action: viewModel.onButtonWithCashAppPayTapped).padding() + } + } + } +} diff --git a/Example/Example/Purchase/PurchaseFlowController.swift b/Example/Example/Purchase/PurchaseFlowController.swift index 376b705d..3f4961d8 100644 --- a/Example/Example/Purchase/PurchaseFlowController.swift +++ b/Example/Example/Purchase/PurchaseFlowController.swift @@ -108,6 +108,8 @@ final class PurchaseFlowController: UIViewController { logicController.toggleExpressCheckout() case .didTapSingleUseCardButton: logicController.payWithAfterpayV3() + case .didTapSingleUseCardButtonWithCashAppPay: + logicController.payWithAfterpayV3WithCashAppPay() } } @@ -156,6 +158,50 @@ final class PurchaseFlowController: UIViewController { logicController.cancelled(with: reason) } } + case let .afterpayCheckoutV3WithCashAppPay(consumer, cart): + Afterpay.checkoutV3WithCashAppPay( + consumer: consumer, + orderTotal: OrderTotal(total: cart.total, shipping: 0, tax: 0)) { result in + switch result { + case .success(let data): + logicController.doCap(cashAppPayload: data) + case .cancelled: + let alert = AlertFactory.alert(successMessage: "Canceled") + navigationController.present(alert, animated: true, completion: nil) + case .failure(let error): + let alert = AlertFactory.alert(for: error) + navigationController.present(alert, animated: true, completion: nil) + } + } + case let .confirmAfterpayCheckoutV3WithCashAppPay( + token: token, + singleUseCardToke: cardToken, + customerID: customerID, + grantID: grantID, + jwt: jwt + ): + Afterpay.checkoutV3ConfirmForCashAppPay( + token: token, + singleUseCardToken: cardToken, + cashAppPayCustomerID: customerID, + cashAppPayGrantID: grantID, + jwt: jwt) { response in + print(response) + switch response { + case .success(let success): + let message = [ + "Card": success.paymentDetails.virtualCard?.cardNumber, + "Expires": success.cardValidUntil.map { RelativeDateTimeFormatter().string(for: $0) ?? "" }, + ].compactMapValues { $0 } + .map { (key: String, value: String) in key + ": " + value } + .joined(separator: "\n") + let alert = AlertFactory.alert(successMessage: message) + navigationController.present(alert, animated: true, completion: nil) + case .failure(let error): + let alert = AlertFactory.alert(for: error) + navigationController.present(alert, animated: true, completion: nil) + } + } case .provideCheckoutTokenResult(let tokenResult): checkoutHandler.provideTokenResult(tokenResult: tokenResult) diff --git a/Example/Example/Purchase/PurchaseLogicController.swift b/Example/Example/Purchase/PurchaseLogicController.swift index ced4178b..0aafd8d5 100644 --- a/Example/Example/Purchase/PurchaseLogicController.swift +++ b/Example/Example/Purchase/PurchaseLogicController.swift @@ -11,6 +11,7 @@ import Foundation import PayKit import PayKitUI +// swiftlint:disable type_body_length final class PurchaseLogicController { enum Command { @@ -28,6 +29,15 @@ final class PurchaseLogicController { case showAlertForErrorMessage(String) case showSuccessWithMessage(String, Token) case showCashSuccess(String, String, [CustomerRequest.Grant]) + + case afterpayCheckoutV3WithCashAppPay(consumer: Consumer, cart: CartDisplay) + case confirmAfterpayCheckoutV3WithCashAppPay( + token: Token, + singleUseCardToke: Token, + customerID: String, + grantID: String, + jwt: String + ) } var commandHandler: (Command) -> Void = { _ in } { @@ -133,6 +143,13 @@ final class PurchaseLogicController { )) } + func payWithAfterpayV3WithCashAppPay() { + commandHandler(.afterpayCheckoutV3WithCashAppPay( + consumer: Consumer(email: email), + cart: buildCart() + )) + } + func payWithCashApp() { guard let cashRequest else { return @@ -146,6 +163,8 @@ final class PurchaseLogicController { static var cashData: CashAppSigningData? + private(set) var checkoutV3CashAppPayPayload: CheckoutV3CashAppPayPayload? + private lazy var paykit: CashAppPay? = { guard let clientId = Afterpay.cashAppClientId else { assertionFailure("Couldn't get cash app client id") @@ -196,6 +215,28 @@ final class PurchaseLogicController { } } + func doCap(cashAppPayload: CheckoutV3CashAppPayPayload) { + self.checkoutV3CashAppPayPayload = cashAppPayload + + paykit?.createCustomerRequest( + params: CreateCustomerRequestParams( + actions: [ + .oneTimePayment( + scopeID: cashAppPayload.cashAppSigningData.brandId, + money: Money( + amount: cashAppPayload.cashAppSigningData.amount, + currency: .USD + ) + ), + ], + channel: .IN_APP, + redirectURL: URL(string: "aftersnack://callback")!, // the cashData.redirectUri object could be used here + referenceID: nil, + metadata: nil + ) + ) + } + func retrieveCashAppToken(cashButton: CashAppPayButton? = nil) { if cashButton != nil { self.cashButton = cashButton @@ -319,19 +360,30 @@ extension PurchaseLogicController: CashAppPayObserver { switch state { case .notStarted, - .creatingCustomerRequest, - .updatingCustomerRequest, - .redirecting, - .polling, - .apiError, - .integrationError, - .networkError, - .unexpectedError: + .creatingCustomerRequest, + .updatingCustomerRequest, + .redirecting, + .polling, + .apiError, + .integrationError, + .networkError, + .unexpectedError: return case .readyToAuthorize(let request): setCashButtonEnabled(true) cashRequest = request - case .approved(let request, let grants): + case .approved(request: let request, grants: let grants) where checkoutV3CashAppPayPayload != nil: + commandHandler( + .confirmAfterpayCheckoutV3WithCashAppPay( + token: checkoutV3CashAppPayPayload!.token, + singleUseCardToke: checkoutV3CashAppPayPayload!.singleUseCardToken, + customerID: request.customerProfile!.id, + grantID: grants.first!.id, + jwt: checkoutV3CashAppPayPayload!.cashAppSigningData.jwt + ) + ) + checkoutV3CashAppPayPayload = nil + case .approved(request: let request, grants: let grants): if PurchaseLogicController.cashData == nil || request.customerProfile == nil || @@ -371,6 +423,7 @@ extension PurchaseLogicController: CashAppPayObserver { } case .declined: retrieveCashAppToken() + checkoutV3CashAppPayPayload = nil } } } diff --git a/Example/Example/Shared/AlertFactory.swift b/Example/Example/Shared/AlertFactory.swift index 8e97ad70..56eb1ce9 100644 --- a/Example/Example/Shared/AlertFactory.swift +++ b/Example/Example/Shared/AlertFactory.swift @@ -35,4 +35,11 @@ import UIKit return alert } + static func alert(successMessage: String) -> UIAlertController { + let alert = UIAlertController(title: "Success", message: nil, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "Okay", style: .default, handler: nil)) + alert.message = successMessage + return alert + } + } From 75cae89325309e0d3599fb98ce385815905d3207 Mon Sep 17 00:00:00 2001 From: Mark Mroz Date: Tue, 4 Jun 2024 16:59:41 -0400 Subject: [PATCH 71/81] updated picker --- .../Example/Purchase/CartViewController.swift | 19 +++- .../Example/Purchase/CheckoutPickerView.swift | 92 ++++++++++++++++--- 2 files changed, 95 insertions(+), 16 deletions(-) diff --git a/Example/Example/Purchase/CartViewController.swift b/Example/Example/Purchase/CartViewController.swift index 54e0225f..6f316afe 100644 --- a/Example/Example/Purchase/CartViewController.swift +++ b/Example/Example/Purchase/CartViewController.swift @@ -11,7 +11,6 @@ import Foundation import PayKitUI final class CartViewController: UIViewController, UITableViewDataSource { - private var tableView: UITableView! private let cart: CartDisplay private let genericCellIdentifier = String(describing: UITableViewCell.self) @@ -94,7 +93,6 @@ final class CartViewController: UIViewController, UITableViewDataSource { checkoutTypeTextField.bottomAnchor.constraint(equalTo: payButton.topAnchor), payButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), payButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), - payButton.bottomAnchor.constraint(equalTo: cashButton.topAnchor), cashButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), cashButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), cashButton.topAnchor.constraint(equalTo: payButton.bottomAnchor, constant: 8), @@ -151,7 +149,20 @@ final class CartViewController: UIViewController, UITableViewDataSource { } @objc private func presentPickerController() { - let controller = CheckoutPickerViewController(delegate: self) + let selectedOption: CheckoutPickerOption? + switch event { + case .didTapSingleUseCardButton: + selectedOption = .button + case .didTapSingleUseCardButtonWithCashAppPay: + selectedOption = .buttonWithCashAppPay + default: + selectedOption = nil + } + + guard let selectedOption else { return } + + let controller = CheckoutPickerViewController(selectedOption: selectedOption, delegate: self) + controller.title = "Configuration" present(UINavigationController(rootViewController: controller), animated: true) } @@ -239,7 +250,7 @@ extension CartViewController: CheckoutPickerControllerDelegate { func didSelectCancel(_ controller: CheckoutPickerViewController) { controller.dismiss(animated: true) } - + func didSelectV3Checkout(_ controller: CheckoutPickerViewController) { event = .didTapSingleUseCardButton controller.dismiss(animated: true) diff --git a/Example/Example/Purchase/CheckoutPickerView.swift b/Example/Example/Purchase/CheckoutPickerView.swift index f1b032c6..c05e9733 100644 --- a/Example/Example/Purchase/CheckoutPickerView.swift +++ b/Example/Example/Purchase/CheckoutPickerView.swift @@ -14,14 +14,24 @@ protocol CheckoutPickerControllerDelegate: AnyObject { func didSelectV3CheckoutWithCashAppPay(_ controller: CheckoutPickerViewController) } +enum CheckoutPickerOption { + // Checkout V3 + case button + // Checkout V3 With Cash App Pay + case buttonWithCashAppPay +} + final class CheckoutPickerViewController: UIViewController { - private lazy var viewModel = makePickerViewModel() - private lazy var pickerView = PickerView(viewModel: viewModel) + private let selectedOption: CheckoutPickerOption private weak var delegate: CheckoutPickerControllerDelegate? - init(delegate: CheckoutPickerControllerDelegate?) { + private lazy var viewModel = makePickerViewModel() + private lazy var pickerView = PickerView(viewModel: viewModel) + + init(selectedOption: CheckoutPickerOption, delegate: CheckoutPickerControllerDelegate?) { + self.selectedOption = selectedOption self.delegate = delegate super.init(nibName: nil, bundle: nil) } @@ -48,6 +58,10 @@ final class CheckoutPickerViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() + setupNavbar() + } + + private func setupNavbar() { let cancelButton = UIBarButtonItem( title: "Close", style: .plain, @@ -62,9 +76,11 @@ final class CheckoutPickerViewController: UIViewController { @objc private func onCancelTapped() { delegate?.didSelectCancel(self) } +} - // MARK: - Private +// MARK: View Building +extension CheckoutPickerViewController { private func makePickerViewModel() -> PickerViewModel { PickerViewModel( onButtonTapped: { [weak self] in @@ -74,7 +90,8 @@ final class CheckoutPickerViewController: UIViewController { onButtonWithCashAppPayTapped: { [weak self] in guard let self else { return } delegate?.didSelectV3CheckoutWithCashAppPay(self) - } + }, + selectedOption: selectedOption ) } } @@ -83,21 +100,72 @@ extension CheckoutPickerViewController { struct PickerViewModel { let onButtonTapped: () -> Void let onButtonWithCashAppPayTapped: () -> Void + let selectedOption: CheckoutPickerOption } struct PickerView: View { - private let viewModel: PickerViewModel + let viewModel: PickerViewModel - init(viewModel: PickerViewModel) { - self.viewModel = viewModel + var body: some View { + VStack { + CustomButton( + "Button Checkout", + isSelected: viewModel.selectedOption == .button, + action: viewModel.onButtonTapped + ) + CustomButton( + "Button Checkout With Cash App Pay", + isSelected: viewModel.selectedOption == .buttonWithCashAppPay, + action: viewModel.onButtonWithCashAppPayTapped + ) + Spacer() + }.padding() } + } +} - var body: some View { - VStack(alignment: .leading) { - Button("Button Checkout", action: viewModel.onButtonTapped).padding() - Button("Button Checkout With Cash App Pay", action: viewModel.onButtonWithCashAppPayTapped).padding() +// MARK: - Custom button Styling + +public struct CustomButton: View { + private let text: String + private let action: (() -> Void) + + private let isSelected: Bool + + private var icon: Image { + Image(systemName: isSelected ? "checkmark.circle" : "circle") + } + + public var body: some View { + Button(action: action) { + HStack { + Text(text).font(.body) + Spacer() + icon } + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(8) } + .buttonStyle(CustomButtonStyle(isSelected: isSelected)) + } + + public init(_ text: String, isSelected: Bool, action: @escaping() -> Void) { + self.text = text + self.action = action + self.isSelected = isSelected + } +} + +private struct CustomButtonStyle: ButtonStyle { + let isSelected: Bool + @ViewBuilder + func makeBody(configuration: Configuration) -> some View { + let background = configuration.isPressed ? Color.gray : Color.blue + configuration.label + .foregroundColor(.white) + .background(background) + .cornerRadius(8) } } From ac75afb333d1481cb9d72f80723e48a78c8c601b Mon Sep 17 00:00:00 2001 From: Mark Mroz Date: Wed, 12 Jun 2024 16:18:12 -0400 Subject: [PATCH 72/81] Use V3 checkout configs for Cash App Pay checkout --- AfterpayTests/AfterpayV3Tests.swift | 11 ++--------- Sources/Afterpay/Afterpay.swift | 13 ++++++++++--- Sources/Afterpay/AfterpayV3.swift | 2 +- Sources/Afterpay/CashApp/CashAppPayCheckout.swift | 8 ++++---- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/AfterpayTests/AfterpayV3Tests.swift b/AfterpayTests/AfterpayV3Tests.swift index f6e51bae..f48c83fd 100644 --- a/AfterpayTests/AfterpayV3Tests.swift +++ b/AfterpayTests/AfterpayV3Tests.swift @@ -21,19 +21,12 @@ final class AfterpayV3Tests: XCTestCase { environment: .production ) - let v2Configuration = try Configuration( - minimumAmount: nil, - maximumAmount: "150", - currencyCode: "USD", - locale: Locale(identifier: "en_US"), - environment: .production - ) - Afterpay.setConfiguration(v2Configuration) + Afterpay.setV3Configuration(v3Configuration) } override func tearDown() { v3Configuration = nil - Afterpay.setConfiguration(nil) + Afterpay.setV3Configuration(nil) super.tearDown() } diff --git a/Sources/Afterpay/Afterpay.swift b/Sources/Afterpay/Afterpay.swift index 061b474a..61fa9c82 100644 --- a/Sources/Afterpay/Afterpay.swift +++ b/Sources/Afterpay/Afterpay.swift @@ -243,7 +243,12 @@ public func signCashAppOrderToken( urlSession: URLSession = .shared, completion: @escaping (_ result: CashAppSigningResult) -> Void ) { - guard let configuration = getConfiguration() else { + + guard + getConfiguration() != nil || getV3Configuration() != nil, + let cashAppSigningURL = getConfiguration()?.environment.cashAppSigningURL ?? + getV3Configuration()?.environment.cashAppSigningURL + else { return assertionFailure( "Configuration must be provided before using `signCashAppOrder`" ) @@ -255,7 +260,7 @@ public func signCashAppOrderToken( let cashAppCheckout = CashAppPayCheckout( urlSession: urlSession, - configuration: configuration, + cashAppSigningURL: cashAppSigningURL, completion: completion ) @@ -418,7 +423,9 @@ internal var brand: Brand { } public var enabled: Bool { - return language != nil && getConfiguration()?.locale != nil + let enabledByV2Config = language != nil && getConfiguration()?.locale != nil + let enabledByV3Config = language != nil && getV3Configuration()?.region == .US + return enabledByV2Config || enabledByV3Config } public var cashAppClientId: String? { diff --git a/Sources/Afterpay/AfterpayV3.swift b/Sources/Afterpay/AfterpayV3.swift index 4e6cff75..b4878b00 100644 --- a/Sources/Afterpay/AfterpayV3.swift +++ b/Sources/Afterpay/AfterpayV3.swift @@ -242,7 +242,7 @@ public func presentCheckoutV3Modally( private var checkoutV3Configuration: CheckoutV3Configuration? -public func setV3Configuration(_ configuration: CheckoutV3Configuration) { +public func setV3Configuration(_ configuration: CheckoutV3Configuration?) { checkoutV3Configuration = configuration } diff --git a/Sources/Afterpay/CashApp/CashAppPayCheckout.swift b/Sources/Afterpay/CashApp/CashAppPayCheckout.swift index f00a8305..bad99e29 100644 --- a/Sources/Afterpay/CashApp/CashAppPayCheckout.swift +++ b/Sources/Afterpay/CashApp/CashAppPayCheckout.swift @@ -10,16 +10,16 @@ import Foundation class CashAppPayCheckout { private let urlSession: URLSession - private let configuration: Configuration + private let cashAppSigningURL: String private let completion: (_ result: CashAppSigningResult) -> Void public init( urlSession: URLSession, - configuration: Configuration, + cashAppSigningURL: String, completion: @escaping (_ result: CashAppSigningResult) -> Void ) { self.urlSession = urlSession - self.configuration = configuration + self.cashAppSigningURL = cashAppSigningURL self.completion = completion } @@ -28,7 +28,7 @@ class CashAppPayCheckout { let jsonRequestBody = try? JSONSerialization.data(withJSONObject: requestBody) guard let request = CashAppPayCheckout.createRequest( - urlString: configuration.environment.cashAppSigningURL, + urlString: cashAppSigningURL, jsonRequestBody: jsonRequestBody ) else { return assertionFailure("Could not create signing request when handling CashApp token") From a7bffd96c2c60393553eaf7a4732d861794478cc Mon Sep 17 00:00:00 2001 From: Mark Mroz Date: Thu, 13 Jun 2024 09:51:05 -0400 Subject: [PATCH 73/81] Updated from comments --- AfterpayTests/AfterpayV3Tests.swift | 4 ++++ Sources/Afterpay/Afterpay.swift | 24 ++++++++----------- Sources/Afterpay/AfterpayV3.swift | 6 ++++- .../Afterpay/CashApp/CashAppPayCheckout.swift | 14 +++++++++++ 4 files changed, 33 insertions(+), 15 deletions(-) diff --git a/AfterpayTests/AfterpayV3Tests.swift b/AfterpayTests/AfterpayV3Tests.swift index f48c83fd..1b22ebb8 100644 --- a/AfterpayTests/AfterpayV3Tests.swift +++ b/AfterpayTests/AfterpayV3Tests.swift @@ -117,6 +117,10 @@ final class AfterpayV3Tests: XCTestCase { waitForExpectations(timeout: 0.5) } + + func testCashAppClientIdForV3() { + XCTAssertEqual(Afterpay.checkoutV3CashAppClientId, "CA-CI_AFTERPAY") + } } // MARK: - Private diff --git a/Sources/Afterpay/Afterpay.swift b/Sources/Afterpay/Afterpay.swift index 61fa9c82..a3174b35 100644 --- a/Sources/Afterpay/Afterpay.swift +++ b/Sources/Afterpay/Afterpay.swift @@ -244,11 +244,7 @@ public func signCashAppOrderToken( completion: @escaping (_ result: CashAppSigningResult) -> Void ) { - guard - getConfiguration() != nil || getV3Configuration() != nil, - let cashAppSigningURL = getConfiguration()?.environment.cashAppSigningURL ?? - getV3Configuration()?.environment.cashAppSigningURL - else { + guard let configuration = getConfiguration() else { return assertionFailure( "Configuration must be provided before using `signCashAppOrder`" ) @@ -258,13 +254,11 @@ public func signCashAppOrderToken( return } - let cashAppCheckout = CashAppPayCheckout( + CashAppPayCheckout.signCashAppOrderToken( + token, + cashAppSigningURL: configuration.environment.cashAppSigningURL, urlSession: urlSession, - cashAppSigningURL: cashAppSigningURL, - completion: completion - ) - - cashAppCheckout.signToken(token: token) + completion: completion) } public func validateCashAppOrder( @@ -423,15 +417,17 @@ internal var brand: Brand { } public var enabled: Bool { - let enabledByV2Config = language != nil && getConfiguration()?.locale != nil - let enabledByV3Config = language != nil && getV3Configuration()?.region == .US - return enabledByV2Config || enabledByV3Config + return language != nil && getConfiguration()?.locale != nil } public var cashAppClientId: String? { getConfiguration()?.environment.cashAppClientId } +public var checkoutV3CashAppClientId: String? { + getV3Configuration()?.environment.cashAppClientId +} + public var environment: Environment? { getConfiguration()?.environment } diff --git a/Sources/Afterpay/AfterpayV3.swift b/Sources/Afterpay/AfterpayV3.swift index b4878b00..b3d7850d 100644 --- a/Sources/Afterpay/AfterpayV3.swift +++ b/Sources/Afterpay/AfterpayV3.swift @@ -48,7 +48,11 @@ public func checkoutV3WithCashAppPay( ) { checkoutResult in switch checkoutResult { case .success(let checkout): - signCashAppOrderToken(checkout.token, urlSession: urlSession) { signingResult in + CashAppPayCheckout.signCashAppOrderToken( + checkout.token, + cashAppSigningURL: configuration.environment.cashAppSigningURL, + urlSession: urlSession + ) { signingResult in switch signingResult { case .success(let signingData): let response = CheckoutV3CashAppPayPayload( diff --git a/Sources/Afterpay/CashApp/CashAppPayCheckout.swift b/Sources/Afterpay/CashApp/CashAppPayCheckout.swift index bad99e29..570f4d66 100644 --- a/Sources/Afterpay/CashApp/CashAppPayCheckout.swift +++ b/Sources/Afterpay/CashApp/CashAppPayCheckout.swift @@ -264,4 +264,18 @@ internal extension CashAppPayCheckout { completion: completion ).resume() } + + static func signCashAppOrderToken( + _ token: Token, + cashAppSigningURL: String, + urlSession: URLSession = .shared, + completion: @escaping (_ result: CashAppSigningResult) -> Void + ) { + let cashAppCheckout = CashAppPayCheckout( + urlSession: urlSession, + cashAppSigningURL: cashAppSigningURL, + completion: completion + ) + cashAppCheckout.signToken(token: token) + } } From 3ffeadabe9898bdfa13661d8ee2d7ef73b8a84c6 Mon Sep 17 00:00:00 2001 From: Mark Mroz Date: Thu, 13 Jun 2024 10:01:01 -0400 Subject: [PATCH 74/81] added tests for Cash App Pay --- Afterpay.xcodeproj/project.pbxproj | 4 +++ AfterpayTests/CashAppPayTests.swift | 42 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 AfterpayTests/CashAppPayTests.swift diff --git a/Afterpay.xcodeproj/project.pbxproj b/Afterpay.xcodeproj/project.pbxproj index dbcaac01..7dadf7e3 100644 --- a/Afterpay.xcodeproj/project.pbxproj +++ b/Afterpay.xcodeproj/project.pbxproj @@ -52,6 +52,7 @@ 5FB958DC2C0A528300137468 /* ConfirmationV3+CashAppPay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FB958DB2C0A528300137468 /* ConfirmationV3+CashAppPay.swift */; }; 5FB958DE2C0E00DC00137468 /* AfterpayV3Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FB958DD2C0E00DC00137468 /* AfterpayV3Tests.swift */; }; 5FB958E32C0E126200137468 /* URLSessionMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FB958E22C0E126200137468 /* URLSessionMock.swift */; }; + 5FBAABA02C1B3109003558AF /* CashAppPayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FBAAB9F2C1B3109003558AF /* CashAppPayTests.swift */; }; 6602EF0F25358A8000A0468C /* ColorScheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6602EF0E25358A8000A0468C /* ColorScheme.swift */; }; 6605666324E5199500DA588E /* Locales.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6605666224E5199500DA588E /* Locales.swift */; }; 66169312257A06B200DF6CF4 /* CheckoutV2Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66169311257A06B200DF6CF4 /* CheckoutV2Message.swift */; }; @@ -148,6 +149,7 @@ 5FB958DB2C0A528300137468 /* ConfirmationV3+CashAppPay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConfirmationV3+CashAppPay.swift"; sourceTree = ""; }; 5FB958DD2C0E00DC00137468 /* AfterpayV3Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AfterpayV3Tests.swift; sourceTree = ""; }; 5FB958E22C0E126200137468 /* URLSessionMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionMock.swift; sourceTree = ""; }; + 5FBAAB9F2C1B3109003558AF /* CashAppPayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CashAppPayTests.swift; sourceTree = ""; }; 6602EF0E25358A8000A0468C /* ColorScheme.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ColorScheme.swift; sourceTree = ""; }; 6605666224E5199500DA588E /* Locales.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Locales.swift; sourceTree = ""; }; 66169311257A06B200DF6CF4 /* CheckoutV2Message.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckoutV2Message.swift; sourceTree = ""; }; @@ -323,6 +325,7 @@ 557511BA264259C30040CC51 /* CombineWrapperTests.swift */, 4573E4B32A4D4DCB00F5CEAA /* LocaleTests.swift */, 5FB958DD2C0E00DC00137468 /* AfterpayV3Tests.swift */, + 5FBAAB9F2C1B3109003558AF /* CashAppPayTests.swift */, ); path = AfterpayTests; sourceTree = ""; @@ -639,6 +642,7 @@ 550D48152625539900C0B0C6 /* WidgetStatusTests.swift in Sources */, 557511BB264259C30040CC51 /* CombineWrapperTests.swift in Sources */, 66C3F7FB25397A810086DD0A /* CurrencyFormatterTests.swift in Sources */, + 5FBAABA02C1B3109003558AF /* CashAppPayTests.swift in Sources */, 4573E4B42A4D4DCB00F5CEAA /* LocaleTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/AfterpayTests/CashAppPayTests.swift b/AfterpayTests/CashAppPayTests.swift new file mode 100644 index 00000000..daa224f2 --- /dev/null +++ b/AfterpayTests/CashAppPayTests.swift @@ -0,0 +1,42 @@ +// +// CashAppPayTests.swift +// AfterpayTests +// +// Created by Mark Mroz on 2024-06-13. +// Copyright © 2024 Afterpay. All rights reserved. +// + +import XCTest +@testable import Afterpay + +final class CashAppPayTests: XCTestCase { + func testSignCashAppOrderToken() { + let signingURL = "https://api-plus.us.afterpay.com/v2/payments/sign-payment" + let signTokenExpectation = self.expectation(description: "Sign Token") + let handle = URLSessionMock(requestDataTaskHandler: { request in + XCTAssertEqual(request.url?.absoluteString, signingURL) + + return (Fixtures.signPaymentResponse, HTTPURLResponse(), nil) + }) + + CashAppPayCheckout.signCashAppOrderToken("Token", cashAppSigningURL: signingURL, urlSession: handle) { _ in + signTokenExpectation.fulfill() + } + waitForExpectations(timeout: 0.5) + } +} + +// MARK: - Fixtures + +extension CashAppPayTests { + // swiftlint:disable line_length indentation_width + enum Fixtures { + static let signPaymentResponse = """ + { + "jwtToken": "eyJraWQiOiJrZXkxIiwiYWxnIjoiRVMyNTYiLCJ0dGwiOiIxNzE3NDM3Nzc1In0.eyJleHRlcm5hbE1lcmNoYW50SWQiOiJNTUlfNm52Z3U5dm93ZWFnd3Q1ZG4wa2R0ZWFpbyIsInRva2VuIjoiMDAyLmlscXFqZjJ1ZG11MnJwM3hjdTdjYjZtenVwNnRlZndiM2Q1eWs2Y3pyZnZqd3Nmb2NuIiwiYW1vdW50Ijp7ImFtb3VudCI6IjUwLjAwIiwiY3VycmVuY3kiOiJVU0QiLCJzeW1ib2wiOiIkIn0sInJlZGlyZWN0VXJsIjoiaHR0cHM6Ly9zdGF0aWMtdXMuYWZ0ZXJwYXkuY29tL2phdmFzY3JpcHQvYnV0dG9uL2luZGV4Lmh0bWwifQ.KRVxIHwrH_QPDTX2WF3Ei5wI7InE_v7xvPDDXFF2YBka2hUROkSX6ubdrFufIkE6yaFHyrlAGoQiS17VB80IDA", + "redirectUrl": "https://static-us.afterpay.com", + "externalBrandId": "BRAND_ID" + } + """.data(using: .utf8) + } +} From 383d8a4cf6939b6dd6c97c20946424856d659d07 Mon Sep 17 00:00:00 2001 From: Mark Mroz Date: Sun, 16 Jun 2024 21:31:25 -0400 Subject: [PATCH 75/81] fix button webview --- Afterpay.xcodeproj/project.pbxproj | 12 +++ AfterpayTests/CheckoutV3RequestTests.swift | 78 +++++++++++++++++++ ...odingContainerProtocolExtensionTests.swift | 46 +++++++++++ Sources/Afterpay/Checkout/CheckoutV3.swift | 31 ++++++++ ...EncodingContainerProtocol+Extensions.swift | 17 ++++ 5 files changed, 184 insertions(+) create mode 100644 AfterpayTests/CheckoutV3RequestTests.swift create mode 100644 AfterpayTests/KeyedEncodingContainerProtocolExtensionTests.swift create mode 100644 Sources/Afterpay/Helpers/KeyedEncodingContainerProtocol+Extensions.swift diff --git a/Afterpay.xcodeproj/project.pbxproj b/Afterpay.xcodeproj/project.pbxproj index 7dadf7e3..b72dcf3f 100644 --- a/Afterpay.xcodeproj/project.pbxproj +++ b/Afterpay.xcodeproj/project.pbxproj @@ -48,6 +48,9 @@ 557511BF2644CAA50040CC51 /* WKWebView+Cache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 557511BE2644CAA50040CC51 /* WKWebView+Cache.swift */; }; 557511C12644D0890040CC51 /* WKWebViewConfiguration+UserAgent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 557511C02644D0890040CC51 /* WKWebViewConfiguration+UserAgent.swift */; }; 55A2D307261BB36C00D8E23A /* Money.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55A2D306261BB36C00D8E23A /* Money.swift */; }; + 5F4336942C1FC3F700ECD0DE /* CheckoutV3RequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F4336932C1FC3F700ECD0DE /* CheckoutV3RequestTests.swift */; }; + 5F4336982C1FC48C00ECD0DE /* KeyedEncodingContainerProtocolExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F4336972C1FC48C00ECD0DE /* KeyedEncodingContainerProtocolExtensionTests.swift */; }; + 5F4336992C1FC8E600ECD0DE /* KeyedEncodingContainerProtocol+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F4336952C1FC46300ECD0DE /* KeyedEncodingContainerProtocol+Extensions.swift */; }; 5FB958DA2C0A526800137468 /* CheckoutV3CashAppPayResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FB958D92C0A526800137468 /* CheckoutV3CashAppPayResult.swift */; }; 5FB958DC2C0A528300137468 /* ConfirmationV3+CashAppPay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FB958DB2C0A528300137468 /* ConfirmationV3+CashAppPay.swift */; }; 5FB958DE2C0E00DC00137468 /* AfterpayV3Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FB958DD2C0E00DC00137468 /* AfterpayV3Tests.swift */; }; @@ -145,6 +148,9 @@ 557511BE2644CAA50040CC51 /* WKWebView+Cache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WKWebView+Cache.swift"; sourceTree = ""; }; 557511C02644D0890040CC51 /* WKWebViewConfiguration+UserAgent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WKWebViewConfiguration+UserAgent.swift"; sourceTree = ""; }; 55A2D306261BB36C00D8E23A /* Money.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Money.swift; sourceTree = ""; }; + 5F4336932C1FC3F700ECD0DE /* CheckoutV3RequestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckoutV3RequestTests.swift; sourceTree = ""; }; + 5F4336952C1FC46300ECD0DE /* KeyedEncodingContainerProtocol+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KeyedEncodingContainerProtocol+Extensions.swift"; sourceTree = ""; }; + 5F4336972C1FC48C00ECD0DE /* KeyedEncodingContainerProtocolExtensionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyedEncodingContainerProtocolExtensionTests.swift; sourceTree = ""; }; 5FB958D92C0A526800137468 /* CheckoutV3CashAppPayResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckoutV3CashAppPayResult.swift; sourceTree = ""; }; 5FB958DB2C0A528300137468 /* ConfirmationV3+CashAppPay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConfirmationV3+CashAppPay.swift"; sourceTree = ""; }; 5FB958DD2C0E00DC00137468 /* AfterpayV3Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AfterpayV3Tests.swift; sourceTree = ""; }; @@ -233,6 +239,7 @@ 45144E7127FD10E00061EBE8 /* AfterpayAssetProvider.swift */, 45144E7327FD11470061EBE8 /* AfterpayBundleFinder.swift */, 4509D35A2940192400952DAD /* JWT.swift */, + 5F4336952C1FC46300ECD0DE /* KeyedEncodingContainerProtocol+Extensions.swift */, ); path = Helpers; sourceTree = ""; @@ -326,6 +333,8 @@ 4573E4B32A4D4DCB00F5CEAA /* LocaleTests.swift */, 5FB958DD2C0E00DC00137468 /* AfterpayV3Tests.swift */, 5FBAAB9F2C1B3109003558AF /* CashAppPayTests.swift */, + 5F4336932C1FC3F700ECD0DE /* CheckoutV3RequestTests.swift */, + 5F4336972C1FC48C00ECD0DE /* KeyedEncodingContainerProtocolExtensionTests.swift */, ); path = AfterpayTests; sourceTree = ""; @@ -643,7 +652,9 @@ 557511BB264259C30040CC51 /* CombineWrapperTests.swift in Sources */, 66C3F7FB25397A810086DD0A /* CurrencyFormatterTests.swift in Sources */, 5FBAABA02C1B3109003558AF /* CashAppPayTests.swift in Sources */, + 5F4336942C1FC3F700ECD0DE /* CheckoutV3RequestTests.swift in Sources */, 4573E4B42A4D4DCB00F5CEAA /* LocaleTests.swift in Sources */, + 5F4336982C1FC48C00ECD0DE /* KeyedEncodingContainerProtocolExtensionTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -699,6 +710,7 @@ 55432830263A61C4005512E4 /* CombineWrapper.swift in Sources */, 42DA4F9826E0740500204E75 /* IntroText.swift in Sources */, 4509D351293473F500952DAD /* CompactBadgeView.swift in Sources */, + 5F4336992C1FC8E600ECD0DE /* KeyedEncodingContainerProtocol+Extensions.swift in Sources */, 45D406D127FE4B67009AA4EE /* LogoView.swift in Sources */, 157E88D125CBCA49007E54C4 /* Result+Fold.swift in Sources */, 55A2D307261BB36C00D8E23A /* Money.swift in Sources */, diff --git a/AfterpayTests/CheckoutV3RequestTests.swift b/AfterpayTests/CheckoutV3RequestTests.swift new file mode 100644 index 00000000..bbd26f3f --- /dev/null +++ b/AfterpayTests/CheckoutV3RequestTests.swift @@ -0,0 +1,78 @@ +// +// CheckoutV3RequestTests.swift +// AfterpayTests +// +// Created by Mark Mroz on 2024-06-16. +// Copyright © 2024 Afterpay. All rights reserved. +// + +@testable import Afterpay +import XCTest + +final class CheckoutV3RequestTests: XCTestCase { + func testEncodingWithCashAppIncludesEncodedKey() throws { + let customer = Customer( + email: "jack@email.com", + givenNames: nil, + surname: nil, + phoneNumber: nil, + shippingInformation: nil, + billingInformation: nil + ) + let request = CheckoutV3.Request( + consumer: customer, + orderTotal: OrderTotal(total: 1, shipping: 1, tax: 0), + items: [], + isCashAppPay: true, + configuration: CheckoutV3Configuration(shopDirectoryMerchantId: "", region: .US, environment: .production) + ) + let encoded = try JSONEncoder().encode(request) + let stringData = try XCTUnwrap(String(data: encoded, encoding: .utf8)) + XCTAssert(stringData.localizedCaseInsensitiveContains("isCashApp")) + } + + func testEncodingWithoutCashAppDoesNotIncludeEncodedKey() throws { + let customer = Customer( + email: "jack@email.com", + givenNames: nil, + surname: nil, + phoneNumber: nil, + shippingInformation: nil, + billingInformation: nil + ) + + let request = CheckoutV3.Request( + consumer: customer, + orderTotal: OrderTotal(total: 1, shipping: 1, tax: 0), + items: [], + isCashAppPay: false, + configuration: CheckoutV3Configuration(shopDirectoryMerchantId: "", region: .US, environment: .production) + ) + let encoded = try JSONEncoder().encode(request) + let stringData = try XCTUnwrap(String(data: encoded, encoding: .utf8)) + XCTAssertFalse(stringData.localizedCaseInsensitiveContains("isCashApp")) + } +} + +private extension CheckoutV3RequestTests { + struct Customer: CheckoutV3Consumer { + let email: String + let givenNames: String? + let surname: String? + let phoneNumber: String? + let shippingInformation: CheckoutV3Contact? + let billingInformation: CheckoutV3Contact? + } + + struct Contact: CheckoutV3Contact { + let name: String + let line1: String + let line2: String? + let area1: String? + let area2: String? + let region: String? + let postcode: String? + let countryCode: String + let phoneNumber: String? + } +} diff --git a/AfterpayTests/KeyedEncodingContainerProtocolExtensionTests.swift b/AfterpayTests/KeyedEncodingContainerProtocolExtensionTests.swift new file mode 100644 index 00000000..28c2a909 --- /dev/null +++ b/AfterpayTests/KeyedEncodingContainerProtocolExtensionTests.swift @@ -0,0 +1,46 @@ +// +// KeyedEncodingContainerProtocolExtensionTests.swift +// AfterpayTests +// +// Created by Mark Mroz on 2024-06-16. +// Copyright © 2024 Afterpay. All rights reserved. +// + +@testable import Afterpay +import XCTest + +final class KeyedEncodingContainerProtocolTests: XCTestCase { + func testEncodeIfTrue() throws { + let encoder = JSONEncoder() + + let params = Params(buyNow: true) + let data = try encoder.encode(params) + let paramsString = String(data: data, encoding: .utf8) + XCTAssertEqual(paramsString, "{\"buyNow\":true}") + } + + func testDoesNotEncodeIfFalse() throws { + let encoder = JSONEncoder() + + let params = Params(buyNow: false) + let data = try encoder.encode(params) + let paramsString = String(data: data, encoding: .utf8) + XCTAssertEqual(paramsString, "{}") + } +} + +private extension KeyedEncodingContainerProtocolTests { + // swiftlint:disable nesting + struct Params: Encodable { + let buyNow: Bool + + enum CodingKeys: CodingKey { + case buyNow + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfTrue(self.buyNow, forKey: .buyNow) + } + } +} diff --git a/Sources/Afterpay/Checkout/CheckoutV3.swift b/Sources/Afterpay/Checkout/CheckoutV3.swift index dc23c66a..fcb588f4 100644 --- a/Sources/Afterpay/Checkout/CheckoutV3.swift +++ b/Sources/Afterpay/Checkout/CheckoutV3.swift @@ -67,6 +67,37 @@ enum CheckoutV3 { self.isCashAppPay = isCashAppPay } + // MARK: - Encoding + + enum CodingKeys: CodingKey { + case shopDirectoryId + case shopDirectoryMerchantId + case amount + case shippingAmount + case taxAmount + case items + case consumer + case merchant + case shipping + case billing + case isCashAppPay + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(shopDirectoryId, forKey: .shopDirectoryId) + try container.encode(shopDirectoryMerchantId, forKey: .shopDirectoryMerchantId) + try container.encode(amount, forKey: .amount) + try container.encodeIfPresent(shippingAmount, forKey: .shippingAmount) + try container.encodeIfPresent(taxAmount, forKey: .taxAmount) + try container.encode(items, forKey: .items) + try container.encode(consumer, forKey: .consumer) + try container.encode(merchant, forKey: .merchant) + try container.encodeIfPresent(shipping, forKey: .shipping) + try container.encodeIfPresent(billing, forKey: .billing) + try container.encodeIfTrue(isCashAppPay, forKey: .isCashAppPay) + } + // MARK: - Inner types struct Item: Encodable { diff --git a/Sources/Afterpay/Helpers/KeyedEncodingContainerProtocol+Extensions.swift b/Sources/Afterpay/Helpers/KeyedEncodingContainerProtocol+Extensions.swift new file mode 100644 index 00000000..f926c8de --- /dev/null +++ b/Sources/Afterpay/Helpers/KeyedEncodingContainerProtocol+Extensions.swift @@ -0,0 +1,17 @@ +// +// KeyedEncodingContainerProtocol+Extensions.swift +// AfterpayTests +// +// Created by Mark Mroz on 2024-06-16. +// Copyright © 2024 Afterpay. All rights reserved. +// + +import Foundation + +extension KeyedEncodingContainer { + /// Encode the given value if it is true otherwise do nothing. + mutating func encodeIfTrue(_ value: Bool, forKey key: Key) throws { + guard value else { return } + try encode(value, forKey: key) + } +} From 3fcad6fc249ce284b5585926e4d4ef351517a3bb Mon Sep 17 00:00:00 2001 From: Scott Antonac Date: Mon, 27 May 2024 16:33:22 +1000 Subject: [PATCH 76/81] docs: add checkout diagrams --- docs/src/Gemfile.lock | 113 +++++++++++---------- docs/src/_config.yml | 3 + docs/src/_includes/mermaid_config.js | 11 ++ docs/src/_sass/color_schemes/afterpay.scss | 1 + docs/src/_sass/custom/custom.scss | 4 + docs/src/getting-started/cash-app-pay.md | 77 ++++++++++++++ docs/src/getting-started/checkout-v1.md | 45 ++++++++ docs/src/getting-started/checkout-v2.md | 49 ++++++++- docs/src/getting-started/configuration.md | 3 +- 9 files changed, 250 insertions(+), 56 deletions(-) create mode 100644 docs/src/_includes/mermaid_config.js diff --git a/docs/src/Gemfile.lock b/docs/src/Gemfile.lock index c029bf9c..1d1bc7f8 100644 --- a/docs/src/Gemfile.lock +++ b/docs/src/Gemfile.lock @@ -1,46 +1,56 @@ GEM remote: https://rubygems.org/ specs: - activesupport (7.0.5) + activesupport (7.1.3.3) + base64 + bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) + connection_pool (>= 2.2.5) + drb i18n (>= 1.6, < 2) minitest (>= 5.1) + mutex_m tzinfo (~> 2.0) - addressable (2.8.4) + addressable (2.8.6) public_suffix (>= 2.0.2, < 6.0) + base64 (0.2.0) + bigdecimal (3.1.8) coffee-script (2.4.1) coffee-script-source execjs - coffee-script-source (1.11.1) + coffee-script-source (1.12.2) colorator (1.1.0) - commonmarker (0.23.9) - concurrent-ruby (1.2.2) - dnsruby (1.70.0) + commonmarker (0.23.10) + concurrent-ruby (1.2.3) + connection_pool (2.4.1) + dnsruby (1.72.1) simpleidn (~> 0.2.1) + drb (2.2.1) em-websocket (0.5.3) eventmachine (>= 0.12.9) http_parser.rb (~> 0) ethon (0.16.0) ffi (>= 1.15.0) eventmachine (1.2.7) - execjs (2.8.1) - faraday (2.7.6) + execjs (2.9.1) + faraday (2.8.1) + base64 faraday-net_http (>= 2.0, < 3.1) ruby2_keywords (>= 0.0.4) faraday-net_http (3.0.2) - ffi (1.15.5) + ffi (1.16.3) forwardable-extended (2.6.0) - gemoji (3.0.1) - github-pages (228) - github-pages-health-check (= 1.17.9) - jekyll (= 3.9.3) - jekyll-avatar (= 0.7.0) - jekyll-coffeescript (= 1.1.1) + gemoji (4.1.0) + github-pages (231) + github-pages-health-check (= 1.18.2) + jekyll (= 3.9.5) + jekyll-avatar (= 0.8.0) + jekyll-coffeescript (= 1.2.2) jekyll-commonmark-ghpages (= 0.4.0) - jekyll-default-layout (= 0.1.4) - jekyll-feed (= 0.15.1) + jekyll-default-layout (= 0.1.5) + jekyll-feed (= 0.17.0) jekyll-gist (= 1.5.0) - jekyll-github-metadata (= 2.13.0) + jekyll-github-metadata (= 2.16.1) jekyll-include-cache (= 0.2.1) jekyll-mentions (= 1.6.0) jekyll-optional-front-matter (= 0.3.2) @@ -67,28 +77,28 @@ GEM jekyll-theme-tactile (= 0.2.0) jekyll-theme-time-machine (= 0.2.0) jekyll-titles-from-headings (= 0.5.3) - jemoji (= 0.12.0) - kramdown (= 2.3.2) + jemoji (= 0.13.0) + kramdown (= 2.4.0) kramdown-parser-gfm (= 1.1.0) liquid (= 4.0.4) mercenary (~> 0.3) minima (= 2.5.1) nokogiri (>= 1.13.6, < 2.0) - rouge (= 3.26.0) + rouge (= 3.30.0) terminal-table (~> 1.4) - github-pages-health-check (1.17.9) + github-pages-health-check (1.18.2) addressable (~> 2.3) dnsruby (~> 1.60) - octokit (~> 4.0) - public_suffix (>= 3.0, < 5.0) + octokit (>= 4, < 8) + public_suffix (>= 3.0, < 6.0) typhoeus (~> 1.3) html-pipeline (2.14.3) activesupport (>= 2) nokogiri (>= 1.4) http_parser.rb (0.8.0) - i18n (1.14.1) + i18n (1.14.5) concurrent-ruby (~> 1.0) - jekyll (3.9.3) + jekyll (3.9.5) addressable (~> 2.4) colorator (~> 1.0) em-websocket (~> 0.5) @@ -101,11 +111,11 @@ GEM pathutil (~> 0.9) rouge (>= 1.7, < 4) safe_yaml (~> 1.0) - jekyll-avatar (0.7.0) + jekyll-avatar (0.8.0) jekyll (>= 3.0, < 5.0) - jekyll-coffeescript (1.1.1) + jekyll-coffeescript (1.2.2) coffee-script (~> 2.2) - coffee-script-source (~> 1.11.1) + coffee-script-source (~> 1.12) jekyll-commonmark (1.4.0) commonmarker (~> 0.22) jekyll-commonmark-ghpages (0.4.0) @@ -113,15 +123,15 @@ GEM jekyll (~> 3.9.0) jekyll-commonmark (~> 1.4.0) rouge (>= 2.0, < 5.0) - jekyll-default-layout (0.1.4) - jekyll (~> 3.0) - jekyll-feed (0.15.1) + jekyll-default-layout (0.1.5) + jekyll (>= 3.0, < 5.0) + jekyll-feed (0.17.0) jekyll (>= 3.7, < 5.0) jekyll-gist (1.5.0) octokit (~> 4.2) - jekyll-github-metadata (2.13.0) + jekyll-github-metadata (2.16.1) jekyll (>= 3.4, < 5.0) - octokit (~> 4.0, != 4.4.0) + octokit (>= 4, < 7, != 4.4.0) jekyll-include-cache (0.2.1) jekyll (>= 3.7, < 5.0) jekyll-mentions (1.6.0) @@ -192,26 +202,27 @@ GEM jekyll (>= 3.3, < 5.0) jekyll-watch (2.2.1) listen (~> 3.0) - jemoji (0.12.0) - gemoji (~> 3.0) + jemoji (0.13.0) + gemoji (>= 3, < 5) html-pipeline (~> 2.2) jekyll (>= 3.0, < 5.0) - kramdown (2.3.2) + kramdown (2.4.0) rexml kramdown-parser-gfm (1.1.0) kramdown (~> 2.0) liquid (4.0.4) - listen (3.8.0) + listen (3.9.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) mercenary (0.3.6) - mini_portile2 (2.8.2) + mini_portile2 (2.8.6) minima (2.5.1) jekyll (>= 3.5, < 5.0) jekyll-feed (~> 0.9) jekyll-seo-tag (~> 2.1) - minitest (5.18.0) - nokogiri (1.15.2) + minitest (5.23.1) + mutex_m (0.2.0) + nokogiri (1.15.6) mini_portile2 (~> 2.8.2) racc (~> 1.4) octokit (4.25.1) @@ -219,13 +230,14 @@ GEM sawyer (~> 0.9) pathutil (0.16.2) forwardable-extended (~> 2.6) - public_suffix (4.0.7) - racc (1.7.0) + public_suffix (5.0.5) + racc (1.8.0) rb-fsevent (0.11.2) - rb-inotify (0.10.1) + rb-inotify (0.11.1) ffi (~> 1.0) - rexml (3.2.5) - rouge (3.26.0) + rexml (3.2.8) + strscan (>= 3.0.9) + rouge (3.30.0) ruby2_keywords (0.0.5) rubyzip (2.3.2) safe_yaml (1.0.5) @@ -237,17 +249,14 @@ GEM sawyer (0.9.2) addressable (>= 2.3.5) faraday (>= 0.17.3, < 3) - simpleidn (0.2.1) - unf (~> 0.1.4) + simpleidn (0.2.3) + strscan (3.1.0) terminal-table (1.8.0) unicode-display_width (~> 1.1, >= 1.1.1) - typhoeus (1.4.0) + typhoeus (1.4.1) ethon (>= 0.9.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - unf (0.1.4) - unf_ext - unf_ext (0.0.8.2) unicode-display_width (1.8.0) PLATFORMS diff --git a/docs/src/_config.yml b/docs/src/_config.yml index 05c313cb..4d9c0f38 100644 --- a/docs/src/_config.yml +++ b/docs/src/_config.yml @@ -28,3 +28,6 @@ callouts: info: title: Info color: blue + +mermaid: + version: "10.9.1" diff --git a/docs/src/_includes/mermaid_config.js b/docs/src/_includes/mermaid_config.js new file mode 100644 index 00000000..ece7b6d3 --- /dev/null +++ b/docs/src/_includes/mermaid_config.js @@ -0,0 +1,11 @@ +{ + theme: "base", + 'themeVariables': { + 'primaryColor': '#b2fce4', + 'primaryBorderColor': '#dfdfdf', + 'lineColor': '#F8B229', + 'secondaryColor': '#006100', + 'tertiaryColor': '#fff', + 'fontFamily': 'system-ui, -apple-system, blinkmacsystemfont, "Segoe UI", roboto, "Helvetica Neue", arial, sans-serif, "Segoe UI Emoji"' + } +} diff --git a/docs/src/_sass/color_schemes/afterpay.scss b/docs/src/_sass/color_schemes/afterpay.scss index a470496c..2bc9aac0 100644 --- a/docs/src/_sass/color_schemes/afterpay.scss +++ b/docs/src/_sass/color_schemes/afterpay.scss @@ -5,3 +5,4 @@ $feedback-color: rgb(223, 234, 246); $sidebar-color: $white; $nav-width: 18.75rem; $nav-width-md: 18.75rem; +$content-width: 60rem; diff --git a/docs/src/_sass/custom/custom.scss b/docs/src/_sass/custom/custom.scss index c582ab67..7d0cea84 100644 --- a/docs/src/_sass/custom/custom.scss +++ b/docs/src/_sass/custom/custom.scss @@ -38,6 +38,10 @@ code { color: white; display: inline-block; padding: 4px; + + &.language-mermaid { + display: block; + } } img { diff --git a/docs/src/getting-started/cash-app-pay.md b/docs/src/getting-started/cash-app-pay.md index cfc4e16b..ab270ea3 100644 --- a/docs/src/getting-started/cash-app-pay.md +++ b/docs/src/getting-started/cash-app-pay.md @@ -206,6 +206,83 @@ Afterpay.validateCashAppOrder(jwt: cashData.jwt, customerId: customerId, grantId The approved customer request will have grants associated with it which can be used with Afterpay’s Capture Payment API. Pass the `grantId` along with the token to capture using a server-to-server request. +## Sequence Diagram + +The below diagram describes the happy path. + +``` mermaid +%%{ + init: { + 'theme': 'base', + 'themeVariables': { + 'primaryColor': '#00c846', + 'primaryTextColor': '#FFFFFF', + 'primaryBorderColor': '#dfdfdf', + 'signalTextColor': '#000000', + 'signalColor': '#000000', + 'secondaryColor': '#006100', + 'tertiaryColor': '#fff' + } + } +}%% + +sequenceDiagram + participant App + participant Afterpay SDK + participant Cash App Pay SDK + participant Proxy Server + + participant Afterpay API + Note over App,Afterpay API: Setup + + App->>Afterpay SDK: Configure the SDK + Note over App,Afterpay SDK: Required for setting environment + + App->>Cash App Pay SDK: Create Cash App Pay instance + Note over App,Cash App Pay SDK: Ensure same environment
as Afterpay SDK config + + App->>App: Implement
deep linking + + App->>Cash App Pay SDK: Register for state updates + + Note over App,Afterpay API: Create Customer Request and Capture + + App->>Proxy Server: Get Token Request + + Proxy Server->>Afterpay API: Create Checkout Request + Note over Proxy Server,Afterpay API: Ensure same environment
as Afterpay SDK config

Request body should
contain `isCashAppPay: true` + + Afterpay API-->>Proxy Server: Create Checkout Response + + Note over Afterpay API,Proxy Server: Body contains a token + + Proxy Server-->>App: Get Token Response + + App->>Afterpay SDK: Sign the token + + Afterpay SDK->>Afterpay API: Token signing request + + Afterpay API-->>Afterpay SDK: Token signing response + + Afterpay SDK-->>App: Decoded token data + + App->>Cash App Pay SDK: Create a customer request + + App->>Cash App Pay SDK: Authorize the customer request + + App->>Afterpay SDK: Validate the order + + App->>Proxy Server: Upon approved state send capture request + Note over App,Proxy Server: Pass the Grant Id (from the approved state)
and token in the body + + Proxy Server->>Afterpay API: Capture Payment Request + + Afterpay API-->>Proxy Server: Capture Payment Response + + Proxy Server-->>App: Capture Payment Respnse + + App->>App: Handle payment
capture response +``` [custom-url-schemes]: https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app [universal-links]: https://developer.apple.com/ios/universal-links/ diff --git a/docs/src/getting-started/checkout-v1.md b/docs/src/getting-started/checkout-v1.md index f3cdbc15..6dc5e775 100644 --- a/docs/src/getting-started/checkout-v1.md +++ b/docs/src/getting-started/checkout-v1.md @@ -107,3 +107,48 @@ struct MyView: View { } ``` + +## Sequence Diagram + +The below diagram describes the happy path. + +``` mermaid +sequenceDiagram + participant App + participant Afterpay SDK + participant Proxy Server + participant Afterpay API + + Note over App,Afterpay API: Setup + + App->>Afterpay SDK: Configure the SDK + Note over App,Afterpay SDK: Only required if
setting consumer locale + + Note over App,Afterpay API: Create checkout and Capture + + App->>Proxy Server: Get Checkout URL Request + + Proxy Server->>Afterpay API: Create Checkout Request + Note over Proxy Server,Afterpay API: Ensure same environment
as Afterpay SDK config + + Afterpay API-->>Proxy Server: Create Checkout Response + Note over Afterpay API,Proxy Server: Body contains a URL + + Proxy Server-->>App: Get URL Response + + App->>Afterpay SDK: Launch the checkout
with the URL + + Note over App,Afterpay API: Consumer confirms Afterpay checkout + + Afterpay SDK-->>App: Checkout result + + App->>Proxy Server: Capture request + + Proxy Server->>Afterpay API: Capture request + + Afterpay API-->>Proxy Server: Capture response + + Proxy Server-->>App: Capture Response + + App->>App: Handle response +``` diff --git a/docs/src/getting-started/checkout-v2.md b/docs/src/getting-started/checkout-v2.md index 3f9bcdf1..cf923ff9 100644 --- a/docs/src/getting-started/checkout-v2.md +++ b/docs/src/getting-started/checkout-v2.md @@ -17,9 +17,6 @@ nav_order: 3 {:toc} -{: .alert } -Checkout v2 is not available at this time for the following regions: France, Italy, Spain. - Checkout version 2 allows you to load the checkout token on demand via `didCommenceCheckout` while presenting a loading view. It also supports `express` checkout features and callbacks which can either be handled in line or via a checkout handler object. {: .note } @@ -122,5 +119,51 @@ final class MyViewController: UIViewController { } ``` +## Sequence Diagram + +The below diagram describes the happy path. + +``` mermaid +sequenceDiagram + participant App + participant Afterpay SDK + participant Proxy Server + participant Afterpay API + + Note over App,Afterpay API: Setup + + App->>Afterpay SDK: Configure the SDK + + App->>Afterpay SDK: Setup checkout handlers + + Note over App,Afterpay API: Create checkout and Capture + + App->>Proxy Server: Get Checkout Token Request + + Proxy Server->>Afterpay API: Create Checkout Request + Note over Proxy Server,Afterpay API: Ensure same environment
as Afterpay SDK config + + Afterpay API-->>Proxy Server: Create Checkout Response + Note over Afterpay API,Proxy Server: Body contains a Token + + Proxy Server-->>App: Get Token Response + + App->>Afterpay SDK: Launch the checkout
with the Token + + Note over App,Afterpay API: Consumer confirms Afterpay checkout + + Afterpay SDK-->>App: Checkout result + + App->>Proxy Server: Capture request + + Proxy Server->>Afterpay API: Capture request + + Afterpay API-->>Proxy Server: Capture response + + Proxy Server-->>App: Capture Response + + App->>App: Handle response +``` + [example-server-param]: https://github.com/afterpay/sdk-example-server/blob/5781eadb25d7f5c5d872e754fdbb7214a8068008/src/routes/checkout.ts#L28 [express-checkout]: https://developers.afterpay.com/afterpay-online/reference#what-is-express-checkout diff --git a/docs/src/getting-started/configuration.md b/docs/src/getting-started/configuration.md index 25d0850b..33f457b6 100644 --- a/docs/src/getting-started/configuration.md +++ b/docs/src/getting-started/configuration.md @@ -18,7 +18,8 @@ let configuration = try Configuration( maximumAmount: response.maximumAmount.amount, currencyCode: response.maximumAmount.currency, locale: Locale(identifier: "en_US"), - environment: .sandbox + environment: .sandbox, + consumerLocale: Locale(identifier: "en_US") // optional. overrides device locale ) Afterpay.setConfiguration(configuration) From 5ccb1fd21b27d560e93c54183a7795ff1a51f3a0 Mon Sep 17 00:00:00 2001 From: Mark Mroz Date: Mon, 17 Jun 2024 12:57:51 -0400 Subject: [PATCH 77/81] Updated parchase login for CAP --- Example/Example.xcodeproj/project.pbxproj | 4 + .../Purchase/ButtonCashAppPayCheckout.swift | 153 ++++++++++++++++++ .../Example/Purchase/CartViewController.swift | 112 ++++++------- .../Purchase/CheckoutOptionsCell.swift | 28 +++- .../Example/Purchase/CheckoutPickerView.swift | 38 +++-- .../Purchase/PurchaseFlowController.swift | 59 ++----- .../Purchase/PurchaseLogicController.swift | 86 ++++------ 7 files changed, 295 insertions(+), 185 deletions(-) create mode 100644 Example/Example/Purchase/ButtonCashAppPayCheckout.swift diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index 119cf2de..8afeaf9d 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 55432838263B7EC2005512E4 /* ExampleUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55432837263B7EC2005512E4 /* ExampleUITests.swift */; }; 55719BA926363ED800634B27 /* SwiftUIWidgetExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55719BA826363ED800634B27 /* SwiftUIWidgetExample.swift */; }; 55FA7270260025DC0006EFCB /* WidgetHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55FA726F260025DC0006EFCB /* WidgetHandler.swift */; }; + 5F43369B2C209B2D00ECD0DE /* ButtonCashAppPayCheckout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F43369A2C209B2D00ECD0DE /* ButtonCashAppPayCheckout.swift */; }; 5FB958E62C0E3D7B00137468 /* CheckoutPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FB958E52C0E3D7B00137468 /* CheckoutPickerView.swift */; }; 660072B724A1B55E00E9A2BC /* TextSettingCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 660072B624A1B55E00E9A2BC /* TextSettingCell.swift */; }; 6620B5D224934FB3004162BC /* AppFlowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6620B5D124934FB3004162BC /* AppFlowController.swift */; }; @@ -101,6 +102,7 @@ 55432839263B7EC2005512E4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 55719BA826363ED800634B27 /* SwiftUIWidgetExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIWidgetExample.swift; sourceTree = ""; }; 55FA726F260025DC0006EFCB /* WidgetHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetHandler.swift; sourceTree = ""; }; + 5F43369A2C209B2D00ECD0DE /* ButtonCashAppPayCheckout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonCashAppPayCheckout.swift; sourceTree = ""; }; 5FB958E52C0E3D7B00137468 /* CheckoutPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckoutPickerView.swift; sourceTree = ""; }; 660072B624A1B55E00E9A2BC /* TextSettingCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextSettingCell.swift; sourceTree = ""; }; 6620B5D124934FB3004162BC /* AppFlowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppFlowController.swift; sourceTree = ""; }; @@ -226,6 +228,7 @@ 66BD11B324ADB7EB00039DA6 /* TitleSubtitleCell.swift */, 55FA726F260025DC0006EFCB /* WidgetHandler.swift */, 5FB958E52C0E3D7B00137468 /* CheckoutPickerView.swift */, + 5F43369A2C209B2D00ECD0DE /* ButtonCashAppPayCheckout.swift */, ); path = Purchase; sourceTree = ""; @@ -516,6 +519,7 @@ 66E6FDA824AC3BDD00ED81E8 /* PurchaseState.swift in Sources */, 66E6FDA424AC344800ED81E8 /* PurchaseLogicController.swift in Sources */, 66D685B524BD7ABF00C7287C /* SwiftUIExample.swift in Sources */, + 5F43369B2C209B2D00ECD0DE /* ButtonCashAppPayCheckout.swift in Sources */, 9490D1D624D8ED4F001E1EFC /* Repository.swift in Sources */, 66F2D8BA24A0415700F65621 /* WindowHolder.swift in Sources */, 5539D83025F068F90088BC97 /* CheckoutOptionsCell.swift in Sources */, diff --git a/Example/Example/Purchase/ButtonCashAppPayCheckout.swift b/Example/Example/Purchase/ButtonCashAppPayCheckout.swift new file mode 100644 index 00000000..584d8de3 --- /dev/null +++ b/Example/Example/Purchase/ButtonCashAppPayCheckout.swift @@ -0,0 +1,153 @@ +// +// ButtonCashAppPayCheckout.swift +// Example +// +// Created by Mark Mroz on 2024-06-17. +// Copyright © 2024 Afterpay. All rights reserved. +// + +import PayKit +import Afterpay + +enum ButtonCashAppPayError: Error { + case checkoutReason(CashAppSigningResult.CashAppSigningCancellationReason) + case checkout(error: Error) + + case customerRequestDeclined + case customerRequestMissingGrant + case customerRequest(error: Error) + + case confirmation(error: Error) +} + +protocol ButtonCashAppPayCheckoutDelegate: AnyObject { + func didFinish(result: Result) +} + +final class ButtonCashAppPayCheckout { + + // MARK: - Properties + + weak var delegate: ButtonCashAppPayCheckoutDelegate? + + // MARK: - Private Properties + + private lazy var paykit: CashAppPay? = { + guard let clientId = Afterpay.cashAppClientId else { + assertionFailure("Couldn't get cash app client id") + return nil + } + let sdk = CashAppPay( + clientID: clientId, + endpoint: Afterpay.environment == .production ? .production : .sandbox + ) + sdk.addObserver(self) + return sdk + }() + + private var checkoutPayload: CheckoutV3CashAppPayPayload! { + didSet { + createCustomerRequest( + brandID: checkoutPayload.cashAppSigningData.brandId, + amount: checkoutPayload.cashAppSigningData.amount + ) + } + } + + private var grant: CustomerRequest.Grant! { + didSet { + confirmCheckout(grant: grant) + } + } + + // MARK: - Private + + func checkoutV3(consumer: Consumer, cartTotal: Decimal) { + Afterpay.checkoutV3WithCashAppPay( + consumer: consumer, + orderTotal: OrderTotal(total: cartTotal, shipping: .zero, tax: .zero) + ) { [weak self] result in + switch result { + case .success(let data): + self?.checkoutPayload = data + case .cancelled(let reason): + self?.delegate?.didFinish(result: .failure(.checkoutReason(reason))) + case .failure(let error): + self?.delegate?.didFinish(result: .failure(.checkout(error: error))) + } + } + } + + private func createCustomerRequest(brandID: String, amount: UInt) { + paykit?.createCustomerRequest( + params: CreateCustomerRequestParams( + actions: [ + .oneTimePayment( + scopeID: brandID, + money: Money( + amount: amount, + currency: .USD + ) + ), + ], + redirectURL: URL(string: "aftersnack://callback")!, + referenceID: nil, + metadata: nil + ) + ) + } + + private func authorizeCustomerRequest(customerRequest: CustomerRequest) { + paykit?.authorizeCustomerRequest(customerRequest) + } + + private func confirmCheckout(grant: CustomerRequest.Grant) { + Afterpay.checkoutV3ConfirmForCashAppPay( + token: checkoutPayload.token, + singleUseCardToken: checkoutPayload.singleUseCardToken, + cashAppPayCustomerID: grant.customerID, + cashAppPayGrantID: grant.id, + jwt: checkoutPayload.cashAppSigningData.jwt) { [weak self] result in + switch result { + case .success(let response): + self?.delegate?.didFinish(result: .success(response)) + case .failure(let error): + self?.delegate?.didFinish(result: .failure(.confirmation(error: error))) + } + } + } +} + +// MARK: - CashAppPayObserver + +extension ButtonCashAppPayCheckout: CashAppPayObserver { + func stateDidChange(to state: CashAppPayState) { + switch state { + case .notStarted, + .creatingCustomerRequest, + .updatingCustomerRequest, + .redirecting, + .polling, + .refreshing: + break + case .readyToAuthorize(let customerRequest): + authorizeCustomerRequest(customerRequest: customerRequest) + case .declined: + delegate?.didFinish(result: .failure(.customerRequestDeclined)) + case .approved(_, let grants): + if let grant = grants.first { + self.grant = grant + } else { + delegate?.didFinish(result: .failure(.customerRequestMissingGrant)) + } + case .apiError(let apiError): + delegate?.didFinish(result: .failure(.customerRequest(error: apiError))) + case .integrationError(let integrationError): + delegate?.didFinish(result: .failure(.customerRequest(error: integrationError))) + case .networkError(let networkError): + delegate?.didFinish(result: .failure(.customerRequest(error: networkError))) + case .unexpectedError(let unexpectedError): + delegate?.didFinish(result: .failure(.customerRequest(error: unexpectedError))) + } + } +} diff --git a/Example/Example/Purchase/CartViewController.swift b/Example/Example/Purchase/CartViewController.swift index 6f316afe..825640b9 100644 --- a/Example/Example/Purchase/CartViewController.swift +++ b/Example/Example/Purchase/CartViewController.swift @@ -19,17 +19,17 @@ final class CartViewController: UIViewController, UITableViewDataSource { private let titleSubtitleCellIdentifier = String(describing: TitleSubtitleCell.self) private let eventHandler: (Event) -> Void - private var event: Event = .didTapSingleUseCardButton { - didSet { - updateViewState() - } - } - private lazy var cashButton = CashAppPayButton(size: .large) { [weak self] in self?.didTapCashAppPay() } - private let checkoutTypeTextField = UITextField() + private lazy var cashAppButtonForV3 = CashAppPayButton(size: .large) { [weak self] in + self?.eventHandler(.didTapSingleUseCardButtonWithCashAppPay) + } + + private var checkoutOption: CheckoutPickerOption = .v1 { + didSet { updateViewState() } + } enum Event { case didTapPay @@ -77,26 +77,20 @@ final class CartViewController: UIViewController, UITableViewDataSource { payButton.accessibilityIdentifier = "payNow" payButton.addTarget(self, action: #selector(didTapPay), for: .touchUpInside) - view.addSubview(payButton) - view.addSubview(checkoutTypeTextField) - cashButton.accessibilityIdentifier = "payWithCashApp" - cashButton.translatesAutoresizingMaskIntoConstraints = false - - checkoutTypeTextField.translatesAutoresizingMaskIntoConstraints = false + cashAppButtonForV3.accessibilityIdentifier = "payWithV3UsingCashApp" - view.addSubview(cashButton) + let stack = UIStackView(arrangedSubviews: [payButton, cashButton, cashAppButtonForV3]) + stack.axis = .vertical + stack.isLayoutMarginsRelativeArrangement = true + stack.directionalLayoutMargins = .init(top: 16, leading: 16, bottom: 8, trailing: 16) + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) NSLayoutConstraint.activate([ - checkoutTypeTextField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), - checkoutTypeTextField.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), - checkoutTypeTextField.bottomAnchor.constraint(equalTo: payButton.topAnchor), - payButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), - payButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), - cashButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), - cashButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), - cashButton.topAnchor.constraint(equalTo: payButton.bottomAnchor, constant: 8), - cashButton.bottomAnchor.constraint(equalTo: view.readableContentGuide.bottomAnchor, constant: -16), + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + stack.bottomAnchor.constraint(equalTo: view.readableContentGuide.bottomAnchor), ]) tableViewBottomAnchor = tableView.bottomAnchor.constraint(equalTo: payButton.topAnchor) @@ -125,23 +119,21 @@ final class CartViewController: UIViewController, UITableViewDataSource { updateViewState() } - // MARK: - Private - func updateViewState() { - switch event { - case .didTapPay, .didTapCashAppPay, .cartDidLoad, .optionsChanged: - break - case .didTapSingleUseCardButton: - checkoutTypeTextField.text = "Button Checkout V3" - case .didTapSingleUseCardButtonWithCashAppPay: - checkoutTypeTextField.text = "Button Checkout V3 with Cash App Pay" - } + cashButton.isHidden = (checkoutOption == .v1 || checkoutOption == .v3) + cashAppButtonForV3.isHidden = (checkoutOption == .v1 || checkoutOption == .v2) + eventHandler(.optionsChanged(.expressEnabled(checkoutOption == .v2))) } // MARK: Actions @objc private func didTapPay() { - eventHandler(event) + switch checkoutOption { + case .v1, .v2: + eventHandler(.didTapPay) + case .v3: + eventHandler(.didTapSingleUseCardButton) + } } @objc private func didTapCashAppPay() { @@ -149,19 +141,7 @@ final class CartViewController: UIViewController, UITableViewDataSource { } @objc private func presentPickerController() { - let selectedOption: CheckoutPickerOption? - switch event { - case .didTapSingleUseCardButton: - selectedOption = .button - case .didTapSingleUseCardButtonWithCashAppPay: - selectedOption = .buttonWithCashAppPay - default: - selectedOption = nil - } - - guard let selectedOption else { return } - - let controller = CheckoutPickerViewController(selectedOption: selectedOption, delegate: self) + let controller = CheckoutPickerViewController(selectedOption: checkoutOption, delegate: self) controller.title = "Configuration" present(UINavigationController(rootViewController: controller), animated: true) } @@ -189,7 +169,12 @@ final class CartViewController: UIViewController, UITableViewDataSource { case .total: return 1 case .options: - return 1 + switch checkoutOption { + case .v1: + return 0 + case .v2, .v3: + return 1 + } } } @@ -222,11 +207,22 @@ final class CartViewController: UIViewController, UITableViewDataSource { withIdentifier: checkoutOptionsCellIdentifier, for: indexPath) as! CheckoutOptionsCell - optionsCell.configure( - options: cart.checkoutV2Options, - expressCheckout: cart.expressCheckout - ) { option in - self.eventHandler(.optionsChanged(option)) + switch checkoutOption { + case .v1: + break + case .v2: + optionsCell.configure( + options: cart.checkoutV2Options, + expressCheckout: cart.expressCheckout + ) { [weak self] option in + self?.eventHandler(.optionsChanged(option)) + } + case .v3: + optionsCell.configureForV3( + buyNow: cart.checkoutV2Options.buyNow + ) { [weak self] option in + self?.eventHandler(.optionsChanged(option)) + } } cell = optionsCell @@ -251,13 +247,9 @@ extension CartViewController: CheckoutPickerControllerDelegate { controller.dismiss(animated: true) } - func didSelectV3Checkout(_ controller: CheckoutPickerViewController) { - event = .didTapSingleUseCardButton - controller.dismiss(animated: true) - } - - func didSelectV3CheckoutWithCashAppPay(_ controller: CheckoutPickerViewController) { - event = .didTapSingleUseCardButtonWithCashAppPay + func didSelectCheckoutOption(_ controller: CheckoutPickerViewController, option: CheckoutPickerOption) { + checkoutOption = option + tableView.reloadData() controller.dismiss(animated: true) } } diff --git a/Example/Example/Purchase/CheckoutOptionsCell.swift b/Example/Example/Purchase/CheckoutOptionsCell.swift index 0e08774d..be1f8a73 100644 --- a/Example/Example/Purchase/CheckoutOptionsCell.swift +++ b/Example/Example/Purchase/CheckoutOptionsCell.swift @@ -22,6 +22,12 @@ final class CheckoutOptionsCell: UITableViewCell { let shippingOptionRequiredLabel = UILabel() let shippingOptionRequiredSwitch = UISwitch() + private lazy var buyNowStack = UIStackView(arrangedSubviews: [buyNowLabel, buyNowSwitch]) + private lazy var pickupStack = UIStackView(arrangedSubviews: [pickupLabel, pickupSwitch]) + private lazy var shippingStack = UIStackView( + arrangedSubviews: [shippingOptionRequiredLabel, shippingOptionRequiredSwitch] + ) + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) @@ -45,11 +51,10 @@ final class CheckoutOptionsCell: UITableViewCell { let verticalStack = UIStackView( arrangedSubviews: [ -// UIStackView(arrangedSubviews: [expressLabel, expressSwitch]), -// checkoutOptionsTitle, - UIStackView(arrangedSubviews: [buyNowLabel, buyNowSwitch]), -// UIStackView(arrangedSubviews: [pickupLabel, pickupSwitch]), -// UIStackView(arrangedSubviews: [shippingOptionRequiredLabel, shippingOptionRequiredSwitch]), + checkoutOptionsTitle, + buyNowStack, + pickupStack, + shippingStack, ] ) @@ -87,10 +92,19 @@ final class CheckoutOptionsCell: UITableViewCell { case pickup case shippingOptionRequired - case expressToggled + case expressEnabled(Bool) + } + + func configureForV3(buyNow: Bool?, eventHandler: ((Event) -> Void)? = nil) { + [checkoutOptionsTitle, pickupStack, shippingStack].forEach { $0.isHidden = true } + buyNowStack.isHidden = false + buyNowSwitch.isOn = buyNow == true + self.eventHandler = eventHandler } func configure(options: CheckoutV2Options, expressCheckout: Bool, eventHandler: ((Event) -> Void)? = nil) { + [checkoutOptionsTitle, buyNowStack, pickupStack, shippingStack].forEach { $0.isHidden = false } + expressSwitch.isOn = expressCheckout configureCheckoutV2Options(enabled: expressCheckout) @@ -109,7 +123,7 @@ final class CheckoutOptionsCell: UITableViewCell { private var eventHandler: ((Event) -> Void)? @objc public func expressToggled() { - eventHandler?(.expressToggled) + eventHandler?(.expressEnabled(expressSwitch.isOn)) configureCheckoutV2Options(enabled: expressSwitch.isOn) } diff --git a/Example/Example/Purchase/CheckoutPickerView.swift b/Example/Example/Purchase/CheckoutPickerView.swift index c05e9733..01903f44 100644 --- a/Example/Example/Purchase/CheckoutPickerView.swift +++ b/Example/Example/Purchase/CheckoutPickerView.swift @@ -10,15 +10,13 @@ import SwiftUI protocol CheckoutPickerControllerDelegate: AnyObject { func didSelectCancel(_ controller: CheckoutPickerViewController) - func didSelectV3Checkout(_ controller: CheckoutPickerViewController) - func didSelectV3CheckoutWithCashAppPay(_ controller: CheckoutPickerViewController) + func didSelectCheckoutOption(_ controller: CheckoutPickerViewController, option: CheckoutPickerOption) } enum CheckoutPickerOption { - // Checkout V3 - case button - // Checkout V3 With Cash App Pay - case buttonWithCashAppPay + case v1 + case v2 + case v3 } final class CheckoutPickerViewController: UIViewController { @@ -83,13 +81,9 @@ final class CheckoutPickerViewController: UIViewController { extension CheckoutPickerViewController { private func makePickerViewModel() -> PickerViewModel { PickerViewModel( - onButtonTapped: { [weak self] in + onButtonTapped: { [weak self] option in guard let self else { return } - delegate?.didSelectV3Checkout(self) - }, - onButtonWithCashAppPayTapped: { [weak self] in - guard let self else { return } - delegate?.didSelectV3CheckoutWithCashAppPay(self) + delegate?.didSelectCheckoutOption(self, option: option) }, selectedOption: selectedOption ) @@ -98,8 +92,7 @@ extension CheckoutPickerViewController { extension CheckoutPickerViewController { struct PickerViewModel { - let onButtonTapped: () -> Void - let onButtonWithCashAppPayTapped: () -> Void + let onButtonTapped: (CheckoutPickerOption) -> Void let selectedOption: CheckoutPickerOption } @@ -110,14 +103,19 @@ extension CheckoutPickerViewController { var body: some View { VStack { CustomButton( - "Button Checkout", - isSelected: viewModel.selectedOption == .button, - action: viewModel.onButtonTapped + "V1", + isSelected: viewModel.selectedOption == .v1, + action: { viewModel.onButtonTapped(.v1) } + ) + CustomButton( + "V2 - Express", + isSelected: viewModel.selectedOption == .v2, + action: { viewModel.onButtonTapped(.v2) } ) CustomButton( - "Button Checkout With Cash App Pay", - isSelected: viewModel.selectedOption == .buttonWithCashAppPay, - action: viewModel.onButtonWithCashAppPayTapped + "V3 - Button", + isSelected: viewModel.selectedOption == .v3, + action: { viewModel.onButtonTapped(.v3) } ) Spacer() }.padding() diff --git a/Example/Example/Purchase/PurchaseFlowController.swift b/Example/Example/Purchase/PurchaseFlowController.swift index 3f4961d8..7a63e425 100644 --- a/Example/Example/Purchase/PurchaseFlowController.swift +++ b/Example/Example/Purchase/PurchaseFlowController.swift @@ -104,8 +104,8 @@ final class PurchaseFlowController: UIViewController { logicController.toggleCheckoutV2Option(\.pickup) case .optionsChanged(.shippingOptionRequired): logicController.toggleCheckoutV2Option(\.shippingOptionRequired) - case .optionsChanged(.expressToggled): - logicController.toggleExpressCheckout() + case .optionsChanged(.expressEnabled(let isEnabled)): + logicController.setExpressCheckoutEnabled(isEnabled) case .didTapSingleUseCardButton: logicController.payWithAfterpayV3() case .didTapSingleUseCardButtonWithCashAppPay: @@ -158,51 +158,6 @@ final class PurchaseFlowController: UIViewController { logicController.cancelled(with: reason) } } - case let .afterpayCheckoutV3WithCashAppPay(consumer, cart): - Afterpay.checkoutV3WithCashAppPay( - consumer: consumer, - orderTotal: OrderTotal(total: cart.total, shipping: 0, tax: 0)) { result in - switch result { - case .success(let data): - logicController.doCap(cashAppPayload: data) - case .cancelled: - let alert = AlertFactory.alert(successMessage: "Canceled") - navigationController.present(alert, animated: true, completion: nil) - case .failure(let error): - let alert = AlertFactory.alert(for: error) - navigationController.present(alert, animated: true, completion: nil) - } - } - case let .confirmAfterpayCheckoutV3WithCashAppPay( - token: token, - singleUseCardToke: cardToken, - customerID: customerID, - grantID: grantID, - jwt: jwt - ): - Afterpay.checkoutV3ConfirmForCashAppPay( - token: token, - singleUseCardToken: cardToken, - cashAppPayCustomerID: customerID, - cashAppPayGrantID: grantID, - jwt: jwt) { response in - print(response) - switch response { - case .success(let success): - let message = [ - "Card": success.paymentDetails.virtualCard?.cardNumber, - "Expires": success.cardValidUntil.map { RelativeDateTimeFormatter().string(for: $0) ?? "" }, - ].compactMapValues { $0 } - .map { (key: String, value: String) in key + ": " + value } - .joined(separator: "\n") - let alert = AlertFactory.alert(successMessage: message) - navigationController.present(alert, animated: true, completion: nil) - case .failure(let error): - let alert = AlertFactory.alert(for: error) - navigationController.present(alert, animated: true, completion: nil) - } - } - case .provideCheckoutTokenResult(let tokenResult): checkoutHandler.provideTokenResult(tokenResult: tokenResult) @@ -225,6 +180,16 @@ final class PurchaseFlowController: UIViewController { let cashReceiptViewController = CashAppGrantsViewController(amount: amount, cashTag: cashTag, grants: grants) let viewControllers = [productsViewController, cashReceiptViewController] navigationController.setViewControllers(viewControllers, animated: true) + case .showSuccessForV3WithCashAppPay(let message, let payload): + var alert = AlertFactory.alert(successMessage: message) + alert.message = [ + "Card": payload.paymentDetails.virtualCard?.cardNumber, + "Valid until": RelativeDateTimeFormatter().string(for: payload.cardValidUntil), + ].compactMapValues { $0 } + .map { (key, value) in + [key, value].joined(separator: ": ") + }.joined(separator: "\n") + navigationController.present(alert, animated: true, completion: nil) } } diff --git a/Example/Example/Purchase/PurchaseLogicController.swift b/Example/Example/Purchase/PurchaseLogicController.swift index 0aafd8d5..d3b06df1 100644 --- a/Example/Example/Purchase/PurchaseLogicController.swift +++ b/Example/Example/Purchase/PurchaseLogicController.swift @@ -30,14 +30,7 @@ final class PurchaseLogicController { case showSuccessWithMessage(String, Token) case showCashSuccess(String, String, [CustomerRequest.Grant]) - case afterpayCheckoutV3WithCashAppPay(consumer: Consumer, cart: CartDisplay) - case confirmAfterpayCheckoutV3WithCashAppPay( - token: Token, - singleUseCardToke: Token, - customerID: String, - grantID: String, - jwt: String - ) + case showSuccessForV3WithCashAppPay(String, ConfirmationV3.CashAppPayResponse) } var commandHandler: (Command) -> Void = { _ in } { @@ -82,6 +75,8 @@ final class PurchaseLogicController { } } + private let buttonCashAppPayCheckout = ButtonCashAppPayCheckout() + init( checkoutResponseProvider: @escaping CheckoutResponseProvider, configurationProvider: @escaping ConfigurationProvider, @@ -109,8 +104,8 @@ final class PurchaseLogicController { checkoutV2Options[keyPath: option] = !currentValue } - func toggleExpressCheckout() { - expressCheckout.toggle() + func setExpressCheckoutEnabled(_ isEnabled: Bool) { + expressCheckout = isEnabled } func buildCart() -> CartDisplay { @@ -144,10 +139,11 @@ final class PurchaseLogicController { } func payWithAfterpayV3WithCashAppPay() { - commandHandler(.afterpayCheckoutV3WithCashAppPay( + buttonCashAppPayCheckout.delegate = self + buttonCashAppPayCheckout.checkoutV3( consumer: Consumer(email: email), - cart: buildCart() - )) + cartTotal: buildCart().total + ) } func payWithCashApp() { @@ -163,8 +159,6 @@ final class PurchaseLogicController { static var cashData: CashAppSigningData? - private(set) var checkoutV3CashAppPayPayload: CheckoutV3CashAppPayPayload? - private lazy var paykit: CashAppPay? = { guard let clientId = Afterpay.cashAppClientId else { assertionFailure("Couldn't get cash app client id") @@ -215,28 +209,6 @@ final class PurchaseLogicController { } } - func doCap(cashAppPayload: CheckoutV3CashAppPayPayload) { - self.checkoutV3CashAppPayPayload = cashAppPayload - - paykit?.createCustomerRequest( - params: CreateCustomerRequestParams( - actions: [ - .oneTimePayment( - scopeID: cashAppPayload.cashAppSigningData.brandId, - money: Money( - amount: cashAppPayload.cashAppSigningData.amount, - currency: .USD - ) - ), - ], - channel: .IN_APP, - redirectURL: URL(string: "aftersnack://callback")!, // the cashData.redirectUri object could be used here - referenceID: nil, - metadata: nil - ) - ) - } - func retrieveCashAppToken(cashButton: CashAppPayButton? = nil) { if cashButton != nil { self.cashButton = cashButton @@ -360,29 +332,19 @@ extension PurchaseLogicController: CashAppPayObserver { switch state { case .notStarted, - .creatingCustomerRequest, + .creatingCustomerRequest, .updatingCustomerRequest, .redirecting, .polling, .apiError, .integrationError, .networkError, - .unexpectedError: + .unexpectedError, + .refreshing: return case .readyToAuthorize(let request): setCashButtonEnabled(true) cashRequest = request - case .approved(request: let request, grants: let grants) where checkoutV3CashAppPayPayload != nil: - commandHandler( - .confirmAfterpayCheckoutV3WithCashAppPay( - token: checkoutV3CashAppPayPayload!.token, - singleUseCardToke: checkoutV3CashAppPayPayload!.singleUseCardToken, - customerID: request.customerProfile!.id, - grantID: grants.first!.id, - jwt: checkoutV3CashAppPayPayload!.cashAppSigningData.jwt - ) - ) - checkoutV3CashAppPayPayload = nil case .approved(request: let request, grants: let grants): if PurchaseLogicController.cashData == nil || @@ -423,7 +385,29 @@ extension PurchaseLogicController: CashAppPayObserver { } case .declined: retrieveCashAppToken() - checkoutV3CashAppPayPayload = nil + } + } +} + +// MARK: - ButtonCashAppPayCheckoutDelegate + +extension PurchaseLogicController: ButtonCashAppPayCheckoutDelegate { + func didFinish(result: Result) { + switch result { + case .success(let data): + commandHandler(.showSuccessForV3WithCashAppPay("Success", data)) + case let .failure(.checkout(error: error)): + commandHandler(.showAlertForErrorMessage("V3 Checkout: error \(error)")) + case let .failure(.checkoutReason(reason)): + commandHandler(.showAlertForErrorMessage("V3 Checkout: reason \(reason)")) + case .failure(.customerRequestMissingGrant): + commandHandler(.showAlertForErrorMessage("Cash App Pay: missing grant")) + case .failure(.customerRequestDeclined): + commandHandler(.showAlertForErrorMessage("Cash App Pay: declined")) + case let .failure(.customerRequest(error: error)): + commandHandler(.showAlertForErrorMessage("Cash App Pay: error \(error)")) + case let .failure(.confirmation(error: error)): + commandHandler(.showAlertForErrorMessage("Confirmation: error \(error)")) } } } From a39b231087323a2698c64f8f0872c1ec6d1195e0 Mon Sep 17 00:00:00 2001 From: Mark Mroz Date: Mon, 17 Jun 2024 13:25:23 -0400 Subject: [PATCH 78/81] default to V2 for example app --- Example/Example/Purchase/CartViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Example/Example/Purchase/CartViewController.swift b/Example/Example/Purchase/CartViewController.swift index 825640b9..bb09cb7b 100644 --- a/Example/Example/Purchase/CartViewController.swift +++ b/Example/Example/Purchase/CartViewController.swift @@ -27,7 +27,7 @@ final class CartViewController: UIViewController, UITableViewDataSource { self?.eventHandler(.didTapSingleUseCardButtonWithCashAppPay) } - private var checkoutOption: CheckoutPickerOption = .v1 { + private var checkoutOption: CheckoutPickerOption = .v2 { didSet { updateViewState() } } From 25714c34a38fa1452e66914dc75e8e30c22ce5fc Mon Sep 17 00:00:00 2001 From: Mark Mroz Date: Mon, 17 Jun 2024 19:10:32 -0400 Subject: [PATCH 79/81] use 0.6.1 for Cash App Pay --- .../xcshareddata/swiftpm/Package.resolved | 7 ++++--- AfterpayTests/CheckoutV3RequestTests.swift | 2 +- Example/Example.xcodeproj/project.pbxproj | 6 +++--- .../Example/Purchase/ButtonCashAppPayCheckout.swift | 10 +++++----- Example/Example/Purchase/PurchaseLogicController.swift | 1 - 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Afterpay.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Afterpay.xcworkspace/xcshareddata/swiftpm/Package.resolved index c745b604..e7af9dbf 100644 --- a/Afterpay.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Afterpay.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,12 +1,13 @@ { + "originHash" : "fc39d5a9b00576a5954658175b5c45a5e03602868b85605ea9e92c024ae31251", "pins" : [ { "identity" : "cash-app-pay-ios-sdk", "kind" : "remoteSourceControl", "location" : "https://github.com/cashapp/cash-app-pay-ios-sdk", "state" : { - "revision" : "89c8d2d09251009f6599b1b1582016e8f25854b4", - "version" : "0.3.3" + "revision" : "8e9b575a6bb0d0671762b06ed7a0938c66680add", + "version" : "0.6.1" } }, { @@ -28,5 +29,5 @@ } } ], - "version" : 2 + "version" : 3 } diff --git a/AfterpayTests/CheckoutV3RequestTests.swift b/AfterpayTests/CheckoutV3RequestTests.swift index bbd26f3f..44aba1c9 100644 --- a/AfterpayTests/CheckoutV3RequestTests.swift +++ b/AfterpayTests/CheckoutV3RequestTests.swift @@ -40,7 +40,7 @@ final class CheckoutV3RequestTests: XCTestCase { shippingInformation: nil, billingInformation: nil ) - + let request = CheckoutV3.Request( consumer: customer, orderTotal: OrderTotal(total: 1, shipping: 1, tax: 0), diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index 8afeaf9d..b26b05b2 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 52; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -825,8 +825,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/cashapp/cash-app-pay-ios-sdk"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 0.3.0; + kind = exactVersion; + version = 0.6.1; }; }; 667F833724C815DC0077DC5F /* XCRemoteSwiftPackageReference "TrustKit" */ = { diff --git a/Example/Example/Purchase/ButtonCashAppPayCheckout.swift b/Example/Example/Purchase/ButtonCashAppPayCheckout.swift index 584d8de3..75472aca 100644 --- a/Example/Example/Purchase/ButtonCashAppPayCheckout.swift +++ b/Example/Example/Purchase/ButtonCashAppPayCheckout.swift @@ -124,11 +124,11 @@ extension ButtonCashAppPayCheckout: CashAppPayObserver { func stateDidChange(to state: CashAppPayState) { switch state { case .notStarted, - .creatingCustomerRequest, - .updatingCustomerRequest, - .redirecting, - .polling, - .refreshing: + .creatingCustomerRequest, + .updatingCustomerRequest, + .redirecting, + .polling, + .refreshing: break case .readyToAuthorize(let customerRequest): authorizeCustomerRequest(customerRequest: customerRequest) diff --git a/Example/Example/Purchase/PurchaseLogicController.swift b/Example/Example/Purchase/PurchaseLogicController.swift index d3b06df1..cf82ade2 100644 --- a/Example/Example/Purchase/PurchaseLogicController.swift +++ b/Example/Example/Purchase/PurchaseLogicController.swift @@ -11,7 +11,6 @@ import Foundation import PayKit import PayKitUI -// swiftlint:disable type_body_length final class PurchaseLogicController { enum Command { From 53d047e0cc134ec08f1d6b44c1188dc3c1a6c322 Mon Sep 17 00:00:00 2001 From: Mark Mroz Date: Mon, 17 Jun 2024 19:25:26 -0400 Subject: [PATCH 80/81] use version 2 --- Afterpay.xcworkspace/xcshareddata/swiftpm/Package.resolved | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Afterpay.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Afterpay.xcworkspace/xcshareddata/swiftpm/Package.resolved index e7af9dbf..2fb93040 100644 --- a/Afterpay.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Afterpay.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -29,5 +29,5 @@ } } ], - "version" : 3 + "version" : 2 } From 71e9b1d46f2c9900f0eb603c7234ee351c70d410 Mon Sep 17 00:00:00 2001 From: Mark Mroz Date: Mon, 17 Jun 2024 19:29:51 -0400 Subject: [PATCH 81/81] merged master --- .../xcshareddata/swiftpm/Package.resolved | 33 ------------------- 1 file changed, 33 deletions(-) delete mode 100644 Afterpay.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/Afterpay.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Afterpay.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 2fb93040..00000000 --- a/Afterpay.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,33 +0,0 @@ -{ - "originHash" : "fc39d5a9b00576a5954658175b5c45a5e03602868b85605ea9e92c024ae31251", - "pins" : [ - { - "identity" : "cash-app-pay-ios-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/cashapp/cash-app-pay-ios-sdk", - "state" : { - "revision" : "8e9b575a6bb0d0671762b06ed7a0938c66680add", - "version" : "0.6.1" - } - }, - { - "identity" : "swiftgenplugin", - "kind" : "remoteSourceControl", - "location" : "https://github.com/SwiftGen/SwiftGenPlugin.git", - "state" : { - "revision" : "879b85a470cacd70c19e22eb7e11a3aed66f4068", - "version" : "6.6.2" - } - }, - { - "identity" : "trustkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/datatheorem/TrustKit", - "state" : { - "revision" : "65d573e0e2687ea91ab0b1be377f9dd3eb1c2785", - "version" : "2.0.1" - } - } - ], - "version" : 2 -}