Skip to content

Commit

Permalink
Release 0.4.0
Browse files Browse the repository at this point in the history
  • Loading branch information
mmroz committed Jul 4, 2023
1 parent 34563b1 commit c68254b
Show file tree
Hide file tree
Showing 14 changed files with 292 additions and 16 deletions.
2 changes: 1 addition & 1 deletion CashAppPayKit.podspec
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'CashAppPayKit'
s.version = '0.3.3'
s.version = '0.4.0'
s.summary = 'PayKit iOS SDK'
s.homepage = 'https://github.com/cashapp/cash-app-pay-ios-sdk'
s.license = 'Apache License, Version 2.0'
Expand Down
2 changes: 1 addition & 1 deletion CashAppPayKitUI.podspec
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'CashAppPayKitUI'
s.version = "0.3.3"
s.version = "0.4.0"
s.summary = 'UI components for the PayKit iOS SDK'
s.homepage = 'https://github.com/cashapp/cash-app-pay-ios-sdk'
s.license = 'Apache License, Version 2.0'
Expand Down
2 changes: 2 additions & 0 deletions Demo/PayKitDemo/PayKitViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,8 @@ extension PayKitViewController: CashAppPayObserver {
case .approved(let customerRequest, let grants):
pendingRequest = customerRequest
statusTextView.text = "✅ APPROVED! ✅ \n \(grants)"
case .refreshing:
statusTextView.text = "Expired AuthFlowTriggers. Refreshing..."
case .apiError(let apiError):
statusTextView.text = "🚨🚨🚨🚨🚨\n API ERROR \n🚨🚨🚨🚨🚨 \n\n\(apiError)"
case .integrationError(let integrationError):
Expand Down
12 changes: 12 additions & 0 deletions RELEASE-NOTES.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
## PayKit 0.4.0 Release Notes

Pay Kit 0.4.0 supports iOS and requires Xcode 9 or later. The minimum supported Base SDK is 11.0.

Pay Kit 0.4.0 includes the following new features and enhancements.

- **Adds `refreshing` to `CashAppPayState`**

When calling `authorizeCustomerRequest()` for a CustomerRequest with expired `AuthFlowTriggers` the state machine refreshes the CustomerRequest before redirecting.

This is a breaking change and clients updating from an older version should show a loading state here.

## PayKit 0.3.3 Release Notes

Pay Kit 0.3.3 supports iOS and requires Xcode 9 or later. The minimum supported Base SDK is 11.0.
Expand Down
13 changes: 11 additions & 2 deletions Sources/PayKit/CashAppPay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import UIKit

public class CashAppPay {

public static let version = "0.3.2"
public static let version = "0.4.0"

public static let RedirectNotification: Notification.Name = Notification.Name("CashAppPayRedirect")

Expand Down Expand Up @@ -103,8 +103,14 @@ public class CashAppPay {
switch request.status {
case .DECLINED:
stateMachine.state = .integrationError(.terminalStateError)
case .PENDING, .PROCESSING, .APPROVED:
case .APPROVED:
stateMachine.state = .redirecting(request)
case .PENDING, .PROCESSING:
if request.authFlowTriggers?.isExpired() == false {
stateMachine.state = .redirecting(request)
} else {
stateMachine.state = .refreshing(request)
}
}
}
}
Expand All @@ -126,6 +132,9 @@ public enum CashAppPayState: Equatable {
case declined(CustomerRequest)
/// CustomerRequest was approved. Update UI to show payment info or $cashtag.
case approved(request: CustomerRequest, grants: [CustomerRequest.Grant])
/// CustomerRequest is being refreshed as a result of the AuthFlowTriggers expiring.
/// Show loading indicator if desired.
case refreshing(CustomerRequest)
/// An error with the Cash App Pay API that can manifest at runtime.
/// If an `APIError` is received, the integration is degraded and Cash App Pay functionality
/// should be temporarily removed from the app's UI.
Expand Down
26 changes: 26 additions & 0 deletions Sources/PayKit/CustomerRequest+Extensions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
//
// File.swift
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

import Foundation

extension CustomerRequest.AuthFlowTriggers {
// Jitter to account for the requests that are about to expire
private static let expiryJitter: TimeInterval = 10

func isExpired(on date: Date = Date()) -> Bool {
refreshesAt <= date.addingTimeInterval(CustomerRequest.AuthFlowTriggers.expiryJitter)
}
}
4 changes: 3 additions & 1 deletion Sources/PayKit/NetworkManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,19 +89,21 @@ class NetworkManager {

func retrieveCustomerRequest(
id: String,
retryPolicy: RetryPolicy? = .exponential(maximumNumberOfAttempts: 5),
completionHandler: @escaping (Result<CustomerRequest, Error>) -> Void
) {
let url = baseURL.appendingPathComponent(id)
var request = URLRequest(url: url, cachePolicy: .useProtocolCachePolicy, timeoutInterval: 5.0)
request.httpMethod = "GET"
request.allHTTPHeaderFields = requestHeaders
performRequest(request, completionHandler: completionHandler)
performRequest(request, retryPolicy: retryPolicy, completionHandler: completionHandler)
}

// MARK: - Internal methods

func performRequest(
_ request: URLRequest,
retryPolicy: RetryPolicy? = .exponential(maximumNumberOfAttempts: 5),
completionHandler: @escaping (Result<CustomerRequest, Error>) -> Void
) {
restService.execute(
Expand Down
5 changes: 5 additions & 0 deletions Sources/PayKit/Services/Analytics/AnalyticsEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ final class CustomerRequestEvent: AnalyticsEvent {
private enum PropertyKey: String {
enum Action: String {
case create, update, readyToAuthorize = "ready_to_authorize", redirect, polling, declined, approved
case refreshing
case apiError = "api_error", integrationError = "integration_error"
case unexpectedError = "unexpected_error", networkError = "network_error"
}
Expand Down Expand Up @@ -207,6 +208,10 @@ final class CustomerRequestEvent: AnalyticsEvent {
.init(action: .approved, request: request, grants: grants)
}

static func refreshing(request: CustomerRequest) -> CustomerRequestEvent {
.init(action: .refreshing, request: request)
}

static func error(_ error: APIError) -> CustomerRequestEvent {
.init(
action: .apiError,
Expand Down
13 changes: 13 additions & 0 deletions Sources/PayKit/StateMachine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,19 @@ class StateMachine {
analyticsService.track(CustomerRequestEvent.declined(request: customerRequest))
case .approved(let customerRequest, let grants):
analyticsService.track(CustomerRequestEvent.approved(request: customerRequest, grants: grants))
case .refreshing(let customerRequest):
analyticsService.track(CustomerRequestEvent.refreshing(request: customerRequest))
networkManager.retrieveCustomerRequest(
id: customerRequest.id,
retryPolicy: .exponential(delay: 3, maximumNumberOfAttempts: 3)
) { [weak self] result in
switch result {
case .success(let refreshedCustomerRequest):
self?.state = .redirecting(refreshedCustomerRequest)
case .failure(let error):
self?.setErrorState(error)
}
}
case .apiError(let error):
analyticsService.track(CustomerRequestEvent.error(error))
case .integrationError(let error):
Expand Down
62 changes: 54 additions & 8 deletions Tests/PayKitTests/CashAppPayTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class PayKitTests: XCTestCase {
func test_retrieve_customer_request_calls_network_manager() {
let expectation = expectation(description: "Called Retrieve Customer Request")

let networkManager = MockNetworkManager(retrieveCustomerRequest: { id, _ in
let networkManager = MockNetworkManager(retrieveCustomerRequest: { id, _, _ in
self.XCTAssertEqual(id, "ID")
expectation.fulfill()
})
Expand All @@ -53,7 +53,8 @@ class PayKitTests: XCTestCase {
let observer = TestObserver { state in
switch state {
case .notStarted, .creatingCustomerRequest, .redirecting,
.readyToAuthorize, .polling, .declined, .approved, .apiError, .networkError, .unexpectedError:
.readyToAuthorize, .polling, .declined, .approved, .refreshing,
.apiError, .networkError, .unexpectedError:
break
case .updatingCustomerRequest:
XCTFail("Do not update authorized Request")
Expand All @@ -78,7 +79,8 @@ class PayKitTests: XCTestCase {
let observer = TestObserver { state in
switch state {
case .notStarted, .creatingCustomerRequest, .redirecting,
.readyToAuthorize, .polling, .declined, .approved, .apiError, .networkError, .unexpectedError:
.readyToAuthorize, .polling, .declined, .approved, .refreshing,
.apiError, .networkError, .unexpectedError:
break
case .updatingCustomerRequest:
XCTFail("Do not update authorized Request")
Expand All @@ -103,7 +105,8 @@ class PayKitTests: XCTestCase {
let observer = TestObserver { state in
switch state {
case .notStarted, .creatingCustomerRequest, .redirecting,
.readyToAuthorize, .polling, .declined, .approved, .apiError, .networkError, .unexpectedError:
.readyToAuthorize, .polling, .declined, .approved, .refreshing,
.apiError, .networkError, .unexpectedError:
break
case .updatingCustomerRequest:
stateExpectation.fulfill()
Expand All @@ -124,7 +127,8 @@ class PayKitTests: XCTestCase {
let observer = TestObserver { state in
switch state {
case .notStarted, .creatingCustomerRequest, .redirecting,
.readyToAuthorize, .polling, .declined, .approved, .apiError, .networkError, .unexpectedError:
.readyToAuthorize, .polling, .declined, .approved, .refreshing,
.apiError, .networkError, .unexpectedError:
break
case .updatingCustomerRequest:
stateExpectation.fulfill()
Expand All @@ -145,7 +149,8 @@ class PayKitTests: XCTestCase {
let observer = TestObserver { state in
switch state {
case .notStarted, .creatingCustomerRequest, .updatingCustomerRequest,
.readyToAuthorize, .polling, .declined, .approved, .apiError, .networkError, .unexpectedError:
.readyToAuthorize, .polling, .declined, .approved, .refreshing,
.apiError, .networkError, .unexpectedError:
break
case .redirecting:
XCTFail("Do not redirect authorized Request")
Expand All @@ -167,7 +172,8 @@ class PayKitTests: XCTestCase {
let observer = TestObserver { state in
switch state {
case .notStarted, .creatingCustomerRequest, .updatingCustomerRequest,
.readyToAuthorize, .polling, .declined, .approved, .apiError, .networkError, .unexpectedError:
.readyToAuthorize, .polling, .declined, .approved, .refreshing,
.apiError, .networkError, .unexpectedError:
break
case .redirecting:
stateExpectation.fulfill()
Expand All @@ -176,7 +182,47 @@ class PayKitTests: XCTestCase {
}
}
payKit.addObserver(observer)
payKit.authorizeCustomerRequest(TestValues.fullyPopulatedPendingRequest)
payKit.authorizeCustomerRequest(TestValues.validAuthFlowTriggerCustomerRequest)
waitForExpectations(timeout: 0.5)
}

func test_authorizing_approved_customer_request_redirects() {
func test_authorizing_pending_customer_request_triggers_redirecting() throws {
let stateExpectation = expectation(description: "Redirecting")
let observer = TestObserver { state in
switch state {
case .notStarted, .creatingCustomerRequest, .updatingCustomerRequest,
.readyToAuthorize, .polling, .declined, .approved, .refreshing,
.apiError, .networkError, .unexpectedError:
break
case .redirecting:
stateExpectation.fulfill()
case .integrationError:
XCTFail("Do not redirect authorized Request")
}
}
payKit.addObserver(observer)
payKit.authorizeCustomerRequest(TestValues.fullyPopulatedApprovedRequest)
waitForExpectations(timeout: 0.5)
}
}

func test_authorizeCustomerRequest_withExpiredAuthFlowTriggers_refreshes() throws {
let stateExpectation = expectation(description: "Refreshing")
let observer = TestObserver { state in
switch state {
case .notStarted, .creatingCustomerRequest, .updatingCustomerRequest,
.readyToAuthorize, .polling, .declined, .approved, .redirecting,
.apiError, .networkError, .unexpectedError:
break
case .refreshing:
stateExpectation.fulfill()
case .integrationError:
XCTFail("Do not redirect authorized Request")
}
}
payKit.addObserver(observer)
payKit.authorizeCustomerRequest(TestValues.customerRequest)
waitForExpectations(timeout: 0.5)
}
}
Expand Down
66 changes: 66 additions & 0 deletions Tests/PayKitTests/CustomerRequest+ExtensionsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
//
// File.swift
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

@testable import PayKit
import XCTest

final class CustomerRequest_ExtensionsTests: XCTestCase {
private var now: Date!
private var url: URL!

override func setUpWithError() throws {
try super.setUpWithError()
now = Date()
url = try XCTUnwrap(URL(string: "https://block.xyz"))
}

override func tearDown() {
now = nil
url = nil
super.tearDown()
}

func test_expired() throws {
let authFlowTrigger = CustomerRequest.AuthFlowTriggers(
qrCodeImageURL: url,
qrCodeSVGURL: url,
mobileURL: url,
refreshesAt: Date(timeInterval: -30, since: now)
)

XCTAssertTrue(authFlowTrigger.isExpired(on: now))
}

func test_notExpired() throws {
let authFlowTrigger = CustomerRequest.AuthFlowTriggers(
qrCodeImageURL: url,
qrCodeSVGURL: url,
mobileURL: url,
refreshesAt: Date(timeInterval: 30, since: now)
)
XCTAssertFalse(authFlowTrigger.isExpired(on: now))
}

func test_expiredBecauseOfJitter() {
let authFlowTrigger = CustomerRequest.AuthFlowTriggers(
qrCodeImageURL: url,
qrCodeSVGURL: url,
mobileURL: url,
refreshesAt: Date(timeInterval: 10, since: now)
)
XCTAssertTrue(authFlowTrigger.isExpired(on: now))
}
}
42 changes: 42 additions & 0 deletions Tests/PayKitTests/Resources/XCTestCase+Fixtures.swift
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,48 @@ enum TestValues {
)
}

static var validAuthFlowTriggerCustomerRequest: CustomerRequest {
CustomerRequest(
id: "GRR_mg3saamyqdm29jj9pqjqkedm",
status: .PROCESSING,
actions: [PaymentAction.onFilePayment(
scopeID: "BRAND_9kx6p0mkuo97jnl025q9ni94t",
accountReferenceID: "account4"
),
],
authFlowTriggers: CustomerRequest.AuthFlowTriggers(
qrCodeImageURL: URL(string: "https://sandbox.api.cash.app/qr/sandbox/v1/GRR_mg3saamyqdm29jj9pqjqkedm-t61pfg?rounded=0&format=png")!,
qrCodeSVGURL: URL(string: "https://sandbox.api.cash.app/qr/sandbox/v1/GRR_mg3saamyqdm29jj9pqjqkedm-t61pfg?rounded=0&format=svg")!,
mobileURL: URL(string: "https://sandbox.api.cash.app/customer-request/v1/requests/GRR_mg3saamyqdm29jj9pqjqkedm/interstitial")!,
refreshesAt: .distantFuture
),
redirectURL: URL(string: "paykitdemo://callback")!,
createdAt: dateFormatter.date(from: "2022-10-20T20:16:18.051Z")!,
updatedAt: dateFormatter.date(from: "2022-10-20T21:04:10.701Z")!,
expiresAt: dateFormatter.date(from: "2027-10-19T21:03:43.159Z")!,
origin: CustomerRequest.Origin(
type: .DIRECT,
id: nil
),
channel: .IN_APP,
grants: approvedRequestGrants, // grants for approved request
referenceID: "refer_to_me",
requesterProfile: CustomerRequest.RequesterProfile(
name: "SDK Hacking: The Brand",
logoURL: URL(string: "https://franklin-assets.s3.amazonaws.com/merchants/assets/v3/generic/m_category_shopping.png")!
),
customerProfile: CustomerRequest.CustomerProfile(
id: "CST_AYVkuLw-sT3OKZ7a_nhNTC_L2ekahLgGrS-EM_QhW4OTrGMbi59X1eCclH0cjaxoLObc",
cashtag: "$CASHTAG_C_TOKEN"
),
metadata: [
"key1": "Valuation",
"key2": "ValuWorld",
"key3": "Valuminous",
]
)
}

// Grants

static var approvedRequestGrants: [CustomerRequest.Grant] {
Expand Down
Loading

0 comments on commit c68254b

Please sign in to comment.