├── Sources ├── PayKitUI │ ├── Shared │ │ ├── Assets │ │ │ └── Resources │ │ │ │ ├── en.lproj │ │ │ │ └── Localizable.strings │ │ │ │ ├── Colors.xcassets │ │ │ │ ├── Contents.json │ │ │ │ ├── PolyChrome.colorset │ │ │ │ │ └── Contents.json │ │ │ │ ├── TextPrimary.colorset │ │ │ │ │ └── Contents.json │ │ │ │ ├── TextSecondary.colorset │ │ │ │ │ └── Contents.json │ │ │ │ ├── TextTernary.colorset │ │ │ │ │ └── Contents.json │ │ │ │ ├── ButtonTextPrimary.colorset │ │ │ │ │ └── Contents.json │ │ │ │ ├── SurfacePrimary.colorset │ │ │ │ │ └── Contents.json │ │ │ │ ├── SurfaceSecondary.colorset │ │ │ │ │ └── Contents.json │ │ │ │ └── SurfacePrimaryDisabled.colorset │ │ │ │ │ └── Contents.json │ │ │ │ └── Images.xcassets │ │ │ │ ├── Contents.json │ │ │ │ ├── MonoChromeLogo.imageset │ │ │ │ └── Contents.json │ │ │ │ ├── PolyChromeLogo.imageset │ │ │ │ └── Contents.json │ │ │ │ └── MonoChromeLogoReverse.imageset │ │ │ │ └── Contents.json │ │ ├── Style │ │ │ └── SizingCategory.swift │ │ └── Generated │ │ │ ├── Strings.generated.swift │ │ │ └── Assets.swift │ ├── PrivacyInfo.xcprivacy │ ├── swiftgen.yml │ ├── UIKit │ │ ├── Extensions │ │ │ └── UIColor+SwiftUI.swift │ │ ├── CashAppPayButton.swift │ │ └── CashAppPaymentMethod.swift │ └── SwiftUI │ │ └── CashAppPaymentMethodView.swift └── PayKit │ ├── PrivacyInfo.xcprivacy │ ├── Services │ ├── Logger │ │ └── Log.swift │ ├── Networking │ │ ├── HTTPRequest.swift │ │ ├── RetryPolicy.swift │ │ ├── RESTService.swift │ │ └── UserAgent.swift │ └── Analytics │ │ ├── CashAppPayEndpoint+Analytics.swift │ │ ├── JSONEncoder+Analytics.swift │ │ ├── AnalyticsDataSource.swift │ │ ├── AnalyticsClient.swift │ │ └── AnalyticsService.swift │ ├── CustomerRequest+Extensions.swift │ ├── JSONCoder+PayKit.swift │ ├── Errors.swift │ ├── ObjcWrapper │ └── ObjCWrapper.swift │ └── CashAppPay.swift ├── Gemfile ├── Tests ├── PayKitTests │ ├── Resources │ │ └── Fixtures │ │ │ ├── Errors │ │ │ ├── emptyErrorArray.json │ │ │ ├── invalidJSON.json │ │ │ ├── brandNotFound.json │ │ │ ├── internalServerError.json │ │ │ ├── idempotencyKeyReused.json │ │ │ └── unauthorized.json │ │ │ ├── updateRequestParams-clearAllButActions.json │ │ │ ├── updateRequestParams-clearAmountFromOneTime.json │ │ │ ├── createRequestParams-fullyPopulated.json │ │ │ ├── pendingRequest-fullyPopulated.json │ │ │ └── approvedRequest-fullyPopulated.json │ ├── DateFormatterTests.swift │ ├── Helpers │ │ ├── MockNotificationCenter.swift │ │ ├── MockRestService.swift │ │ └── MockURLProtocol.swift │ ├── UnexpectedErrorTests.swift │ ├── CashAppPay+EndpointTest.swift │ ├── UserAgentTests.swift │ ├── APIErrorTests.swift │ ├── RetryPolicyTests.swift │ ├── CustomerRequest+ExtensionsTests.swift │ ├── AnalyticsDataSourceTests.swift │ ├── AnalyticsClientTests.swift │ ├── UpdateCustomerRequestParamsTests.swift │ ├── CreateCustomerRequestParamsTests.swift │ ├── CustomerRequestTests.swift │ ├── IntegrationErrorTests.swift │ ├── ResilientRestServiceTests.swift │ ├── AccessModifierTests.swift │ ├── Errors+ObjCTests.swift │ └── LoggableTests.swift └── PayKitUITests │ ├── __Snapshots__ │ ├── CashAppPayButtonSnapshotTests │ │ ├── test_dark_mode.1.png │ │ ├── test_large_button.1.png │ │ ├── test_small_button.1.png │ │ ├── test_button_expands.1.png │ │ ├── test_button_expands.2.png │ │ ├── test_button_disabled.1.png │ │ └── test_button_disabled.2.png │ ├── CashAppPayButtonViewSnapshotTests │ │ ├── test_dark_mode.1.png │ │ ├── test_large_button.1.png │ │ ├── test_minimum_size.1.png │ │ ├── test_minimum_size.2.png │ │ ├── test_small_button.1.png │ │ ├── test_button_disabled.1.png │ │ └── test_button_disabled.2.png │ ├── CashAppPaymentMethodSnapshotTests │ │ ├── test_dark_mode.1.png │ │ ├── test_expands_full_width.1.png │ │ ├── test_text_custom_font.1.png │ │ ├── test_large_payment_method.1.png │ │ └── test_small_payment_method.1.png │ └── CashAppPaymentMethodViewSnapshotTests │ │ ├── test_dark_mode.1.png │ │ ├── test_large_button.1.png │ │ ├── test_minimum_size.1.png │ │ ├── test_small_button.1.png │ │ └── test_text_custom_font.1.png │ ├── CashAppPaymentMethodViewSnapshotTests.swift │ ├── CashAppPayButtonSnapshotTests.swift │ ├── CashAppPayButtonViewSnapshotTests.swift │ ├── CashAppPaymentMethodSnapshotTests.swift │ └── BaseSnapshotTestCase.swift ├── Demo ├── PayKitDemo │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ └── AppIcon.appiconset │ │ │ └── Contents.json │ ├── Info.plist │ ├── SceneDelegate.swift │ ├── TabBarController.swift │ ├── Base.lproj │ │ ├── LaunchScreen.storyboard │ │ └── Main.storyboard │ ├── AppDelegate.swift │ └── ComponentsViewController.swift └── PayKitDemo.xcodeproj │ ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ └── xcschemes │ └── PayKitDemo.xcscheme ├── THIRD_PARTY_LICENSE.txt ├── PayKit.xcworkspace ├── contents.xcworkspacedata └── xcshareddata │ ├── IDEWorkspaceChecks.plist │ └── IDETemplateMacros.plist ├── .github ├── CODEOWNERS ├── workflows │ ├── codelint.yml │ └── ci.yml └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── CashAppPayKit.podspec ├── CashAppPayKitUI.podspec ├── Contributing.md ├── .swiftlint.yml ├── Package.swift ├── Gemfile.lock └── RELEASE-NOTES.md /Sources/PayKitUI/Shared/Assets/Resources/en.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' do 2 | gem 'cocoapods', '1.11.3' 3 | end 4 | -------------------------------------------------------------------------------- /Tests/PayKitTests/Resources/Fixtures/Errors/emptyErrorArray.json: -------------------------------------------------------------------------------- 1 | { 2 | "errors": 3 | [ 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /Demo/PayKitDemo/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sources/PayKitUI/Shared/Assets/Resources/Colors.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sources/PayKitUI/Shared/Assets/Resources/Images.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /THIRD_PARTY_LICENSE.txt: -------------------------------------------------------------------------------- 1 | 3RD PARTY LICENSES 2 | 3 | IOS is a trademark or registered trademark of Cisco in the U.S. and other countries and is used under license. 4 | -------------------------------------------------------------------------------- /Tests/PayKitTests/Resources/Fixtures/Errors/invalidJSON.json: -------------------------------------------------------------------------------- 1 | { 2 | "textbook": "Invalid JSON Error", 3 | "seems_like" : [ 4 | "someone", 5 | "should have", 6 | "caught this" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /Demo/PayKitDemo.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /PayKit.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Tests/PayKitUITests/__Snapshots__/CashAppPayButtonSnapshotTests/test_dark_mode.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cashapp/cash-app-pay-ios-sdk/HEAD/Tests/PayKitUITests/__Snapshots__/CashAppPayButtonSnapshotTests/test_dark_mode.1.png -------------------------------------------------------------------------------- /Tests/PayKitUITests/__Snapshots__/CashAppPayButtonSnapshotTests/test_large_button.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cashapp/cash-app-pay-ios-sdk/HEAD/Tests/PayKitUITests/__Snapshots__/CashAppPayButtonSnapshotTests/test_large_button.1.png -------------------------------------------------------------------------------- /Tests/PayKitUITests/__Snapshots__/CashAppPayButtonSnapshotTests/test_small_button.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cashapp/cash-app-pay-ios-sdk/HEAD/Tests/PayKitUITests/__Snapshots__/CashAppPayButtonSnapshotTests/test_small_button.1.png -------------------------------------------------------------------------------- /Tests/PayKitUITests/__Snapshots__/CashAppPayButtonSnapshotTests/test_button_expands.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cashapp/cash-app-pay-ios-sdk/HEAD/Tests/PayKitUITests/__Snapshots__/CashAppPayButtonSnapshotTests/test_button_expands.1.png -------------------------------------------------------------------------------- /Tests/PayKitUITests/__Snapshots__/CashAppPayButtonSnapshotTests/test_button_expands.2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cashapp/cash-app-pay-ios-sdk/HEAD/Tests/PayKitUITests/__Snapshots__/CashAppPayButtonSnapshotTests/test_button_expands.2.png -------------------------------------------------------------------------------- /Tests/PayKitUITests/__Snapshots__/CashAppPayButtonViewSnapshotTests/test_dark_mode.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cashapp/cash-app-pay-ios-sdk/HEAD/Tests/PayKitUITests/__Snapshots__/CashAppPayButtonViewSnapshotTests/test_dark_mode.1.png -------------------------------------------------------------------------------- /Tests/PayKitUITests/__Snapshots__/CashAppPaymentMethodSnapshotTests/test_dark_mode.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cashapp/cash-app-pay-ios-sdk/HEAD/Tests/PayKitUITests/__Snapshots__/CashAppPaymentMethodSnapshotTests/test_dark_mode.1.png -------------------------------------------------------------------------------- /Demo/PayKitDemo/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Tests/PayKitUITests/__Snapshots__/CashAppPayButtonSnapshotTests/test_button_disabled.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cashapp/cash-app-pay-ios-sdk/HEAD/Tests/PayKitUITests/__Snapshots__/CashAppPayButtonSnapshotTests/test_button_disabled.1.png -------------------------------------------------------------------------------- /Tests/PayKitUITests/__Snapshots__/CashAppPayButtonSnapshotTests/test_button_disabled.2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cashapp/cash-app-pay-ios-sdk/HEAD/Tests/PayKitUITests/__Snapshots__/CashAppPayButtonSnapshotTests/test_button_disabled.2.png -------------------------------------------------------------------------------- /Tests/PayKitUITests/__Snapshots__/CashAppPayButtonViewSnapshotTests/test_large_button.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cashapp/cash-app-pay-ios-sdk/HEAD/Tests/PayKitUITests/__Snapshots__/CashAppPayButtonViewSnapshotTests/test_large_button.1.png -------------------------------------------------------------------------------- /Tests/PayKitUITests/__Snapshots__/CashAppPayButtonViewSnapshotTests/test_minimum_size.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cashapp/cash-app-pay-ios-sdk/HEAD/Tests/PayKitUITests/__Snapshots__/CashAppPayButtonViewSnapshotTests/test_minimum_size.1.png -------------------------------------------------------------------------------- /Tests/PayKitUITests/__Snapshots__/CashAppPayButtonViewSnapshotTests/test_minimum_size.2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cashapp/cash-app-pay-ios-sdk/HEAD/Tests/PayKitUITests/__Snapshots__/CashAppPayButtonViewSnapshotTests/test_minimum_size.2.png -------------------------------------------------------------------------------- /Tests/PayKitUITests/__Snapshots__/CashAppPayButtonViewSnapshotTests/test_small_button.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cashapp/cash-app-pay-ios-sdk/HEAD/Tests/PayKitUITests/__Snapshots__/CashAppPayButtonViewSnapshotTests/test_small_button.1.png -------------------------------------------------------------------------------- /Tests/PayKitUITests/__Snapshots__/CashAppPaymentMethodViewSnapshotTests/test_dark_mode.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cashapp/cash-app-pay-ios-sdk/HEAD/Tests/PayKitUITests/__Snapshots__/CashAppPaymentMethodViewSnapshotTests/test_dark_mode.1.png -------------------------------------------------------------------------------- /Tests/PayKitUITests/__Snapshots__/CashAppPayButtonViewSnapshotTests/test_button_disabled.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cashapp/cash-app-pay-ios-sdk/HEAD/Tests/PayKitUITests/__Snapshots__/CashAppPayButtonViewSnapshotTests/test_button_disabled.1.png -------------------------------------------------------------------------------- /Tests/PayKitUITests/__Snapshots__/CashAppPayButtonViewSnapshotTests/test_button_disabled.2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cashapp/cash-app-pay-ios-sdk/HEAD/Tests/PayKitUITests/__Snapshots__/CashAppPayButtonViewSnapshotTests/test_button_disabled.2.png -------------------------------------------------------------------------------- /Tests/PayKitUITests/__Snapshots__/CashAppPaymentMethodSnapshotTests/test_expands_full_width.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cashapp/cash-app-pay-ios-sdk/HEAD/Tests/PayKitUITests/__Snapshots__/CashAppPaymentMethodSnapshotTests/test_expands_full_width.1.png -------------------------------------------------------------------------------- /Tests/PayKitUITests/__Snapshots__/CashAppPaymentMethodSnapshotTests/test_text_custom_font.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cashapp/cash-app-pay-ios-sdk/HEAD/Tests/PayKitUITests/__Snapshots__/CashAppPaymentMethodSnapshotTests/test_text_custom_font.1.png -------------------------------------------------------------------------------- /Tests/PayKitUITests/__Snapshots__/CashAppPaymentMethodViewSnapshotTests/test_large_button.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cashapp/cash-app-pay-ios-sdk/HEAD/Tests/PayKitUITests/__Snapshots__/CashAppPaymentMethodViewSnapshotTests/test_large_button.1.png -------------------------------------------------------------------------------- /Tests/PayKitUITests/__Snapshots__/CashAppPaymentMethodViewSnapshotTests/test_minimum_size.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cashapp/cash-app-pay-ios-sdk/HEAD/Tests/PayKitUITests/__Snapshots__/CashAppPaymentMethodViewSnapshotTests/test_minimum_size.1.png -------------------------------------------------------------------------------- /Tests/PayKitUITests/__Snapshots__/CashAppPaymentMethodViewSnapshotTests/test_small_button.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cashapp/cash-app-pay-ios-sdk/HEAD/Tests/PayKitUITests/__Snapshots__/CashAppPaymentMethodViewSnapshotTests/test_small_button.1.png -------------------------------------------------------------------------------- /Tests/PayKitUITests/__Snapshots__/CashAppPaymentMethodSnapshotTests/test_large_payment_method.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cashapp/cash-app-pay-ios-sdk/HEAD/Tests/PayKitUITests/__Snapshots__/CashAppPaymentMethodSnapshotTests/test_large_payment_method.1.png -------------------------------------------------------------------------------- /Tests/PayKitUITests/__Snapshots__/CashAppPaymentMethodSnapshotTests/test_small_payment_method.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cashapp/cash-app-pay-ios-sdk/HEAD/Tests/PayKitUITests/__Snapshots__/CashAppPaymentMethodSnapshotTests/test_small_payment_method.1.png -------------------------------------------------------------------------------- /Tests/PayKitUITests/__Snapshots__/CashAppPaymentMethodViewSnapshotTests/test_text_custom_font.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cashapp/cash-app-pay-ios-sdk/HEAD/Tests/PayKitUITests/__Snapshots__/CashAppPaymentMethodViewSnapshotTests/test_text_custom_font.1.png -------------------------------------------------------------------------------- /Tests/PayKitTests/Resources/Fixtures/Errors/brandNotFound.json: -------------------------------------------------------------------------------- 1 | { 2 | "errors": 3 | [ 4 | { 5 | "category": "INVALID_REQUEST_ERROR", 6 | "code": "BRAND_NOT_FOUND", 7 | "detail": "The requested brand could not be found." 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /Tests/PayKitTests/Resources/Fixtures/Errors/internalServerError.json: -------------------------------------------------------------------------------- 1 | { 2 | "errors": 3 | [ 4 | { 5 | "category": "API_ERROR", 6 | "code": "INTERNAL_SERVER_ERROR", 7 | "detail": "An internal server error has occurred." 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /Sources/PayKitUI/PrivacyInfo.xcprivacy: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSPrivacyTracking 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /PayKit.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Demo/PayKitDemo.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Tests/PayKitTests/Resources/Fixtures/Errors/idempotencyKeyReused.json: -------------------------------------------------------------------------------- 1 | { 2 | "errors": 3 | [ 4 | { 5 | "category": "INVALID_REQUEST_ERROR", 6 | "code": "IDEMPOTENCY_KEY_REUSED", 7 | "detail": "Idempotency key already in use, request body checksum does not match" 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /Sources/PayKitUI/swiftgen.yml: -------------------------------------------------------------------------------- 1 | input_dir: Shared/Assets/Resources 2 | output_dir: Shared/Generated 3 | 4 | strings: 5 | inputs: 6 | - en.lproj/Localizable.strings 7 | outputs: 8 | - templateName: structured-swift5 9 | output: Strings.generated.swift 10 | xcassets: 11 | inputs: 12 | - Colors.xcassets 13 | - Images.xcassets 14 | outputs: 15 | templatePath: custom-xcassets-template.stencil 16 | output: Assets.swift 17 | -------------------------------------------------------------------------------- /Tests/PayKitTests/Resources/Fixtures/updateRequestParams-clearAllButActions.json: -------------------------------------------------------------------------------- 1 | { 2 | "request": { 3 | "actions": [ 4 | { 5 | "type": "ON_FILE_PAYMENT", 6 | "scope_id": "BRAND_9kx6p0mkuo97jnl025q9ni94t", 7 | "account_reference_id": null 8 | } 9 | ], 10 | "metadata": null, 11 | "reference_id": null 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Tests/PayKitTests/Resources/Fixtures/Errors/unauthorized.json: -------------------------------------------------------------------------------- 1 | { 2 | "errors": 3 | [ 4 | { 5 | "category": "AUTHENTICATION_ERROR", 6 | "code": "UNAUTHORIZED", 7 | "detail": "The request specified a client ID of 'FAKE_CLIENT_ID', but a client with that ID does not exist. Please ensure the client ID being passed is correct and contact Cash App API support if you are unable to resolve this issue." 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /Tests/PayKitTests/Resources/Fixtures/updateRequestParams-clearAmountFromOneTime.json: -------------------------------------------------------------------------------- 1 | { 2 | "request": { 3 | "actions": [ 4 | { 5 | "type": "ONE_TIME_PAYMENT", 6 | "scope_id": "BRAND_9kx6p0mkuo97jnl025q9ni94t", 7 | "amount": null, 8 | "currency": null 9 | } 10 | ], 11 | "metadata": null, 12 | "reference_id": null 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # CODEOWNERS syntax https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners#codeowners-syntax 2 | # 3 | # Code owners are automatically requested for review when someone opens a pull request that modifies code that they own. 4 | # Code owners are not automatically requested to review draft pull requests 5 | # When you mark a draft pull request as ready for review, code owners are automatically notified. 6 | 7 | * @squareup/cash-commerce-ios 8 | 9 | -------------------------------------------------------------------------------- /Sources/PayKitUI/Shared/Assets/Resources/Images.xcassets/MonoChromeLogo.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "MonoChromeDark.svg", 5 | "idiom" : "universal" 6 | }, 7 | { 8 | "appearances" : [ 9 | { 10 | "appearance" : "luminosity", 11 | "value" : "dark" 12 | } 13 | ], 14 | "filename" : "MonoChromeLight.svg", 15 | "idiom" : "universal" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/PayKitUI/Shared/Assets/Resources/Images.xcassets/PolyChromeLogo.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "PolyChromeDark.svg", 5 | "idiom" : "universal" 6 | }, 7 | { 8 | "appearances" : [ 9 | { 10 | "appearance" : "luminosity", 11 | "value" : "dark" 12 | } 13 | ], 14 | "filename" : "PolyChromeLight.svg", 15 | "idiom" : "universal" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/PayKitUI/Shared/Assets/Resources/Images.xcassets/MonoChromeLogoReverse.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "MonoChromeLight.svg", 5 | "idiom" : "universal" 6 | }, 7 | { 8 | "appearances" : [ 9 | { 10 | "appearance" : "luminosity", 11 | "value" : "dark" 12 | } 13 | ], 14 | "filename" : "MonoChromeDark.svg", 15 | "idiom" : "universal" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.github/workflows/codelint.yml: -------------------------------------------------------------------------------- 1 | name: Codelint 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | paths: 9 | - '.github/workflows/codelint.yml' 10 | - '.swiftlint.yml' 11 | - '**/*.swift' 12 | jobs: 13 | swiftlint: 14 | runs-on: ubuntu-latest 15 | container: 16 | image: ghcr.io/realm/swiftlint:0.47.1 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | with: 21 | fetch-depth: 1 22 | 23 | - name: SwiftLint 24 | run: | 25 | swiftlint --reporter github-actions-logging 26 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Standards checklist: 2 | 3 | 4 | 5 | - [ ] The PR title is descriptive. 6 | - [ ] The PR doesn't replicate another PR which is already open. 7 | - [ ] I have read the contribution guide and followed all the instructions. 8 | - [ ] The code is mine or it's from somewhere with an Apache 2.0 compatible license. 9 | - [ ] The code is efficient, to the best of my ability, and does not waste computer resources. 10 | - [ ] The code is stable and I have tested it myself, to the best of my abilities. 11 | 12 | ## Tests: 13 | 14 | - [ ] PayKit Tests 15 | - [ ] PayKitUI Tests 16 | 17 | ## Changes: 18 | 19 | - [...] 20 | 21 | ## Other comments: 22 | 23 | ... 24 | -------------------------------------------------------------------------------- /Tests/PayKitTests/Resources/Fixtures/createRequestParams-fullyPopulated.json: -------------------------------------------------------------------------------- 1 | { 2 | "request": { 3 | "actions": [ 4 | { 5 | "type": "ON_FILE_PAYMENT", 6 | "scope_id": "BRAND_9kx6p0mkuo97jnl025q9ni94t", 7 | "account_reference_id": "account4" 8 | } 9 | ], 10 | "metadata": { 11 | "key1": "Valuation", 12 | "key2": "ValuWorld", 13 | "key3": "Valuminous" 14 | }, 15 | "channel": "IN_APP", 16 | "redirect_url": "paykitdemo://callback", 17 | "reference_id": "refer_to_me" 18 | }, 19 | "idempotency_key": "e345c3fb-1caa-46fd-b0d3-aa6c7b00ab19" 20 | } 21 | -------------------------------------------------------------------------------- /Sources/PayKit/PrivacyInfo.xcprivacy: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSPrivacyCollectedDataTypes 6 | 7 | 8 | NSPrivacyCollectedDataType 9 | NSPrivacyCollectedDataTypeUserID 10 | NSPrivacyCollectedDataTypeLinked 11 | 12 | NSPrivacyCollectedDataTypeTracking 13 | 14 | NSPrivacyCollectedDataTypePurposes 15 | 16 | NSPrivacyCollectedDataTypePurposeAnalytics 17 | 18 | 19 | 20 | NSPrivacyTracking 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /Sources/PayKit/Services/Logger/Log.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Log.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import Foundation 18 | 19 | struct Log { 20 | static func write(_ message: String) { 21 | #if LOGGING 22 | print(message) 23 | #endif 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/PayKitUI/Shared/Assets/Resources/Colors.xcassets/PolyChrome.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x00", 9 | "green" : "0x00", 10 | "red" : "0x00" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0x4F", 27 | "green" : "0xD6", 28 | "red" : "0x00" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/PayKitUI/Shared/Assets/Resources/Colors.xcassets/TextPrimary.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x00", 9 | "green" : "0x00", 10 | "red" : "0x00" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0xFF", 27 | "green" : "0xFF", 28 | "red" : "0xFF" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/PayKitUI/Shared/Assets/Resources/Colors.xcassets/TextSecondary.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x66", 9 | "green" : "0x66", 10 | "red" : "0x66" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "102", 27 | "green" : "102", 28 | "red" : "102" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/PayKitUI/Shared/Assets/Resources/Colors.xcassets/TextTernary.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x99", 9 | "green" : "0x99", 10 | "red" : "0x99" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0xFA", 27 | "green" : "0xFA", 28 | "red" : "0xFA" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/PayKitUI/Shared/Assets/Resources/Colors.xcassets/ButtonTextPrimary.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xFF", 9 | "green" : "0xFF", 10 | "red" : "0xFF" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0x00", 27 | "green" : "0x00", 28 | "red" : "0x00" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/PayKitUI/Shared/Assets/Resources/Colors.xcassets/SurfacePrimary.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x00", 9 | "green" : "0x00", 10 | "red" : "0x00" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0xFF", 27 | "green" : "0xFF", 28 | "red" : "0xFF" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/PayKitUI/Shared/Assets/Resources/Colors.xcassets/SurfaceSecondary.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xFF", 9 | "green" : "0xFF", 10 | "red" : "0xFF" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0x00", 27 | "green" : "0x00", 28 | "red" : "0x00" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/PayKitUI/Shared/Style/SizingCategory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SizingCategory.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import Foundation 18 | 19 | /// The styles defined for particular sizing 20 | public enum SizingCategory { 21 | // Preferred for small screen sizes 22 | case small 23 | // Preferred for large screen sizes 24 | case large 25 | } 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | .DS_Store 3 | build/ 4 | .build/ 5 | .swiftpm/ 6 | *.pbxuser 7 | !default.pbxuser 8 | *.mode1v3 9 | !default.mode1v3 10 | *.mode2v3 11 | !default.mode2v3 12 | *.perspectivev3 13 | !default.perspectivev3 14 | xcuserdata 15 | *.moved-aside 16 | DerivedData 17 | .idea/ 18 | *.gcno 19 | *.gcda 20 | *.iml 21 | Package.resolved 22 | 23 | # Gradle 24 | .gradle 25 | local.properties 26 | 27 | Code/SquareShared/build 28 | 29 | # Cocoapods 30 | Pods 31 | Podfile.local 32 | 33 | !Pods/Pods.xcodeproj/xcshareddata/xcschemes/CommonCashUI-Unit-SnapshotTests.xcscheme 34 | 35 | # cocoapods-dependencies 36 | Podfile.gv 37 | Podfile.png 38 | 39 | # cocoapods-generate 40 | gen 41 | 42 | # Fastlane 43 | Fastlane/.env 44 | Fastlane/Preview.html 45 | Fastlane/README.md 46 | Fastlane/report.xml 47 | Fastlane/bin 48 | 49 | # ios-builder 50 | .bin 51 | .bundle 52 | 53 | # Bazel 54 | bazel-* 55 | user.bazelrc 56 | Code/*/BUILD.bazel 57 | Pods/*/BUILD.bazel -------------------------------------------------------------------------------- /PayKit.xcworkspace/xcshareddata/IDETemplateMacros.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | FILEHEADER 6 | 7 | // ___FILENAME___ 8 | // 9 | // Licensed under the Apache License, Version 2.0 (the "License"); 10 | // you may not use this file except in compliance with the License. 11 | // You may obtain a copy of the License at 12 | // 13 | // http://www.apache.org/licenses/LICENSE-2.0 14 | // 15 | // Unless required by applicable law or agreed to in writing, software 16 | // distributed under the License is distributed on an "AS IS" BASIS, 17 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 | // See the License for the specific language governing permissions and 19 | // limitations under the License. 20 | // 21 | 22 | 23 | -------------------------------------------------------------------------------- /Sources/PayKitUI/Shared/Assets/Resources/Colors.xcassets/SurfacePrimaryDisabled.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x99", 9 | "green" : "0x99", 10 | "red" : "0x99" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0x66", 27 | "green" : "0x66", 28 | "red" : "0x66" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/PayKitUI/UIKit/Extensions/UIColor+SwiftUI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIColor+SwiftUI.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import SwiftUI 18 | 19 | @available(iOS 13.0, *) 20 | extension UIColor { 21 | var swiftUIColor: Color? { 22 | guard let rgb = cgColor.components, rgb.count >= 3 else { 23 | return nil 24 | } 25 | return Color(red: rgb[0], green: rgb[1], blue: rgb[2]) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/PayKit/CustomerRequest+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import Foundation 18 | 19 | extension CustomerRequest.AuthFlowTriggers { 20 | // Jitter to account for the requests that are about to expire 21 | private static let expiryJitter: TimeInterval = 10 22 | 23 | func isExpired(on date: Date = Date()) -> Bool { 24 | refreshesAt <= date.addingTimeInterval(CustomerRequest.AuthFlowTriggers.expiryJitter) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /CashAppPayKit.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = 'CashAppPayKit' 3 | s.version = '0.6.3' 4 | s.summary = 'PayKit iOS SDK' 5 | s.homepage = 'https://github.com/cashapp/cash-app-pay-ios-sdk' 6 | s.license = 'Apache License, Version 2.0' 7 | s.author = 'Cash App' 8 | s.source = { :git => 'https://github.com/cashapp/cash-app-pay-ios-sdk.git', :tag => "v#{s.version}" } 9 | s.module_name = 'PayKit' 10 | 11 | s.swift_version = ['5.0'] 12 | s.ios.deployment_target = '12.0' 13 | 14 | s.source_files = 'Sources/PayKit/**/*.swift' 15 | 16 | s.test_spec 'Tests' do |test_spec| 17 | test_spec.source_files = 'Tests/PayKitTests/**/*.swift' 18 | test_spec.framework = 'XCTest' 19 | test_spec.library = 'swiftos' 20 | test_spec.requires_app_host = true 21 | test_spec.resources = 'Tests/PayKitTests/Resources/Fixtures/**/*.json' 22 | 23 | test_spec.resource_bundles = { 24 | "PayKitTestFixtures" => [ 25 | "Tests/PayKitTests/Resources/Fixtures/**/*.json" 26 | ] 27 | } 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /CashAppPayKitUI.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = 'CashAppPayKitUI' 3 | s.version = "0.6.3" 4 | s.summary = 'UI components for the PayKit iOS SDK' 5 | s.homepage = 'https://github.com/cashapp/cash-app-pay-ios-sdk' 6 | s.license = 'Apache License, Version 2.0' 7 | s.author = 'Cash App' 8 | s.source = { :git => 'https://github.com/cashapp/cash-app-pay-ios-sdk.git', :tag => "v#{s.version}" } 9 | s.module_name = 'PayKitUI' 10 | 11 | ios_deployment_target = '12.0' 12 | 13 | s.swift_version = ['5.0'] 14 | s.ios.deployment_target = ios_deployment_target 15 | 16 | s.resources = "Sources/PayKitUI/Shared/Assets/Resources/**/**.xcassets" 17 | s.source_files = 'Sources/PayKitUI/**/**.swift' 18 | 19 | s.test_spec 'Tests' do |test_spec| 20 | test_spec.dependency 'SnapshotTesting', '~> 1.9' 21 | test_spec.requires_app_host = true 22 | test_spec.source_files = 'Tests/PayKitUITests/**/*.swift' 23 | 24 | test_spec.ios.resource_bundle = { 25 | 'SnapshotTestImages' => 'Tests/PayKitUITests/*' 26 | } 27 | end 28 | end 29 | 30 | -------------------------------------------------------------------------------- /Demo/PayKitDemo/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleURLTypes 6 | 7 | 8 | CFBundleTypeRole 9 | Editor 10 | CFBundleURLName 11 | app.cash.paykitdemo 12 | CFBundleURLSchemes 13 | 14 | paykitdemo 15 | 16 | 17 | 18 | UIApplicationSceneManifest 19 | 20 | UIApplicationSupportsMultipleScenes 21 | 22 | UISceneConfigurations 23 | 24 | UIWindowSceneSessionRoleApplication 25 | 26 | 27 | UISceneConfigurationName 28 | Default Configuration 29 | UISceneDelegateClassName 30 | $(PRODUCT_MODULE_NAME).SceneDelegate 31 | UISceneStoryboardFile 32 | Main 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /Sources/PayKit/Services/Networking/HTTPRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HTTPRequest.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import Foundation 18 | 19 | struct HTTPRequest { 20 | let urlRequest: URLRequest 21 | let retryPolicy: RetryPolicy? 22 | let handler: (Data?, URLResponse?, Error?) -> Void 23 | 24 | init( 25 | urlRequest: URLRequest, 26 | retryPolicy: RetryPolicy?, 27 | handler: @escaping (Data?, URLResponse?, Error?) -> Void) { 28 | self.urlRequest = urlRequest 29 | self.retryPolicy = retryPolicy 30 | self.handler = handler 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Contributing.md: -------------------------------------------------------------------------------- 1 | ## Sign the CLA 2 | 3 | All contributors to your PR must sign our [Individual Contributor License Agreement (CLA)][1]. The CLA is a short form that ensures that you are eligible to contribute. 4 | 5 | You only need to do this once, so if you've done this for another Square open source project, you're all set. If you are submitting a pull request for the first time, just let us know that you have completed the CLA (we'll look up your github user name). 6 | 7 | ## One issue or bug per Pull Request 8 | 9 | Keep your Pull Requests small. Small PRs are easier to reason about which makes them significantly more likely to get merged. 10 | 11 | ## Issues before features 12 | 13 | If you want to add a feature, please file an Issue first. An Issue gives us the opportunity to discuss the requirements and implications of a feature with you before you start writing code. 14 | 15 | ## Backwards compatibility 16 | 17 | Existing installs of PayKit should continue to work after upgrading to a version that includes your changes. Some changes may need to include a migration plan. 18 | 19 | ## Forwards compatibility 20 | 21 | Please do not write new code using deprecated APIs. 22 | 23 | [1]: https://spreadsheets.google.com/spreadsheet/viewform?formkey=dDViT2xzUHAwRkI3X3k5Z0lQM091OGc6MQ&ndplr=1 24 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | pod-lint: 11 | name: Pod Lint 12 | runs-on: macos-13 13 | steps: 14 | - name: Checkout Repo 15 | uses: actions/checkout@v4 16 | - name: Bundle Install 17 | run: bundle install --gemfile=Gemfile 18 | - name: Lint PayKit Podspec 19 | run: bundle exec --gemfile=Gemfile pod lib lint --verbose --fail-fast CashAppPayKit.podspec 20 | - name: Lint PayKitUI Podspec 21 | run: bundle exec --gemfile=Gemfile pod lib lint --verbose --fail-fast CashAppPayKitUI.podspec 22 | - uses: actions/upload-artifact@v4 23 | if: failure() 24 | with: 25 | name: failed_snaphots 26 | path: /Users/runner/Library/Developer/CoreSimulator/Devices/*/data/Containers/Data/Application/*/tmp/ 27 | spm: 28 | name: SPM Build 29 | runs-on: macos-latest 30 | steps: 31 | - name: Checkout Repo 32 | uses: actions/checkout@v4 33 | - name: Select Xcode Version (15.1.0) 34 | run: sudo xcode-select --switch /Applications/Xcode_15.1.0.app/Contents/Developer 35 | - name: Build 36 | run: xcodebuild build -scheme PayKit -sdk "`xcrun --sdk iphonesimulator --show-sdk-path`" 37 | -------------------------------------------------------------------------------- /Sources/PayKit/Services/Analytics/CashAppPayEndpoint+Analytics.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CashAppPayEndpoint+Analytics.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import Foundation 18 | 19 | extension CashAppPay.Endpoint { 20 | var analyticsEndpoint: AnalyticsClient.Endpoint { 21 | switch self { 22 | case .production, .sandbox: 23 | return .production 24 | case .staging: 25 | return .staging 26 | } 27 | } 28 | 29 | var analyticsField: String { 30 | switch self { 31 | case .production: 32 | return "production" 33 | case .sandbox: 34 | return "sandbox" 35 | case .staging: 36 | return "staging" 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Tests/PayKitTests/DateFormatterTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DateFormatterTests.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | @testable import PayKit 18 | import XCTest 19 | 20 | class DateFormatterTests: XCTestCase { 21 | func test_formatter() throws { 22 | let date = try XCTUnwrap( 23 | DateComponents( 24 | calendar: Calendar(identifier: .gregorian), 25 | timeZone: .init(secondsFromGMT: 0), 26 | year: 2022, 27 | month: 4, 28 | day: 20, 29 | hour: 8, 30 | minute: 30, 31 | second: 45 32 | ).date 33 | ) 34 | XCTAssertEqual(DateFormatter.payKitFormatter().string(from: date), "2022-04-20T08:30:45.000Z") 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Tests/PayKitTests/Helpers/MockNotificationCenter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockNotificationCenter.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import Foundation 18 | import UIKit 19 | 20 | class MockNotificationCenter: NotificationCenter { 21 | var addObserverStub: ((Any, Selector, NSNotification.Name?, Any?) -> Void) 22 | 23 | init(addObserverStub: @escaping (Any, Selector, NSNotification.Name?, Any?) -> Void) { 24 | self.addObserverStub = addObserverStub 25 | } 26 | 27 | override func addObserver( 28 | _ observer: Any, 29 | selector aSelector: Selector, 30 | name aName: NSNotification.Name?, 31 | object anObject: Any? 32 | ) { 33 | addObserverStub(observer, aSelector, aName, anObject) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Tests/PayKitTests/UnexpectedErrorTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UnexpectedErrorTests.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | @testable import PayKit 18 | import XCTest 19 | 20 | class UnexpectedErrorTests: XCTestCase { 21 | 22 | private lazy var jsonEncoder: JSONEncoder = .payKitEncoder() 23 | private lazy var jsonDecoder: JSONDecoder = .payKitDecoder() 24 | 25 | func test_deserialize_unexpectedError() throws { 26 | let fixtureJSON = try fixtureDataForFilename(idempotencyKeyReusedFilename, in: .errors) 27 | let errorWrapper = try jsonDecoder.decode(UnexpectedErrorWrapper.self, from: fixtureJSON) 28 | XCTAssertEqual(errorWrapper.errors.first, TestValues.idempotencyKeyReusedError) 29 | } 30 | 31 | let idempotencyKeyReusedFilename = "idempotencyKeyReused" 32 | } 33 | -------------------------------------------------------------------------------- /Tests/PayKitTests/Helpers/MockRestService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockRestService.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import Foundation 18 | @testable import PayKit 19 | 20 | class MockRestService: RESTService { 21 | var executeStub: (URLRequest, RetryPolicy?, @escaping ((Data?, URLResponse?, Error?) -> Void)) -> Void 22 | 23 | // swiftlint:disable:next line_length 24 | init(executeStub: @escaping (URLRequest, RetryPolicy?, @escaping (Data?, URLResponse?, Error?) -> Void) -> Void = { _, _, _ in }) { 25 | self.executeStub = executeStub 26 | } 27 | 28 | // swiftlint:disable:next line_length 29 | func execute(request: URLRequest, retryPolicy: RetryPolicy?, completion: @escaping ((Data?, URLResponse?, Error?) -> Void)) { 30 | executeStub(request, retryPolicy, completion) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/PayKitUI/Shared/Generated/Strings.generated.swift: -------------------------------------------------------------------------------- 1 | // swiftlint:disable all 2 | // Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen 3 | 4 | import Foundation 5 | 6 | // swiftlint:disable superfluous_disable_command file_length implicit_return prefer_self_in_static_references 7 | 8 | // MARK: - Strings 9 | 10 | // swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length 11 | // swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces 12 | internal enum L10n { 13 | } 14 | // swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length 15 | // swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces 16 | 17 | // MARK: - Implementation Details 18 | 19 | extension L10n { 20 | private static func tr(_ table: String, _ key: String, _ args: CVarArg..., fallback value: String) -> String { 21 | let format = BundleToken.bundle.localizedString(forKey: key, value: value, table: table) 22 | return String(format: format, locale: Locale.current, arguments: args) 23 | } 24 | } 25 | 26 | // swiftlint:disable convenience_type 27 | private final class BundleToken { 28 | static let bundle: Bundle = { 29 | #if SWIFT_PACKAGE 30 | return Bundle.module 31 | #else 32 | return Bundle(for: BundleToken.self) 33 | #endif 34 | }() 35 | } 36 | // swiftlint:enable convenience_type 37 | -------------------------------------------------------------------------------- /Demo/PayKitDemo/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegate.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import PayKit 18 | import UIKit 19 | 20 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 21 | 22 | var window: UIWindow? 23 | 24 | func scene( 25 | _ scene: UIScene, 26 | willConnectTo session: UISceneSession, 27 | options connectionOptions: UIScene.ConnectionOptions 28 | ) { 29 | guard (scene as? UIWindowScene) != nil else { return } 30 | } 31 | 32 | func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { 33 | if let url = URLContexts.first?.url { 34 | NotificationCenter.default.post( 35 | name: CashAppPay.RedirectNotification, 36 | object: nil, 37 | userInfo: [UIApplication.LaunchOptionsKey.url: url] 38 | ) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Demo/PayKitDemo/TabBarController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TabBarController.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import UIKit 18 | 19 | class TabBarController: UITabBarController, UITabBarControllerDelegate { 20 | override func viewDidLoad() { 21 | super.viewDidLoad() 22 | view.backgroundColor = UIColor.systemGroupedBackground 23 | 24 | let payKitController = PayKitViewController() 25 | payKitController.tabBarItem = UITabBarItem(title: "PayKit", image: UIImage(systemName: "briefcase"), tag: 0) 26 | 27 | let componentController = ComponentsViewController(style: .grouped) 28 | componentController.tabBarItem = UITabBarItem( 29 | title: "Components", 30 | image: UIImage(systemName: "pencil.and.outline"), 31 | tag: 1 32 | ) 33 | 34 | self.viewControllers = [ 35 | payKitController, 36 | UINavigationController(rootViewController: componentController), 37 | ] 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/PayKit/Services/Analytics/JSONEncoder+Analytics.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JSONEncoder+Analytics.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import Foundation 18 | 19 | // MARK: - JSONEncoder 20 | 21 | extension JSONEncoder { 22 | static func eventStream2Encoder() -> JSONEncoder { 23 | let encoder = JSONEncoder() 24 | encoder.keyEncodingStrategy = .convertToSnakeCase 25 | encoder.dateEncodingStrategy = .microsecondsSince1970 26 | encoder.outputFormatting = .sortedKeys 27 | return encoder 28 | } 29 | } 30 | 31 | extension JSONEncoder.DateEncodingStrategy { 32 | // Encode the data to ms rounding to remove fractions 33 | static let microsecondsSince1970 = custom { date, encoder in 34 | var container = encoder.singleValueContainer() 35 | try container.encode(date.microsecondsSince1970) 36 | } 37 | } 38 | 39 | extension Date { 40 | var microsecondsSince1970: UInt { 41 | UInt((timeIntervalSince1970 * 1_000_000).rounded()) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/PayKit/JSONCoder+PayKit.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JSONCoder+PayKit.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import Foundation 18 | 19 | // MARK: - Date Formatter 20 | 21 | extension DateFormatter { 22 | static func payKitFormatter() -> DateFormatter { 23 | let dateFormatter = DateFormatter() 24 | dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX" 25 | dateFormatter.timeZone = TimeZone(abbreviation: "GMT") 26 | return dateFormatter 27 | } 28 | } 29 | 30 | // MARK: - JSON Encoder 31 | 32 | extension JSONEncoder { 33 | static func payKitEncoder() -> JSONEncoder { 34 | let encoder = JSONEncoder() 35 | encoder.keyEncodingStrategy = .convertToSnakeCase 36 | encoder.dateEncodingStrategy = .formatted(.payKitFormatter()) 37 | return encoder 38 | } 39 | } 40 | 41 | // MARK: - JSON Decoder 42 | 43 | extension JSONDecoder { 44 | static func payKitDecoder() -> JSONDecoder { 45 | let decoder = JSONDecoder() 46 | decoder.keyDecodingStrategy = .convertFromSnakeCase 47 | decoder.dateDecodingStrategy = .formatted(.payKitFormatter()) 48 | return decoder 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Tests/PayKitTests/Resources/Fixtures/pendingRequest-fullyPopulated.json: -------------------------------------------------------------------------------- 1 | { 2 | "request": 3 | { 4 | "id": "GRR_mg3saamyqdm29jj9pqjqkedm", 5 | "status": "PENDING", 6 | "actions": 7 | [ 8 | { 9 | "type": "ON_FILE_PAYMENT", 10 | "scope_id": "BRAND_9kx6p0mkuo97jnl025q9ni94t", 11 | "account_reference_id": "account4" 12 | } 13 | ], 14 | "origin": 15 | { 16 | "type": "DIRECT" 17 | }, 18 | "auth_flow_triggers": 19 | { 20 | "qr_code_image_url": "https://sandbox.api.cash.app/qr/sandbox/v1/GRR_mg3saamyqdm29jj9pqjqkedm-t61pfg?rounded=0&format=png", 21 | "qr_code_svg_url": "https://sandbox.api.cash.app/qr/sandbox/v1/GRR_mg3saamyqdm29jj9pqjqkedm-t61pfg?rounded=0&format=svg", 22 | "mobile_url": "https://sandbox.api.cash.app/customer-request/v1/requests/GRR_mg3saamyqdm29jj9pqjqkedm/interstitial", 23 | "refreshes_at": "2022-10-20T20:16:48.036Z" 24 | }, 25 | "reference_id": "refer_to_me", 26 | "created_at": "2022-10-20T20:16:18.051Z", 27 | "updated_at": "2022-10-20T20:16:18.051Z", 28 | "expires_at": "2022-10-20T21:16:18.024Z", 29 | "requester_profile": 30 | { 31 | "name": "SDK Hacking: The Brand", 32 | "logo_url": "https://franklin-assets.s3.amazonaws.com/merchants/assets/v3/generic/m_category_shopping.png" 33 | }, 34 | "metadata": 35 | { 36 | "key1": "Valuation", 37 | "key2": "ValuWorld", 38 | "key3": "Valuminous" 39 | }, 40 | "redirect_url": "paykitdemo://callback", 41 | "channel": "IN_APP" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Demo/PayKitDemo/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Sources/PayKit/Services/Analytics/AnalyticsDataSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnalyticsDataSource.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import Foundation 18 | 19 | class AnalyticsDataSource { 20 | 21 | // MARK: - Static Properties 22 | 23 | static let maxEventCount = 300 24 | 25 | // MARK: - Private Properties 26 | 27 | private var events: [AnalyticsEvent] 28 | 29 | // MARK: - Lifecycle 30 | 31 | init(events: [AnalyticsEvent] = []) { 32 | self.events = events 33 | } 34 | 35 | // MARK: - Public Properties 36 | 37 | var count: Int { 38 | events.count 39 | } 40 | 41 | // MARK: - Public 42 | 43 | func insert(event: AnalyticsEvent) { 44 | guard count < AnalyticsDataSource.maxEventCount else { 45 | Log.write("Too many Analytics Events dropping event id: \(event.id)") 46 | return 47 | } 48 | events.append(event) 49 | } 50 | 51 | func fetch(limit: Int) -> [AnalyticsEvent] { 52 | events.prefix(limit).map { $0 } 53 | } 54 | 55 | func remove(_ eventIds: [UUID]) { 56 | let eventIdSet = Set(eventIds) 57 | events.removeAll(where: { eventIdSet.contains($0.id) }) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Tests/PayKitTests/CashAppPay+EndpointTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PayKit+EndpointTest.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | @testable import PayKit 18 | import XCTest 19 | 20 | class CashAppPay_EndpointTest: XCTestCase { 21 | func test_endpoint_url() { 22 | XCTAssertEqual(CashAppPay.Endpoint.staging.baseURL.absoluteString, "https://api.cashstaging.app/") 23 | XCTAssertEqual(CashAppPay.Endpoint.sandbox.baseURL.absoluteString, "https://sandbox.api.cash.app/") 24 | XCTAssertEqual(CashAppPay.Endpoint.production.baseURL.absoluteString, "https://api.cash.app/") 25 | } 26 | 27 | func test_endpoint_analytics_endpoint() { 28 | XCTAssertEqual(CashAppPay.Endpoint.staging.analyticsEndpoint, .staging) 29 | XCTAssertEqual(CashAppPay.Endpoint.production.analyticsEndpoint, .production) 30 | XCTAssertEqual(CashAppPay.Endpoint.sandbox.analyticsEndpoint, .production) 31 | } 32 | 33 | func test_analytics_field() { 34 | XCTAssertEqual(CashAppPay.Endpoint.staging.analyticsField, "staging") 35 | XCTAssertEqual(CashAppPay.Endpoint.production.analyticsField, "production") 36 | XCTAssertEqual(CashAppPay.Endpoint.sandbox.analyticsField, "sandbox") 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | 2 | # For a list of available rules, see: 3 | # https://github.com/realm/SwiftLint/blob/master/Rules.md 4 | 5 | disabled_rules: 6 | - file_length 7 | - identifier_name 8 | - nesting 9 | - notification_center_detachment 10 | - shorthand_operator 11 | - todo 12 | - type_body_length 13 | - type_name 14 | - unused_setter_value 15 | 16 | opt_in_rules: 17 | - modifier_order 18 | - multiline_arguments 19 | - multiline_arguments_brackets 20 | - multiline_literal_brackets 21 | - prefer_self_type_over_type_of_self 22 | - shorthand_optional_binding 23 | - sorted_imports 24 | - unavailable_function 25 | - file_header 26 | 27 | excluded: 28 | - Package.swift 29 | 30 | trailing_comma: 31 | mandatory_comma: true 32 | 33 | trailing_whitespace: 34 | ignores_comments: false 35 | 36 | modifier_order: 37 | preferred_modifier_order: 38 | - acl 39 | - setterACL 40 | - override 41 | - dynamic 42 | - mutators 43 | - lazy 44 | - final 45 | - required 46 | - convenience 47 | - typeMethods 48 | - owned 49 | 50 | file_header: 51 | severity: error 52 | required_pattern: | 53 | \/\/ 54 | \/\/ .*?\.swift 55 | \/\/ 56 | \/\/ Licensed under the Apache License, Version 2\.0 \(the "License"\); 57 | \/\/ you may not use this file except in compliance with the License\. 58 | \/\/ You may obtain a copy of the License at 59 | \/\/ 60 | \/\/ http:\/\/www\.apache\.org\/licenses/LICENSE-2\.0 61 | \/\/ 62 | \/\/ Unless required by applicable law or agreed to in writing, software 63 | \/\/ distributed under the License is distributed on an "AS IS" BASIS, 64 | \/\/ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied\. 65 | -------------------------------------------------------------------------------- /Demo/PayKitDemo/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Tests/PayKitTests/UserAgentTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserAgentTests.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | @testable import PayKit 18 | import XCTest 19 | 20 | class UserAgentTests: XCTestCase { 21 | func test_user_agent() { 22 | let appIdentifier = infoDictionaryString(forKey: kCFBundleIdentifierKey as String) 23 | let appVersion = infoDictionaryString(forKey: kCFBundleVersionKey as String) 24 | let payKitVersion = CashAppPay.version 25 | 26 | let model = UIDevice.current.deviceModel ?? UserAgent.unknownValue 27 | let language = Locale.current.languageCode?.lowercased() ?? UserAgent.unknownValue 28 | let country = Locale.current.regionCode?.lowercased() ?? UserAgent.unknownValue 29 | let osVersion = UIDevice.current.systemVersion 30 | 31 | let expectedUserAgent = String( 32 | format: "Mozilla/5.0 (%@; CPU iPhone OS %@ like Mac OS X; %@-%@) PayKitVersion/%@ %@/%@", 33 | model, 34 | osVersion, 35 | language, 36 | country, 37 | payKitVersion, 38 | appIdentifier, 39 | appVersion 40 | ) 41 | XCTAssertEqual(UserAgent.userAgent, expectedUserAgent) 42 | } 43 | 44 | func test_unknown_value() { 45 | XCTAssertEqual(UserAgent.unknownValue, "unknown") 46 | } 47 | 48 | private func infoDictionaryString(forKey key: String) -> String { 49 | return Bundle.main.object(forInfoDictionaryKey: key) as? String ?? UserAgent.unknownValue 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/PayKit/Services/Networking/RetryPolicy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RetryPolicy.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import Foundation 18 | 19 | enum RetryPolicy: Equatable { 20 | case exponential(delay: TimeInterval = 2, attempt: Int = 0, maximumNumberOfAttempts: Int) 21 | 22 | /// The delay before the request can be retried. 23 | var lockout: TimeInterval { 24 | switch self { 25 | case .exponential(delay: let interval, attempt: let attempt, maximumNumberOfAttempts: _): 26 | return RetryPolicy.exponentialBackoffCalculation(interval: interval, retryNumber: attempt) 27 | } 28 | } 29 | 30 | /// Returns the retry policy for the next attempt or nil if the policy indiciates it should not run again. 31 | func decrement() -> RetryPolicy? { 32 | switch self { 33 | case .exponential(delay: let delay, attempt: let attempt, maximumNumberOfAttempts: let maximumNumberOfAttempts): 34 | let attempts = attempt + 1 35 | guard attempts < maximumNumberOfAttempts else { return nil } 36 | return .exponential(delay: delay, attempt: attempts, maximumNumberOfAttempts: maximumNumberOfAttempts) 37 | } 38 | } 39 | } 40 | 41 | // MARK: - Calculations 42 | 43 | private extension RetryPolicy { 44 | /// Calculation based on https://en.wikipedia.org/wiki/Exponential_backoff#Expected_backoff 45 | private static func exponentialBackoffCalculation(interval: TimeInterval, retryNumber: Int) -> TimeInterval { 46 | interval * (pow(2, Double(retryNumber)) - 1) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Tests/PayKitTests/APIErrorTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APIErrorTests.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | @testable import PayKit 18 | import XCTest 19 | 20 | class APIErrorTests: XCTestCase { 21 | 22 | private lazy var jsonEncoder: JSONEncoder = .payKitEncoder() 23 | private lazy var jsonDecoder: JSONDecoder = .payKitDecoder() 24 | 25 | func test_serialize_internalServerError() throws { 26 | // Try serializing the error to JSON. 27 | let serializedError = try jsonEncoder.encode(APIErrorWrapper(errors: [TestValues.internalServerError])) 28 | 29 | // Try loading the fixture JSON from file. 30 | let fixtureError = try fixtureDataForFilename(internalServerErrorFilename, in: .errors) 31 | 32 | // The JSON data is unordered, so convert it to a dictionary to compare. 33 | let fixtureDict = try XCTUnwrap(JSONSerialization.jsonObject(with: fixtureError) as? [String: AnyHashable]) 34 | let serializedDict = try XCTUnwrap( 35 | JSONSerialization.jsonObject(with: serializedError) as? [String: AnyHashable] 36 | ) 37 | XCTAssertEqual(fixtureDict, serializedDict) 38 | } 39 | 40 | func test_deserialize_internalServerError() throws { 41 | let fixtureJSON = try fixtureDataForFilename(internalServerErrorFilename, in: .errors) 42 | let errorWrapper = try jsonDecoder.decode(APIErrorWrapper.self, from: fixtureJSON) 43 | XCTAssertEqual(errorWrapper.errors.first, TestValues.internalServerError) 44 | } 45 | 46 | let internalServerErrorFilename = "internalServerError" 47 | } 48 | -------------------------------------------------------------------------------- /Demo/PayKitDemo/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /Tests/PayKitTests/RetryPolicyTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RetryPolicyTests.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | @testable import PayKit 18 | import XCTest 19 | 20 | class RetryPolicyTests: XCTestCase { 21 | func test_exponential_lockout() { 22 | XCTAssertEqual(RetryPolicy.exponential(delay: 5, attempt: 0, maximumNumberOfAttempts: 5).lockout, 0) 23 | XCTAssertEqual(RetryPolicy.exponential(delay: 5, attempt: 1, maximumNumberOfAttempts: 5).lockout, 5) 24 | XCTAssertEqual(RetryPolicy.exponential(delay: 5, attempt: 2, maximumNumberOfAttempts: 5).lockout, 15) 25 | XCTAssertEqual(RetryPolicy.exponential(delay: 5, attempt: 3, maximumNumberOfAttempts: 5).lockout, 35) 26 | XCTAssertEqual(RetryPolicy.exponential(delay: 5, attempt: 4, maximumNumberOfAttempts: 5).lockout, 75) 27 | } 28 | 29 | func test_exponential_default_values() { 30 | XCTAssertEqual( 31 | RetryPolicy.exponential(maximumNumberOfAttempts: 5), 32 | .exponential(delay: 2, attempt: 0, maximumNumberOfAttempts: 5) 33 | ) 34 | } 35 | 36 | func test_decrement_exponential_lockout() { 37 | let policy = RetryPolicy.exponential(delay: 5, attempt: 0, maximumNumberOfAttempts: 3) 38 | let firstAttempt = policy.decrement() 39 | XCTAssertEqual(firstAttempt, RetryPolicy.exponential(delay: 5, attempt: 1, maximumNumberOfAttempts: 3)) 40 | let secondAttempt = firstAttempt?.decrement() 41 | XCTAssertEqual(secondAttempt, RetryPolicy.exponential(delay: 5, attempt: 2, maximumNumberOfAttempts: 3)) 42 | XCTAssertNil(secondAttempt?.decrement()) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "PayKit", 6 | defaultLocalization: "en", 7 | platforms: [ 8 | .iOS(.v12), 9 | .macOS(.v10_13), 10 | ], 11 | products: [ 12 | .library( 13 | name: "PayKit", 14 | targets: ["PayKit"] 15 | ), 16 | .library( 17 | name: "PayKitUI", 18 | targets: ["PayKitUI"] 19 | ), 20 | ], 21 | dependencies: [ 22 | .package(url: "https://github.com/SwiftGen/SwiftGenPlugin.git", from: "6.6.2"), 23 | .package(url: "https://github.com/pointfreeco/swift-snapshot-testing.git", from: "1.9.0"), 24 | ], 25 | targets: [ 26 | .target( 27 | name: "PayKit", 28 | resources: [ 29 | .process("PrivacyInfo.xcprivacy") 30 | ], 31 | swiftSettings: [ 32 | .define("LOGGING", .when(configuration: .debug)) 33 | ] 34 | ), 35 | .target( 36 | name: "PayKitUI", 37 | resources: [ 38 | .copy("custom-xcassets-template.stencil"), 39 | .copy("Shared/Assets/Resources/Colors.xcassets"), 40 | .copy("swiftgen.yml"), 41 | .copy("Shared/Assets/Resources/Images.xcassets"), 42 | .process("PrivacyInfo.xcprivacy") 43 | ], plugins: [ 44 | .plugin(name: "SwiftGenPlugin", package: "SwiftGenPlugin"), 45 | ] 46 | ), 47 | .testTarget( 48 | name: "PayKitTests", 49 | dependencies: ["PayKit"], 50 | resources: [ 51 | .copy("Resources/Fixtures"), 52 | .copy("Resources/Fixtures/Errors"), 53 | ] 54 | ), 55 | .testTarget( 56 | name: "PayKitUITests", 57 | dependencies: [ 58 | "PayKitUI", 59 | .product(name: "SnapshotTesting", package: "swift-snapshot-testing"), 60 | ], 61 | resources: [ 62 | .copy("__Snapshots__/"), 63 | ] 64 | ) 65 | ] 66 | ) 67 | -------------------------------------------------------------------------------- /Tests/PayKitTests/CustomerRequest+ExtensionsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | @testable import PayKit 18 | import XCTest 19 | 20 | final class CustomerRequest_ExtensionsTests: XCTestCase { 21 | private var now: Date! 22 | private var url: URL! 23 | 24 | override func setUpWithError() throws { 25 | try super.setUpWithError() 26 | now = Date() 27 | url = try XCTUnwrap(URL(string: "https://block.xyz")) 28 | } 29 | 30 | override func tearDown() { 31 | now = nil 32 | url = nil 33 | super.tearDown() 34 | } 35 | 36 | func test_expired() throws { 37 | let authFlowTrigger = CustomerRequest.AuthFlowTriggers( 38 | qrCodeImageURL: url, 39 | qrCodeSVGURL: url, 40 | mobileURL: url, 41 | refreshesAt: Date(timeInterval: -30, since: now) 42 | ) 43 | 44 | XCTAssertTrue(authFlowTrigger.isExpired(on: now)) 45 | } 46 | 47 | func test_notExpired() throws { 48 | let authFlowTrigger = CustomerRequest.AuthFlowTriggers( 49 | qrCodeImageURL: url, 50 | qrCodeSVGURL: url, 51 | mobileURL: url, 52 | refreshesAt: Date(timeInterval: 30, since: now) 53 | ) 54 | XCTAssertFalse(authFlowTrigger.isExpired(on: now)) 55 | } 56 | 57 | func test_expiredBecauseOfJitter() { 58 | let authFlowTrigger = CustomerRequest.AuthFlowTriggers( 59 | qrCodeImageURL: url, 60 | qrCodeSVGURL: url, 61 | mobileURL: url, 62 | refreshesAt: Date(timeInterval: 10, since: now) 63 | ) 64 | XCTAssertTrue(authFlowTrigger.isExpired(on: now)) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Tests/PayKitTests/Resources/Fixtures/approvedRequest-fullyPopulated.json: -------------------------------------------------------------------------------- 1 | { 2 | "request": 3 | { 4 | "id": "GRR_mg3saamyqdm29jj9pqjqkedm", 5 | "status": "APPROVED", 6 | "actions": 7 | [ 8 | { 9 | "type": "ON_FILE_PAYMENT", 10 | "scope_id": "BRAND_9kx6p0mkuo97jnl025q9ni94t", 11 | "account_reference_id": "account4" 12 | } 13 | ], 14 | "origin": 15 | { 16 | "type": "DIRECT" 17 | }, 18 | "grants": 19 | [ 20 | { 21 | "id": "GRG_AZYyHv2DwQltw0SiCLTaRb73y40XFe2dWM690WDF9Btqn-uTCYAUROa4ciwCdDnZcG4PuY1m_i3gwHODiO8DSf9zdMmRl1T0SM267vzuldnBs246-duHZhcehhXtmhfU8g", 22 | "status": "ACTIVE", 23 | "type": "EXTENDED", 24 | "action": 25 | { 26 | "type": "ON_FILE_PAYMENT", 27 | "scope_id": "BRAND_9kx6p0mkuo97jnl025q9ni94t", 28 | "account_reference_id": "account4" 29 | }, 30 | "channel": "IN_APP", 31 | "customer_id": "CST_AYVkuLw-sT3OKZ7a_nhNTC_L2ekahLgGrS-EM_QhW4OTrGMbi59X1eCclH0cjaxoLObc", 32 | "expires_at": "2027-10-19T21:03:43.159Z", 33 | "created_at": "2022-10-20T21:03:43.249Z", 34 | "updated_at": "2022-10-20T21:03:43.249Z" 35 | } 36 | ], 37 | "reference_id": "refer_to_me", 38 | "created_at": "2022-10-20T20:16:18.051Z", 39 | "updated_at": "2022-10-20T21:04:10.701Z", 40 | "expires_at": "2022-10-20T22:03:43.113Z", 41 | "requester_profile": 42 | { 43 | "name": "SDK Hacking: The Brand", 44 | "logo_url": "https://franklin-assets.s3.amazonaws.com/merchants/assets/v3/generic/m_category_shopping.png" 45 | }, 46 | "customer_profile": 47 | { 48 | "id": "CST_AYVkuLw-sT3OKZ7a_nhNTC_L2ekahLgGrS-EM_QhW4OTrGMbi59X1eCclH0cjaxoLObc", 49 | "cashtag": "$CASHTAG_C_TOKEN" 50 | }, 51 | "metadata": 52 | { 53 | "key1": "Valuation", 54 | "key2": "ValuWorld", 55 | "key3": "Valuminous" 56 | }, 57 | "redirect_url": "paykitdemo://callback", 58 | "channel": "IN_APP" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Tests/PayKitTests/Helpers/MockURLProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockURLProtocol.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import Foundation 18 | 19 | class MockURLSession { 20 | let session: URLSession 21 | 22 | // swiftlint:disable:next large_tuple 23 | init(handler: ((URLRequest) -> (Data?, URLResponse?, Error?))?) { 24 | URLProtocolStub.loadingHandler = handler 25 | 26 | let configuration = URLSessionConfiguration.ephemeral 27 | configuration.protocolClasses = [URLProtocolStub.self] 28 | self.session = URLSession(configuration: configuration) 29 | } 30 | 31 | deinit { 32 | URLProtocolStub.loadingHandler = nil 33 | } 34 | } 35 | 36 | private final class URLProtocolStub: URLProtocol { 37 | 38 | // swiftlint:disable:next large_tuple 39 | static var loadingHandler: ((URLRequest) -> (Data?, URLResponse?, Error?))? 40 | 41 | override class func canInit(with request: URLRequest) -> Bool { 42 | true 43 | } 44 | 45 | override class func canonicalRequest(for request: URLRequest) -> URLRequest { 46 | request 47 | } 48 | 49 | override func startLoading() { 50 | let (data, response, error) = Self.loadingHandler!(request) 51 | 52 | if let error { 53 | client?.urlProtocol(self, didFailWithError: error) 54 | } else if let response, let data { 55 | client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) 56 | client?.urlProtocol(self, didLoad: data) 57 | } else { 58 | fatalError("No stubs provided") 59 | } 60 | 61 | client?.urlProtocolDidFinishLoading(self) 62 | } 63 | 64 | override func stopLoading() { 65 | // Intentionally left blank 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Tests/PayKitUITests/CashAppPaymentMethodViewSnapshotTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CashAppPaymentMethodViewSnapshotTests.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import PayKitUI 18 | import SnapshotTesting 19 | import SwiftUI 20 | 21 | @available(iOS 13.0, *) 22 | class CashAppPaymentMethodViewSnapshotTests: BaseSnapshotTestCase { 23 | func test_small_button() { 24 | assertSnapshot( 25 | matching: CashAppPaymentMethodView(size: .small, cashTag: "$jack").frame(height: 100), 26 | as: .image(on: .iPhone8) 27 | ) 28 | } 29 | 30 | func test_large_button() { 31 | assertSnapshot( 32 | matching: CashAppPaymentMethodView(size: .large, cashTag: "$jack"), 33 | as: .image(on: .iPhone8) 34 | ) 35 | } 36 | 37 | func test_dark_mode() { 38 | assertSnapshot( 39 | matching: CashAppPaymentMethodView(size: .large, cashTag: "$jack"), 40 | as: .image(on: .iPhone8, userInterfaceStyle: .dark) 41 | ) 42 | } 43 | 44 | func test_text_custom_font() { 45 | assertSnapshot( 46 | matching: CashAppPaymentMethodView( 47 | size: .large, 48 | cashTag: "$jack", 49 | cashTagFont: Font.caption, 50 | cashTagTextColor: .red 51 | ), 52 | as: .image(on: .iPhone8, userInterfaceStyle: .dark) 53 | ) 54 | } 55 | 56 | func test_minimum_size() { 57 | let view = HStack { 58 | Spacer().frame(idealWidth: .infinity) 59 | CashAppPaymentMethodView(size: .small, cashTag: "$jack") 60 | Spacer().frame(idealWidth: .infinity) 61 | } 62 | assertSnapshot(matching: view, as: .image(on: .iPhone8, userInterfaceStyle: .dark)) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Tests/PayKitUITests/CashAppPayButtonSnapshotTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CashAppPayButtonSnapshotTests.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import PayKitUI 18 | import SnapshotTesting 19 | import XCTest 20 | 21 | @available(iOS 13.0, *) 22 | class CashAppPayButtonSnapshotTests: BaseSnapshotTestCase { 23 | func test_small_button() { 24 | assertSnapshot(matching: CashAppPayButton(size: .small, onClickHandler: {}), as: .image(centeredIn: .iPhone8)) 25 | } 26 | 27 | func test_large_button() { 28 | assertSnapshot(matching: CashAppPayButton(size: .large, onClickHandler: {}), as: .image(centeredIn: .iPhone8)) 29 | } 30 | 31 | func test_button_disabled() { 32 | let lightButton = CashAppPayButton(size: .large, onClickHandler: {}) 33 | lightButton.isEnabled = false 34 | assertSnapshot(matching: lightButton, as: .image(centeredIn: .iPhone8, userInterfaceStyle: .light)) 35 | 36 | let darkButton = CashAppPayButton(size: .large, onClickHandler: {}) 37 | darkButton.isEnabled = false 38 | assertSnapshot(matching: darkButton, as: .image(centeredIn: .iPhone8, userInterfaceStyle: .dark)) 39 | } 40 | 41 | func test_dark_mode() { 42 | assertSnapshot( 43 | matching: CashAppPayButton(onClickHandler: {}), 44 | as: .image(centeredIn: .iPhone8, userInterfaceStyle: .dark) 45 | ) 46 | } 47 | 48 | func test_button_expands() { 49 | assertSnapshot( 50 | matching: CashAppPayButton(size: .large, onClickHandler: {}), 51 | as: .image(filling: .iPhone8, userInterfaceStyle: .dark) 52 | ) 53 | assertSnapshot( 54 | matching: CashAppPayButton(size: .small, onClickHandler: {}), 55 | as: .image(filling: .iPhone8, userInterfaceStyle: .dark) 56 | ) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Tests/PayKitTests/AnalyticsDataSourceTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnalyticsDataSourceTests.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | @testable import PayKit 18 | import XCTest 19 | 20 | class AnalyticsDataSourceTests: XCTestCase { 21 | private var dataSource: AnalyticsDataSource! 22 | 23 | override func setUp() { 24 | super.setUp() 25 | self.dataSource = AnalyticsDataSource() 26 | } 27 | 28 | override func tearDown() { 29 | self.dataSource = nil 30 | super.tearDown() 31 | } 32 | 33 | func test_insert() { 34 | let event = AnalyticsEvent(catalog: "test", fields: ["test": "key"]) 35 | 36 | XCTAssertEqual(dataSource.count, 0) 37 | dataSource.insert(event: event) 38 | XCTAssertEqual(dataSource.count, 1) 39 | } 40 | 41 | func test_query_limit() { 42 | let event1 = AnalyticsEvent(catalog: "test", fields: ["test": "key"]) 43 | let event2 = AnalyticsEvent(catalog: "test", fields: ["test": 5]) 44 | 45 | dataSource.insert(event: event1) 46 | dataSource.insert(event: event2) 47 | 48 | XCTAssertEqual(dataSource.fetch(limit: 1).count, 1) 49 | } 50 | 51 | func test_remove() { 52 | let id = UUID() 53 | let event = AnalyticsEvent(id: id, catalog: "test", fields: ["test": "key"]) 54 | 55 | dataSource.insert(event: event) 56 | dataSource.remove([event.id]) 57 | 58 | XCTAssertEqual(dataSource.count, 0) 59 | } 60 | 61 | func test_max_event_count_is_not_exceeded() { 62 | let events = Array(repeating: AnalyticsEvent(catalog: "", fields: [:]), count: 300) 63 | let store = AnalyticsDataSource(events: events) 64 | XCTAssertEqual(store.count, 300) 65 | store.insert(event: AnalyticsEvent(catalog: "", fields: [:])) 66 | XCTAssertEqual(store.count, 300) 67 | } 68 | 69 | func test_max_event_count() { 70 | XCTAssertEqual(AnalyticsDataSource.maxEventCount, 300) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Tests/PayKitUITests/CashAppPayButtonViewSnapshotTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CashAppPayButtonViewSnapshotTests.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import PayKitUI 18 | import SnapshotTesting 19 | import SwiftUI 20 | 21 | @available(iOS 13.0, *) 22 | class CashAppPayButtonViewSnapshotTests: BaseSnapshotTestCase { 23 | func test_small_button() { 24 | assertSnapshot( 25 | matching: CashAppPayButtonView(size: .small, onClickHandler: {}), 26 | as: .image(on: .iPhone8) 27 | ) 28 | } 29 | 30 | func test_large_button() { 31 | assertSnapshot( 32 | matching: CashAppPayButtonView(size: .large, onClickHandler: {}), 33 | as: .image(on: .iPhone8) 34 | ) 35 | } 36 | 37 | func test_button_disabled() { 38 | assertSnapshot( 39 | matching: CashAppPayButtonView(size: .large, isEnabled: false, onClickHandler: {}), 40 | as: .image(on: .iPhone8, userInterfaceStyle: .light) 41 | ) 42 | 43 | assertSnapshot( 44 | matching: CashAppPayButtonView(size: .large, isEnabled: false, onClickHandler: {}), 45 | as: .image(on: .iPhone8, userInterfaceStyle: .dark) 46 | ) 47 | } 48 | 49 | func test_dark_mode() { 50 | assertSnapshot( 51 | matching: CashAppPayButtonView(size: .large, onClickHandler: {}), 52 | as: .image(on: .iPhone8, userInterfaceStyle: .dark) 53 | ) 54 | } 55 | 56 | func test_minimum_size() { 57 | let smallButton = HStack { 58 | Spacer().frame(idealWidth: .infinity) 59 | CashAppPayButtonView(size: .small, onClickHandler: {}) 60 | Spacer().frame(idealWidth: .infinity) 61 | } 62 | assertSnapshot(matching: smallButton, as: .image(on: .iPhone8)) 63 | 64 | let largeButton = HStack { 65 | Spacer().frame(idealWidth: .infinity) 66 | CashAppPayButtonView(size: .large, onClickHandler: {}) 67 | Spacer().frame(idealWidth: .infinity) 68 | } 69 | assertSnapshot(matching: largeButton, as: .image(on: .iPhone8)) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Demo/PayKitDemo/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import UIKit 18 | 19 | @main 20 | class AppDelegate: UIResponder, UIApplicationDelegate { 21 | 22 | func application( 23 | _ application: UIApplication, 24 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 25 | ) -> Bool { 26 | // Override point for customization after application launch. 27 | 28 | let appearance = UINavigationBarAppearance() 29 | appearance.configureWithOpaqueBackground() 30 | UINavigationBar.appearance().standardAppearance = appearance 31 | UINavigationBar.appearance().scrollEdgeAppearance = appearance 32 | 33 | let tabBarAppearance: UITabBarAppearance = UITabBarAppearance() 34 | tabBarAppearance.configureWithDefaultBackground() 35 | tabBarAppearance.backgroundColor = .white 36 | UITabBar.appearance().standardAppearance = tabBarAppearance 37 | UITabBar.appearance().scrollEdgeAppearance = tabBarAppearance 38 | 39 | return true 40 | } 41 | 42 | // MARK: UISceneSession Lifecycle 43 | 44 | func application( 45 | _ application: UIApplication, 46 | configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions 47 | ) -> UISceneConfiguration { 48 | // Called when a new scene session is being created. 49 | // Use this method to select a configuration to create the new scene with. 50 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 51 | } 52 | 53 | func application( 54 | _ application: UIApplication, 55 | didDiscardSceneSessions sceneSessions: Set 56 | ) { 57 | // Called when the user discards a scene session. 58 | // If any sessions were discarded while the application was not running, 59 | // this will be called shortly after application:didFinishLaunchingWithOptions. 60 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return. 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Tests/PayKitUITests/CashAppPaymentMethodSnapshotTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CashAppPaymentMethodSnapshotTests.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import PayKitUI 18 | import SnapshotTesting 19 | import UIKit 20 | 21 | @available(iOS 13.0, *) 22 | class CashAppPaymentMethodSnapshotTests: BaseSnapshotTestCase { 23 | func test_small_payment_method() { 24 | let paymentMethod = CashAppPaymentMethod(size: .small) 25 | paymentMethod.cashTag = "$jack" 26 | assertSnapshot(matching: paymentMethod, as: .image(centeredIn: .iPhone8)) 27 | } 28 | 29 | func test_large_payment_method() { 30 | let paymentMethod = CashAppPaymentMethod(size: .large) 31 | paymentMethod.cashTag = "$jack" 32 | assertSnapshot(matching: paymentMethod, as: .image(centeredIn: .iPhone8)) 33 | } 34 | 35 | func test_dark_mode() { 36 | let paymentMethod = CashAppPaymentMethod(size: .large) 37 | assertSnapshot(matching: paymentMethod, as: .image(centeredIn: .iPhone8, userInterfaceStyle: .dark)) 38 | } 39 | 40 | func test_text_custom_font() { 41 | let paymentMethod = CashAppPaymentMethod(size: .large) 42 | paymentMethod.cashTag = "$jack" 43 | paymentMethod.setCashTagFont(.italicSystemFont(ofSize: 12)) 44 | paymentMethod.setCashTagTextColor(.red) 45 | assertSnapshot(matching: paymentMethod, as: .image(centeredIn: .iPhone8, userInterfaceStyle: .dark)) 46 | } 47 | 48 | func test_expands_full_width() { 49 | let paymentMethod = CashAppPaymentMethod(size: .large) 50 | paymentMethod.cashTag = "$jack" 51 | 52 | let container = UIView() 53 | paymentMethod.translatesAutoresizingMaskIntoConstraints = false 54 | container.addSubview(paymentMethod) 55 | 56 | NSLayoutConstraint.activate([ 57 | container.leadingAnchor.constraint(equalTo: paymentMethod.leadingAnchor), 58 | container.trailingAnchor.constraint(equalTo: paymentMethod.trailingAnchor), 59 | container.centerYAnchor.constraint(equalTo: paymentMethod.centerYAnchor), 60 | ]) 61 | assertSnapshot(matching: container, as: .image(filling: .iPhone8, userInterfaceStyle: .dark)) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Tests/PayKitTests/AnalyticsClientTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnalyticsClientTests.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | @testable import PayKit 18 | import XCTest 19 | 20 | class AnalyticsClientTests: XCTestCase { 21 | 22 | private let event = AnalyticsEvent(catalog: "catlog", fields: ["hello": "world"]) 23 | 24 | func test_event_stream_2_params() throws { 25 | let param = EventStream2Params(appName: "App", event: event) 26 | XCTAssertEqual(param.appName, "App") 27 | 28 | XCTAssertEqual(try param.event.jsonString(), try event.jsonString()) 29 | } 30 | 31 | func test_endpoint() { 32 | XCTAssertEqual(AnalyticsClient.Endpoint.staging.baseURL.absoluteString, "https://api.squareupstaging.com/") 33 | XCTAssertEqual(AnalyticsClient.Endpoint.production.baseURL.absoluteString, "https://api.squareup.com/") 34 | } 35 | 36 | func test_request_body() { 37 | let expectation = self.expectation(description: "Uploaded") 38 | let mock = MockRestService { request, retry, _ in 39 | self.XCTAssertEqual(retry, .exponential(maximumNumberOfAttempts: 5)) 40 | self.XCTAssertEqual(request.url?.absoluteString, "https://api.squareup.com/2.0/log/eventstream") 41 | self.XCTAssertEqual(request.httpMethod, "POST") 42 | XCTAssertNotNil(request.httpBody) 43 | expectation.fulfill() 44 | } 45 | 46 | let client = AnalyticsClient(restService: mock, endpoint: .production) 47 | client.upload(appName: "app", events: [event]) { _ in } 48 | 49 | waitForExpectations(timeout: 0.5) 50 | } 51 | 52 | func test_analytics_date_encoder_uses_rounded_ms() throws { 53 | let date = try XCTUnwrap( 54 | DateComponents( 55 | calendar: Calendar(identifier: .gregorian), 56 | timeZone: .init(secondsFromGMT: 0), 57 | year: 2022, 58 | month: 4, 59 | day: 20, 60 | hour: 8, 61 | minute: 30, 62 | second: 45 63 | ).date 64 | ) 65 | let data = try JSONEncoder.eventStream2Encoder().encode(date) 66 | XCTAssertEqual(String(data: data, encoding: .utf8), "1650443445000000") 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | specs: 3 | CFPropertyList (3.0.6) 4 | rexml 5 | activesupport (6.1.7.2) 6 | concurrent-ruby (~> 1.0, >= 1.0.2) 7 | i18n (>= 1.6, < 2) 8 | minitest (>= 5.1) 9 | tzinfo (~> 2.0) 10 | zeitwerk (~> 2.3) 11 | addressable (2.8.1) 12 | public_suffix (>= 2.0.2, < 6.0) 13 | algoliasearch (1.27.5) 14 | httpclient (~> 2.8, >= 2.8.3) 15 | json (>= 1.5.1) 16 | atomos (0.1.3) 17 | claide (1.1.0) 18 | cocoapods (1.11.3) 19 | addressable (~> 2.8) 20 | claide (>= 1.0.2, < 2.0) 21 | cocoapods-core (= 1.11.3) 22 | cocoapods-deintegrate (>= 1.0.3, < 2.0) 23 | cocoapods-downloader (>= 1.4.0, < 2.0) 24 | cocoapods-plugins (>= 1.0.0, < 2.0) 25 | cocoapods-search (>= 1.0.0, < 2.0) 26 | cocoapods-trunk (>= 1.4.0, < 2.0) 27 | cocoapods-try (>= 1.1.0, < 2.0) 28 | colored2 (~> 3.1) 29 | escape (~> 0.0.4) 30 | fourflusher (>= 2.3.0, < 3.0) 31 | gh_inspector (~> 1.0) 32 | molinillo (~> 0.8.0) 33 | nap (~> 1.0) 34 | ruby-macho (>= 1.0, < 3.0) 35 | xcodeproj (>= 1.21.0, < 2.0) 36 | cocoapods-core (1.11.3) 37 | activesupport (>= 5.0, < 7) 38 | addressable (~> 2.8) 39 | algoliasearch (~> 1.0) 40 | concurrent-ruby (~> 1.1) 41 | fuzzy_match (~> 2.0.4) 42 | nap (~> 1.0) 43 | netrc (~> 0.11) 44 | public_suffix (~> 4.0) 45 | typhoeus (~> 1.0) 46 | cocoapods-deintegrate (1.0.5) 47 | cocoapods-downloader (1.6.3) 48 | cocoapods-plugins (1.0.0) 49 | nap 50 | cocoapods-search (1.0.1) 51 | cocoapods-trunk (1.6.0) 52 | nap (>= 0.8, < 2.0) 53 | netrc (~> 0.11) 54 | cocoapods-try (1.2.0) 55 | colored2 (3.1.2) 56 | concurrent-ruby (1.2.0) 57 | escape (0.0.4) 58 | ethon (0.16.0) 59 | ffi (>= 1.15.0) 60 | ffi (1.15.5) 61 | fourflusher (2.3.1) 62 | fuzzy_match (2.0.4) 63 | gh_inspector (1.1.3) 64 | httpclient (2.8.3) 65 | i18n (1.12.0) 66 | concurrent-ruby (~> 1.0) 67 | json (2.6.3) 68 | minitest (5.17.0) 69 | molinillo (0.8.0) 70 | nanaimo (0.3.0) 71 | nap (1.1.0) 72 | netrc (0.11.0) 73 | public_suffix (4.0.7) 74 | rexml (3.2.5) 75 | ruby-macho (2.5.1) 76 | typhoeus (1.4.0) 77 | ethon (>= 0.9.0) 78 | tzinfo (2.0.6) 79 | concurrent-ruby (~> 1.0) 80 | xcodeproj (1.22.0) 81 | CFPropertyList (>= 2.3.3, < 4.0) 82 | atomos (~> 0.1.3) 83 | claide (>= 1.0.2, < 2.0) 84 | colored2 (~> 3.1) 85 | nanaimo (~> 0.3.0) 86 | rexml (~> 3.2.4) 87 | zeitwerk (2.6.6) 88 | 89 | GEM 90 | remote: https://rubygems.org/ 91 | specs: 92 | 93 | PLATFORMS 94 | arm64-darwin-22 95 | 96 | DEPENDENCIES 97 | cocoapods (= 1.11.3)! 98 | 99 | BUNDLED WITH 100 | 2.4.6 101 | -------------------------------------------------------------------------------- /Sources/PayKit/Services/Networking/RESTService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RESTService.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import Foundation 18 | 19 | protocol RESTService { 20 | func execute( 21 | request: URLRequest, 22 | retryPolicy: RetryPolicy?, 23 | completion: @escaping ((Data?, URLResponse?, Error?) -> Void) 24 | ) 25 | } 26 | 27 | final class ResilientRESTService: RESTService { 28 | 29 | // MARK: - Properties 30 | 31 | private var urlSession: URLSession 32 | 33 | // MARK: - Lifecycle 34 | 35 | init(urlSession: URLSession = .shared) { 36 | self.urlSession = urlSession 37 | } 38 | 39 | // MARK: - RESTService 40 | 41 | func execute( 42 | request: URLRequest, 43 | retryPolicy: RetryPolicy?, 44 | completion: @escaping ((Data?, URLResponse?, Error?) -> Void) 45 | ) { 46 | let request = HTTPRequest(urlRequest: request, retryPolicy: retryPolicy, handler: completion) 47 | performRequest(request: request) 48 | } 49 | 50 | // MARK: Private 51 | 52 | private func performRequest(request: HTTPRequest) { 53 | let task = urlSession.dataTask(with: request.urlRequest) { [weak self] data, response, error in 54 | self?.handleResponse(for: request, data: data, response: response, error: error) 55 | } 56 | task.resume() 57 | } 58 | 59 | private func handleResponse(for request: HTTPRequest, data: Data?, response: URLResponse?, error: Error?) { 60 | // Retry transport errors only. 61 | let isSuccessful = (data != nil) && (error == nil) && (response as? HTTPURLResponse != nil) 62 | guard !isSuccessful, let retryLockout = request.retryPolicy?.lockout else { 63 | // Complete the request. 64 | request.handler(data, response, error) 65 | return 66 | } 67 | retry(request: request, delay: retryLockout) 68 | } 69 | 70 | private func retry(request: HTTPRequest, delay: TimeInterval) { 71 | let retryRequest = HTTPRequest( 72 | urlRequest: request.urlRequest, 73 | retryPolicy: request.retryPolicy?.decrement(), 74 | handler: request.handler 75 | ) 76 | DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in 77 | self?.performRequest(request: retryRequest) 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Sources/PayKit/Services/Networking/UserAgent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserAgent.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import UIKit 18 | 19 | /// A utility struct used in generating a user agent string for the customer's device. 20 | /// An example is 21 | /// "Mozilla/5.0 (iPhone7,2; CPU iPhone OS 10.0 like Mac OS X; en-us) PayKitVersion/2.1.5 com.squareup.PayKitDemo/1". 22 | enum UserAgent { 23 | // MARK: - Private Static Properties 24 | 25 | static let unknownValue = "unknown" 26 | 27 | // MARK: - Public Static Properties 28 | 29 | static let userAgent: String = { 30 | func infoDictionaryString(forKey key: String) -> String { 31 | let bundleValue = Bundle.main.object(forInfoDictionaryKey: key) as? String 32 | return bundleValue ?? unknownValue 33 | } 34 | let appIdentifier = infoDictionaryString(forKey: kCFBundleIdentifierKey as String) 35 | let appVersion = infoDictionaryString(forKey: kCFBundleVersionKey as String) 36 | var payKitVersion = CashAppPay.version 37 | 38 | let model = UIDevice.current.deviceModel ?? unknownValue 39 | let language = Locale.current.languageCode?.lowercased() ?? unknownValue 40 | let country = Locale.current.regionCode?.lowercased() ?? unknownValue 41 | let osVersion = UIDevice.current.systemVersion 42 | 43 | return String( 44 | format: "Mozilla/5.0 (%@; CPU iPhone OS %@ like Mac OS X; %@-%@) PayKitVersion/%@ %@/%@", 45 | model, 46 | osVersion, 47 | language, 48 | country, 49 | payKitVersion, 50 | appIdentifier, 51 | appVersion 52 | ) 53 | }() 54 | } 55 | 56 | // MARK: - UIDevice 57 | 58 | extension UIDevice { 59 | // Returns the human readable version of the device being used 60 | var deviceModel: String? { 61 | if let sim = ProcessInfo().environment["SIMULATOR_MODEL_IDENTIFIER"] { 62 | return "Simulator(\(sim))" 63 | } else { 64 | var systemInfo = utsname() 65 | uname(&systemInfo) 66 | let modelCode = withUnsafePointer(to: &systemInfo.machine) { 67 | $0.withMemoryRebound(to: CChar.self, capacity: 1) { ptr in 68 | String(validatingUTF8: ptr) 69 | } 70 | } 71 | return modelCode 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Tests/PayKitTests/UpdateCustomerRequestParamsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UpdateCustomerRequestParamsTests.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | @testable import PayKit 18 | import XCTest 19 | 20 | class UpdateCustomerRequestParamsTests: XCTestCase { 21 | 22 | private lazy var jsonEncoder: JSONEncoder = .payKitEncoder() 23 | private lazy var jsonDecoder: JSONDecoder = .payKitDecoder() 24 | 25 | func test_serializeUpdateParams_clearAllButActions() throws { 26 | let params = TestValues.updateCustomerRequestParams_clearAllButActions 27 | 28 | // Try serializing the params to JSON. 29 | let serializedParams = try jsonEncoder.encode(UpdateCustomerRequestParamsWrapper(params)) 30 | // Load the param fixture from file. 31 | let fixtureParams = try fixtureDataForFilename(clearAllButActionsFilename) 32 | 33 | // The wrappers won't match because they have different idempotency keys, but the underlying requests 34 | // should be identical. 35 | let serializedDict = try XCTUnwrap( 36 | JSONSerialization.jsonObject(with: serializedParams) as? [String: AnyHashable] 37 | ) 38 | let fixtureDict = try XCTUnwrap( 39 | JSONSerialization.jsonObject(with: fixtureParams) as? [String: AnyHashable] 40 | ) 41 | XCTAssertEqual(serializedDict["request"]!, fixtureDict["request"]!) 42 | } 43 | 44 | func test_serializeUpdateParams_clearAmount() throws { 45 | let params = TestValues.updateCustomerRequestParams_clearAmount 46 | 47 | // Try serializing the params to JSON. 48 | let serializedParams = try jsonEncoder.encode(UpdateCustomerRequestParamsWrapper(params)) 49 | // Load the param fixture from file. 50 | let fixtureParams = try fixtureDataForFilename(clearAmountFilename) 51 | 52 | // The wrappers won't match because they have different idempotency keys, but the underlying requests 53 | // should be identical. 54 | let serializedDict = try XCTUnwrap( 55 | JSONSerialization.jsonObject(with: serializedParams) as? [String: AnyHashable] 56 | ) 57 | let fixtureDict = try XCTUnwrap( 58 | JSONSerialization.jsonObject(with: fixtureParams) as? [String: AnyHashable] 59 | ) 60 | XCTAssertEqual(serializedDict["request"]!, fixtureDict["request"]!) 61 | } 62 | 63 | let clearAllButActionsFilename = "updateRequestParams-clearAllButActions" 64 | let clearAmountFilename = "updateRequestParams-clearAmountFromOneTime" 65 | } 66 | -------------------------------------------------------------------------------- /Tests/PayKitTests/CreateCustomerRequestParamsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CreateCustomerRequestParamsTests.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | @testable import PayKit 18 | import XCTest 19 | 20 | class CreateCustomerRequestParamsTests: XCTestCase { 21 | 22 | private lazy var jsonEncoder: JSONEncoder = .payKitEncoder() 23 | private lazy var jsonDecoder: JSONDecoder = .payKitDecoder() 24 | 25 | func test_constructor_defaults_to_in_app_channel() throws { 26 | let url = try XCTUnwrap(URL(string: "https://block.xyz/")) 27 | let params = CreateCustomerRequestParams(actions: [], redirectURL: url, referenceID: nil, metadata: nil) 28 | XCTAssertEqual(params.channel, .IN_APP) 29 | } 30 | 31 | func test_serializeCreateParams_fullyPopulated() throws { 32 | let params = TestValues.createCustomerRequestParams 33 | 34 | // Try serializing the params to JSON. 35 | let serializedParams = try jsonEncoder.encode(CreateCustomerRequestParamsWrapper(params)) 36 | 37 | // Try loading the param fixture from file. 38 | let fixtureParams = try fixtureDataForFilename(createRequestParamsFilename) 39 | 40 | // The wrappers won't match because they have different idempotency keys, but the underlying requests 41 | // should be identical. 42 | let serializedDict = try XCTUnwrap(JSONSerialization.jsonObject(with: serializedParams) as? [String: Any]) 43 | let fixtureDict = try XCTUnwrap(JSONSerialization.jsonObject(with: fixtureParams) as? [String: Any]) 44 | XCTAssertNotNil(serializedDict["request"]) 45 | XCTAssertNotNil(fixtureDict["request"]) 46 | XCTAssertEqual( 47 | try XCTUnwrap(JSONSerialization.data(withJSONObject: serializedDict["request"]!, options: .sortedKeys)), 48 | try XCTUnwrap(JSONSerialization.data(withJSONObject: fixtureDict["request"]!, options: .sortedKeys)) 49 | ) 50 | } 51 | 52 | func test_deserializeCreateParams_fullyPopulated() throws { 53 | // Load the param fixture from file. 54 | let fixtureParams = try fixtureDataForFilename(createRequestParamsFilename) 55 | 56 | // Try deserializing the params to a struct. 57 | let deserializedParams = try jsonDecoder.decode(CreateCustomerRequestParamsWrapper.self, from: fixtureParams) 58 | 59 | let params = TestValues.createCustomerRequestParams 60 | XCTAssertEqual(deserializedParams.request, params) 61 | } 62 | 63 | let createRequestParamsFilename = "createRequestParams-fullyPopulated" 64 | } 65 | -------------------------------------------------------------------------------- /Demo/PayKitDemo.xcodeproj/xcshareddata/xcschemes/PayKitDemo.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /Sources/PayKitUI/UIKit/CashAppPayButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CashAppPayButton.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import SwiftUI 18 | 19 | @available(iOS 13.0, *) 20 | public class CashAppPayButton: UIView { 21 | 22 | // MARK: - Public Properties 23 | 24 | /// View size of the view 25 | public var size: SizingCategory { 26 | get { cashAppButton.viewModel.size } 27 | set { cashAppButton.viewModel.size = newValue } 28 | } 29 | 30 | /// True if the button is enabled 31 | public var isEnabled: Bool { 32 | get { cashAppButton.viewModel.isEnabled } 33 | set { cashAppButton.viewModel.isEnabled = newValue } 34 | } 35 | 36 | // MARK: - Private Properties 37 | 38 | private let onClickHandler: () -> Void 39 | private let cashAppButton: CashAppPayButtonView 40 | 41 | // MARK: - Life Cycle 42 | 43 | /** 44 | Initializes a button with the Cash App Logo and name. 45 | 46 | - Parameters: 47 | - size: The size of the button. Defaults to `large`. 48 | - onClickHandler: The handler called when the button is tapped. 49 | - usePolychromeAsset: Toggle usage of polychrome UI 50 | */ 51 | public init( 52 | size: SizingCategory = .large, 53 | onClickHandler: @escaping () -> Void, 54 | usePolychromeAsset: Bool = false 55 | ) { 56 | self.cashAppButton = CashAppPayButtonView( 57 | size: size, 58 | onClickHandler: onClickHandler, 59 | usePolychromeAsset: usePolychromeAsset 60 | ) 61 | self.onClickHandler = onClickHandler 62 | super.init(frame: .zero) 63 | guard let view = makeView() else { return } 64 | addSubview(view) 65 | NSLayoutConstraint.activate([ 66 | view.leadingAnchor.constraint(equalTo: leadingAnchor), 67 | view.trailingAnchor.constraint(equalTo: trailingAnchor), 68 | view.topAnchor.constraint(equalTo: topAnchor), 69 | view.bottomAnchor.constraint(equalTo: bottomAnchor), 70 | ]) 71 | } 72 | 73 | @available(*, unavailable) 74 | required init?(coder: NSCoder) { 75 | fatalError("init(coder:) has not been implemented") 76 | } 77 | } 78 | 79 | // MARK: - View Building 80 | 81 | @available(iOS 13.0, *) 82 | private extension CashAppPayButton { 83 | private func makeView() -> UIView? { 84 | guard let view = UIHostingController(rootView: cashAppButton).view else { 85 | return nil 86 | } 87 | view.backgroundColor = .clear 88 | view.translatesAutoresizingMaskIntoConstraints = false 89 | return view 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Tests/PayKitTests/CustomerRequestTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomerRequestTests.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | @testable import PayKit 18 | import XCTest 19 | 20 | class CustomerRequestTests: XCTestCase { 21 | 22 | private lazy var jsonEncoder: JSONEncoder = .payKitEncoder() 23 | private lazy var jsonDecoder: JSONDecoder = .payKitDecoder() 24 | 25 | func test_deserializePendingRequest_fullyPopulated() throws { 26 | let json = try fixtureDataForFilename(pendingRequestFilename) 27 | let requestWrapper = try jsonDecoder.decode(CustomerRequestWrapper.self, from: json) 28 | XCTAssertEqual(requestWrapper.request, TestValues.fullyPopulatedPendingRequest) 29 | } 30 | 31 | func test_serializePendingRequest_fullyPopulated() throws { 32 | let serializedRequest = try jsonEncoder.encode( 33 | CustomerRequestWrapper( 34 | request: TestValues.fullyPopulatedPendingRequest 35 | ) 36 | ) 37 | let fixtureJSON = try fixtureDataForFilename(pendingRequestFilename) 38 | 39 | // The JSON data is unordered, so convert it to a dictionary to compare. 40 | let fixtureDict = try XCTUnwrap(JSONSerialization.jsonObject(with: fixtureJSON) as? [String: AnyHashable]) 41 | let serializedDict = try XCTUnwrap( 42 | JSONSerialization.jsonObject(with: serializedRequest) as? [String: AnyHashable] 43 | ) 44 | XCTAssertEqual(fixtureDict, serializedDict) 45 | } 46 | 47 | func test_deserializeApprovedRequest_fullyPopulated() throws { 48 | let json = try fixtureDataForFilename(approvedRequestFilename) 49 | let requestWrapper = try jsonDecoder.decode(CustomerRequestWrapper.self, from: json) 50 | XCTAssertEqual(requestWrapper.request, TestValues.fullyPopulatedApprovedRequest) 51 | } 52 | 53 | func test_serializeApprovedRequest_fullyPopulated() throws { 54 | let serializedRequest = try jsonEncoder.encode( 55 | CustomerRequestWrapper( 56 | request: TestValues.fullyPopulatedApprovedRequest 57 | ) 58 | ) 59 | let fixtureJSON = try fixtureDataForFilename(approvedRequestFilename) 60 | 61 | // The JSON data is unordered, so convert it to a dictionary to compare. 62 | let fixtureDict = try XCTUnwrap(JSONSerialization.jsonObject(with: fixtureJSON) as? [String: AnyHashable]) 63 | let serializedDict = try XCTUnwrap( 64 | JSONSerialization.jsonObject(with: serializedRequest) as? [String: AnyHashable] 65 | ) 66 | XCTAssertEqual(fixtureDict, serializedDict) 67 | } 68 | 69 | let pendingRequestFilename = "pendingRequest-fullyPopulated" 70 | let approvedRequestFilename = "approvedRequest-fullyPopulated" 71 | } 72 | -------------------------------------------------------------------------------- /Sources/PayKitUI/UIKit/CashAppPaymentMethod.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CashAppPaymentMethod.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import SwiftUI 18 | 19 | @available(iOS 13.0, *) 20 | public class CashAppPaymentMethod: UIView { 21 | 22 | // MARK: - Private Properties 23 | 24 | private let paymentMethodView: CashAppPaymentMethodView 25 | 26 | // MARK: - Public Properties 27 | 28 | /// View size of the view 29 | public var size: SizingCategory { 30 | get { paymentMethodView.viewModel.size } 31 | set { paymentMethodView.viewModel.size = newValue } 32 | } 33 | 34 | /// Cash Tag representing the customer. 35 | public var cashTag: String { 36 | get { paymentMethodView.viewModel.cashTag } 37 | set { paymentMethodView.viewModel.cashTag = newValue } 38 | } 39 | 40 | // MARK: - Lifecycle 41 | 42 | /** 43 | Initializes a view with the Cash App logo on the left and the Cash Tag 44 | representing the customer on the right or the bottom. 45 | 46 | - Parameters: 47 | - size: The size of the view where the `small` is vertically stacked while `large` is horizontally stacked. 48 | Defaults to `large`. 49 | - cashTag: The Customer ID. Defaults to `nil`. 50 | - usePolychromeAsset: Toggle usage of polychrome UI 51 | */ 52 | public init(size: SizingCategory = .large, cashTag: String = "", usePolychromeAsset: Bool = false) { 53 | self.paymentMethodView = CashAppPaymentMethodView( 54 | size: size, 55 | cashTag: cashTag, 56 | usePolychromeAsset: usePolychromeAsset 57 | ) 58 | super.init(frame: .zero) 59 | guard let view = makeView() else { return } 60 | addSubview(view) 61 | NSLayoutConstraint.activate([ 62 | view.leadingAnchor.constraint(equalTo: leadingAnchor), 63 | view.trailingAnchor.constraint(equalTo: trailingAnchor), 64 | view.topAnchor.constraint(equalTo: topAnchor), 65 | view.bottomAnchor.constraint(equalTo: bottomAnchor), 66 | ]) 67 | } 68 | 69 | @available(*, unavailable) 70 | required init?(coder: NSCoder) { 71 | fatalError("init(coder:) has not been implemented") 72 | } 73 | 74 | /// Set the Cash Tag text color 75 | public func setCashTagTextColor(_ color: UIColor) { 76 | guard let uiColor = color.swiftUIColor else { return } 77 | paymentMethodView.viewModel.cashTagTextColor = uiColor 78 | } 79 | 80 | /// Set the Cash Tag font 81 | public func setCashTagFont(_ font: UIFont) { 82 | paymentMethodView.viewModel.cashTagFont = Font(font) 83 | } 84 | } 85 | 86 | // MARK: - View Building 87 | 88 | @available(iOS 13.0, *) 89 | private extension CashAppPaymentMethod { 90 | private func makeView() -> UIView? { 91 | guard let view = UIHostingController(rootView: paymentMethodView).view else { 92 | return nil 93 | } 94 | view.layer.cornerRadius = CashAppPaymentMethodView.Constants.cornerRadius 95 | view.translatesAutoresizingMaskIntoConstraints = false 96 | return view 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Tests/PayKitTests/IntegrationErrorTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IntegrationErrorTests.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | @testable import PayKit 18 | import XCTest 19 | 20 | class IntegrationErrorTests: XCTestCase { 21 | 22 | private lazy var jsonEncoder: JSONEncoder = .payKitEncoder() 23 | private lazy var jsonDecoder: JSONDecoder = .payKitDecoder() 24 | 25 | func test_serialize_unauthorizedError() throws { 26 | // Try serializing the error to JSON. 27 | let serializedError = try jsonEncoder.encode(IntegrationErrorWrapper(errors: [TestValues.unauthorizedError])) 28 | 29 | // Try loading the fixture JSON from file. 30 | let fixtureError = try fixtureDataForFilename(unauthorizedFilename, in: .errors) 31 | 32 | // The JSON data is unordered, so convert it to a dictionary to compare. 33 | let fixtureDict = try XCTUnwrap(JSONSerialization.jsonObject(with: fixtureError) as? [String: AnyHashable]) 34 | let serializedDict = try XCTUnwrap( 35 | JSONSerialization.jsonObject(with: serializedError) as? [String: AnyHashable] 36 | ) 37 | XCTAssertEqual(fixtureDict, serializedDict) 38 | } 39 | 40 | func test_deserialize_unauthorizedError() throws { 41 | let fixtureJSON = try fixtureDataForFilename(unauthorizedFilename, in: .errors) 42 | let errorWrapper = try jsonDecoder.decode(IntegrationErrorWrapper.self, from: fixtureJSON) 43 | XCTAssertEqual(errorWrapper.errors.first, TestValues.unauthorizedError) 44 | } 45 | 46 | func test_serialize_brandNotFoundError() throws { 47 | // Try serializing the error to JSON. 48 | let serializedError = try jsonEncoder.encode(IntegrationErrorWrapper(errors: [TestValues.brandNotFoundError])) 49 | 50 | // Try loading the fixture JSON from file. 51 | let fixtureError = try fixtureDataForFilename(brandNotFoundFilename, in: .errors) 52 | 53 | // The JSON data is unordered, so convert it to a dictionary to compare. 54 | let fixtureDict = try XCTUnwrap( 55 | JSONSerialization.jsonObject(with: fixtureError) as? [String: AnyHashable] 56 | ) 57 | let serializedDict = try XCTUnwrap( 58 | JSONSerialization.jsonObject(with: serializedError) as? [String: AnyHashable] 59 | ) 60 | XCTAssertEqual(fixtureDict, serializedDict) 61 | } 62 | 63 | func test_deserialize_brandNotFoundError() throws { 64 | let fixtureJSON = try fixtureDataForFilename(brandNotFoundFilename, in: .errors) 65 | let errorWrapper = try jsonDecoder.decode(IntegrationErrorWrapper.self, from: fixtureJSON) 66 | XCTAssertEqual(errorWrapper.errors.first, TestValues.brandNotFoundError) 67 | } 68 | 69 | func test_deserialize_unexpectedError() throws { 70 | // IDEMPOTENCY_KEY_REUSED is an unexpected error, so deserialization should fail. 71 | let fixtureJSON = try fixtureDataForFilename(idempotencyKeyReusedFilename, in: .errors) 72 | XCTAssertThrowsError(try jsonDecoder.decode(IntegrationErrorWrapper.self, from: fixtureJSON)) { error in 73 | XCTAssert(error is Swift.DecodingError) 74 | } 75 | } 76 | 77 | let unauthorizedFilename = "unauthorized" 78 | let brandNotFoundFilename = "brandNotFound" 79 | let idempotencyKeyReusedFilename = "idempotencyKeyReused" 80 | } 81 | -------------------------------------------------------------------------------- /Tests/PayKitUITests/BaseSnapshotTestCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BaseSnapshotTestCase.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import SnapshotTesting 18 | import SwiftUI 19 | import XCTest 20 | 21 | class BaseSnapshotTestCase: XCTestCase { 22 | override func setUp() { 23 | super.setUp() 24 | isRecording = false 25 | } 26 | } 27 | 28 | // MARK: - SnapshotTesting Extensions 29 | 30 | private let lowPrecision: Float = 0.95 31 | private let viewBackground: UIColor = .darkGray 32 | 33 | @available(iOS 13.0, *) 34 | extension Snapshotting where Value: UIView, Format == UIImage { 35 | public static func image( 36 | filling config: ViewImageConfig, 37 | userInterfaceStyle: UIUserInterfaceStyle = .light 38 | ) -> Snapshotting { 39 | Snapshotting.image( 40 | on: config, 41 | precision: lowPrecision, 42 | traits: UITraitCollection(userInterfaceStyle: userInterfaceStyle) 43 | ).pullback { view in 44 | let controller = UIViewController() 45 | controller.view.backgroundColor = .gray 46 | controller.view.addSubview(view) 47 | view.translatesAutoresizingMaskIntoConstraints = false 48 | NSLayoutConstraint.activate([ 49 | view.leadingAnchor.constraint(equalTo: controller.view.leadingAnchor), 50 | view.trailingAnchor.constraint(equalTo: controller.view.trailingAnchor), 51 | view.topAnchor.constraint(equalTo: controller.view.topAnchor), 52 | view.bottomAnchor.constraint(equalTo: controller.view.bottomAnchor), 53 | ]) 54 | controller.view.frame = UIScreen.main.bounds 55 | return controller 56 | } 57 | } 58 | 59 | public static func image( 60 | centeredIn config: ViewImageConfig, 61 | userInterfaceStyle: UIUserInterfaceStyle = .light 62 | ) -> Snapshotting { 63 | Snapshotting.image( 64 | on: config, 65 | precision: lowPrecision, 66 | traits: UITraitCollection(userInterfaceStyle: userInterfaceStyle) 67 | ).pullback { view in 68 | let controller = UIViewController() 69 | controller.view.backgroundColor = .gray 70 | controller.view.addSubview(view) 71 | view.translatesAutoresizingMaskIntoConstraints = false 72 | NSLayoutConstraint.activate([ 73 | view.centerXAnchor.constraint(equalTo: controller.view.centerXAnchor), 74 | view.centerYAnchor.constraint(equalTo: controller.view.centerYAnchor), 75 | ]) 76 | controller.view.frame = UIScreen.main.bounds 77 | return controller 78 | } 79 | } 80 | } 81 | 82 | @available(iOS 13.0, *) 83 | extension Snapshotting where Value: View, Format == UIImage { 84 | public static func image( 85 | on config: ViewImageConfig, 86 | userInterfaceStyle: UIUserInterfaceStyle = .light 87 | ) -> Snapshotting { 88 | Snapshotting.image( 89 | on: config, 90 | precision: lowPrecision, 91 | traits: UITraitCollection(userInterfaceStyle: userInterfaceStyle) 92 | ).pullback { view in 93 | let controller = UIHostingController.init(rootView: view) 94 | controller.view.backgroundColor = .darkGray 95 | controller.view.frame = UIScreen.main.bounds 96 | return controller 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Sources/PayKit/Services/Analytics/AnalyticsClient.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnalyticsClient.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import Foundation 18 | 19 | class AnalyticsClient { 20 | 21 | // MARK: - Properties 22 | 23 | private let restService: RESTService 24 | 25 | private let apiPath = "2.0/log/eventstream" 26 | private var baseURL: URL { 27 | URL(string: apiPath, relativeTo: endpoint.baseURL)! 28 | } 29 | 30 | private var requestHeaders: [String: String] { 31 | [ 32 | "Accept": "application/json", 33 | "Content-Type": "application/json", 34 | ] 35 | } 36 | 37 | private let encoder: JSONEncoder = .eventStream2Encoder() 38 | 39 | // MARK: - Public Properties 40 | 41 | private let endpoint: Endpoint 42 | 43 | // MARK: - Lifecycle 44 | 45 | init(restService: RESTService, endpoint: Endpoint) { 46 | self.restService = restService 47 | self.endpoint = endpoint 48 | } 49 | 50 | // MARK: - Public 51 | 52 | func upload( 53 | appName: String, 54 | events: [AnalyticsEvent], 55 | completion: @escaping (Result) -> Void 56 | ) { 57 | let params = events.map { EventStream2Params(appName: appName, event: $0) } 58 | let eventsCollection = EventStream2CollectionParams(events: params) 59 | 60 | let jsonParams: Data 61 | do { 62 | jsonParams = try encoder.encode(eventsCollection) 63 | } catch { 64 | DispatchQueue.main.async { 65 | completion(.failure(DebugError.parseError(events))) 66 | } 67 | return 68 | } 69 | 70 | var request = URLRequest(url: baseURL, cachePolicy: .useProtocolCachePolicy, timeoutInterval: 5.0) 71 | request.httpMethod = "POST" 72 | request.allHTTPHeaderFields = requestHeaders 73 | request.httpBody = jsonParams 74 | 75 | restService.execute( 76 | request: request, 77 | retryPolicy: .exponential(maximumNumberOfAttempts: 5) 78 | ) { _, _, error in 79 | if let error { 80 | Log.write("Failed to upload analytics events with error \(error)") 81 | } 82 | DispatchQueue.main.async { 83 | completion(.success(())) 84 | } 85 | } 86 | } 87 | } 88 | 89 | // MARK: - Endpoint 90 | 91 | extension AnalyticsClient { 92 | enum Endpoint { 93 | case production 94 | case staging 95 | 96 | var baseURL: URL { 97 | switch self { 98 | case .production: 99 | return URL(string: "https://api.squareup.com/")! 100 | case .staging: 101 | return URL(string: "https://api.squareupstaging.com/")! 102 | } 103 | } 104 | } 105 | } 106 | 107 | // MARK: - Request Models 108 | 109 | struct EventStream2CollectionParams: Encodable { 110 | let events: [EventStream2Params] 111 | } 112 | 113 | struct EventStream2Params: Encodable { 114 | let appName: String 115 | let event: AnalyticsEvent 116 | 117 | init(appName: String, event: AnalyticsEvent) { 118 | self.appName = appName 119 | self.event = event 120 | } 121 | 122 | enum CodingKeys: CodingKey { 123 | case appName 124 | case catalogName 125 | case recordedAtUsec 126 | case uuid 127 | case jsonData 128 | } 129 | 130 | func encode(to encoder: Encoder) throws { 131 | var container = encoder.container(keyedBy: CodingKeys.self) 132 | try container.encode(appName, forKey: .appName) 133 | try container.encode(event.catalog, forKey: .catalogName) 134 | try container.encode(event.timestamp, forKey: .recordedAtUsec) 135 | try container.encode(event.id.uuidString, forKey: .uuid) 136 | try container.encode(event.jsonString(), forKey: .jsonData) 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /Sources/PayKit/Errors.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Errors.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import Foundation 18 | 19 | public struct APIError: Error, Codable, Equatable { 20 | let category: Category 21 | let code: ErrorCode 22 | let detail: String? 23 | let field: String? 24 | 25 | enum Category: String, Codable, Equatable { 26 | case API_ERROR 27 | } 28 | 29 | enum ErrorCode: String, Codable, Equatable { 30 | case INTERNAL_SERVER_ERROR 31 | case SERVICE_UNAVAILABLE 32 | case GATEWAY_TIMEOUT 33 | } 34 | } 35 | 36 | public struct IntegrationError: Error, Codable, Equatable { 37 | let category: Category 38 | let code: ErrorCode 39 | let detail: String? 40 | let field: String? 41 | 42 | enum Category: String, Codable, Equatable { 43 | case AUTHENTICATION_ERROR 44 | case BRAND_ERROR 45 | case MERCHANT_ERROR 46 | case INVALID_REQUEST_ERROR 47 | case RATE_LIMIT_ERROR 48 | } 49 | 50 | enum ErrorCode: String, Codable, Equatable { 51 | 52 | // Authentication Errors 53 | case UNAUTHORIZED 54 | case CLIENT_DISABLED 55 | case FORBIDDEN 56 | 57 | // Invalid Request Errors 58 | case VALUE_TOO_LONG 59 | case VALUE_TOO_SHORT 60 | case VALUE_EMPTY 61 | case VALUE_REGEX_MISMATCH 62 | case INVALID_URL 63 | case VALUE_TOO_HIGH 64 | case VALUE_TOO_LOW 65 | case ARRAY_LENGTH_TOO_LONG 66 | case ARRAY_LENGTH_TOO_SHORT 67 | case INVALID_ARRAY_TYPE 68 | case NOT_FOUND 69 | case CONFLICT 70 | case INVALID_STATE_TRANSITION 71 | case CLIENT_NOT_FOUND 72 | 73 | // Rate Limit Errors 74 | case RATE_LIMITED 75 | 76 | // Brand Errors 77 | case BRAND_NOT_FOUND 78 | 79 | // Merchant Errors 80 | case MERCHANT_MISSING_ADDRESS_OR_SITE 81 | } 82 | 83 | static var terminalStateError: IntegrationError { 84 | IntegrationError( 85 | category: .INVALID_REQUEST_ERROR, 86 | code: .INVALID_STATE_TRANSITION, 87 | detail: "The request provided was already in a terminal state.", 88 | field: nil 89 | ) 90 | } 91 | } 92 | 93 | public struct UnexpectedError: Error, Codable, Equatable { 94 | let category: String 95 | let code: String 96 | let detail: String? 97 | let field: String? 98 | 99 | static var emptyErrorArray: UnexpectedError { 100 | return UnexpectedError( 101 | category: "API_ERROR", 102 | code: "EMPTY_ERROR_ARRAY", 103 | detail: "The API returned an error, but the `errors` array was empty. " + 104 | "Please report this bug to Cash App Developer Support.", 105 | field: "errors" 106 | ) 107 | } 108 | 109 | static func noRedirectURLFor(_ customerRequest: CustomerRequest) -> UnexpectedError { 110 | return UnexpectedError( 111 | category: "API_ERROR", 112 | code: "NO_REDIRECT_URL", 113 | detail: "The API returned a customer request without a `mobileURL` field to use for redirecting. " + 114 | "Customer request ID: \(customerRequest.id). Please report this bug to Cash App Developer Support.", 115 | field: "auth_flow_triggers.mobile_url" 116 | ) 117 | } 118 | 119 | static func unknownErrorFor(_ error: Error) -> UnexpectedError { 120 | return UnexpectedError( 121 | category: "UNKNOWN_ERROR", 122 | code: "UNKNOWN_ERROR", 123 | detail: "Received an Error in an unexpected form: \(String(describing: error))", 124 | field: nil 125 | ) 126 | } 127 | } 128 | 129 | public enum NetworkError: Error, Equatable { 130 | case noResponse 131 | case nilData(HTTPURLResponse) 132 | case invalidJSON(Data) 133 | case systemError(NSError) 134 | } 135 | 136 | // MARK: - Wrappers for serialization 137 | struct APIErrorWrapper: Codable, Equatable { 138 | let errors: [APIError] 139 | } 140 | 141 | struct IntegrationErrorWrapper: Codable, Equatable { 142 | let errors: [IntegrationError] 143 | } 144 | 145 | struct UnexpectedErrorWrapper: Codable, Equatable { 146 | let errors: [UnexpectedError] 147 | } 148 | -------------------------------------------------------------------------------- /Tests/PayKitTests/ResilientRestServiceTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ResilientRESTServiceTests.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | @testable import PayKit 18 | import XCTest 19 | 20 | final class ResilientRESTServiceTests: XCTestCase { 21 | 22 | private var url: URL! 23 | 24 | override func setUp() { 25 | super.setUp() 26 | url = URL(string: "https://api.cash.app/")! 27 | } 28 | 29 | override func tearDown() { 30 | url = nil 31 | super.tearDown() 32 | } 33 | 34 | func test_execute_performs_request_and_calls_handler() { 35 | let requestExpectation = expectation(description: "Executed request") 36 | let mock = MockURLSession { request in 37 | self.XCTAssertEqual(request.url, self.url) 38 | requestExpectation.fulfill() 39 | return (Data(), HTTPURLResponse(), nil) 40 | } 41 | 42 | let handlerExpectation = expectation(description: "Handler called") 43 | let service = ResilientRESTService(urlSession: mock.session) 44 | service.execute(request: URLRequest(url: url), retryPolicy: nil) { data, _, _ in 45 | XCTAssertNotNil(data) 46 | handlerExpectation.fulfill() 47 | } 48 | waitForExpectations(timeout: 0.2) 49 | } 50 | 51 | func test_failure_without_retry_calls_handler() { 52 | let requestExpectation = expectation(description: "Executed request") 53 | let mock = MockURLSession { request in 54 | self.XCTAssertEqual(request.url, self.url) 55 | requestExpectation.fulfill() 56 | return (nil, nil, NSError(domain: "", code: 5)) 57 | } 58 | 59 | let handlerExpectation = expectation(description: "Handler called") 60 | let service = ResilientRESTService(urlSession: mock.session) 61 | service.execute(request: URLRequest(url: url), retryPolicy: nil) { _, _, error in 62 | self.XCTAssertEqual((error as? NSError)?.code, 5) 63 | handlerExpectation.fulfill() 64 | } 65 | waitForExpectations(timeout: 2) 66 | } 67 | 68 | func test_execute_with_retry_eventually_fails_and_calls_handler() { 69 | let requestExpectation = expectation(description: "Executed request") 70 | requestExpectation.expectedFulfillmentCount = 2 71 | let mock = MockURLSession { request in 72 | self.XCTAssertEqual(request.url, self.url) 73 | requestExpectation.fulfill() 74 | return (nil, nil, NSError(domain: "", code: 5)) 75 | } 76 | 77 | let handlerExpectation = expectation(description: "Handler called") 78 | let service = ResilientRESTService(urlSession: mock.session) 79 | service.execute( 80 | request: URLRequest(url: url), 81 | retryPolicy: .exponential( 82 | delay: 1, 83 | maximumNumberOfAttempts: 1 84 | ) 85 | ) { _, _, error in 86 | self.XCTAssertEqual((error as? NSError)?.code, 5) 87 | handlerExpectation.fulfill() 88 | } 89 | waitForExpectations(timeout: 1) 90 | } 91 | 92 | func test_execute_with_retry_stops_after_success() { 93 | let stubError = NSError(domain: "", code: 5) 94 | let stubResponse = "".data(using: .utf8) 95 | let requestExpectation = expectation(description: "Executed request") 96 | requestExpectation.expectedFulfillmentCount = 2 97 | 98 | var responses: [(Data?, URLResponse?, Error?)] = [(nil, nil, stubError), (stubResponse, HTTPURLResponse(), nil)] 99 | let mock = MockURLSession { request in 100 | self.XCTAssertEqual(request.url, self.url) 101 | requestExpectation.fulfill() 102 | return responses.removeFirst() 103 | } 104 | 105 | let handlerExpectation = expectation(description: "Handler called") 106 | let service = ResilientRESTService(urlSession: mock.session) 107 | service.execute( 108 | request: URLRequest(url: url), 109 | retryPolicy: 110 | .exponential( 111 | delay: 1, 112 | maximumNumberOfAttempts: 10 113 | ) 114 | ) { data, _, _ in 115 | XCTAssertNotNil(data) 116 | handlerExpectation.fulfill() 117 | } 118 | waitForExpectations(timeout: 1) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /Sources/PayKit/Services/Analytics/AnalyticsService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnalyticsService.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import Foundation 18 | import UIKit 19 | 20 | protocol AnalyticsService { 21 | func track(_ event: AnalyticsEvent) 22 | } 23 | 24 | class EventStream2: AnalyticsService { 25 | 26 | // MARK: - Properties 27 | 28 | private let appName: String 29 | private let commonParameters: [String: Loggable] 30 | 31 | private let store: AnalyticsDataSource 32 | private let client: AnalyticsClient 33 | 34 | private let workQueue = OperationQueue() 35 | 36 | private let eventBatchSize: Int 37 | 38 | private var isBatchSizeExceeded: Bool { 39 | store.count >= eventBatchSize 40 | } 41 | 42 | // MARK: - Lifecycle 43 | 44 | /** 45 | - Parameters: 46 | - appName: The appname in EventStream2. 47 | - commonParameters: Parameters to be combined with each event event sending. 48 | - store: The event store used to buffer events before sending. 49 | - client: The networking client used for analytics. 50 | - eventBatchSize: The batch size for events sent to ES2. Default is 5. 51 | - timeInterval: The maximum time interval before events are uploaded. Default is 5 seconds. 52 | - notificationCenter: The Notification Center. 53 | */ 54 | init( 55 | appName: String, 56 | commonParameters: [String: Loggable], 57 | store: AnalyticsDataSource, 58 | client: AnalyticsClient, 59 | eventBatchSize: Int = 5, 60 | timeInterval: TimeInterval = 5, 61 | notificationCenter: NotificationCenter = .default) { 62 | self.appName = appName 63 | self.commonParameters = commonParameters 64 | self.store = store 65 | self.client = client 66 | self.eventBatchSize = eventBatchSize 67 | 68 | notificationCenter.addObserver( 69 | self, 70 | selector: #selector(enqueueUpload), 71 | name: UIApplication.willEnterForegroundNotification, 72 | object: nil 73 | ) 74 | 75 | notificationCenter.addObserver( 76 | self, 77 | selector: #selector(enqueueUpload), 78 | name: UIApplication.didEnterBackgroundNotification, 79 | object: nil 80 | ) 81 | 82 | notificationCenter.addObserver( 83 | self, 84 | selector: #selector(enqueueUpload), 85 | name: UIApplication.willTerminateNotification, 86 | object: nil 87 | ) 88 | 89 | let timer = Timer(timeInterval: timeInterval, repeats: true) { [weak self] _ in 90 | self?.enqueueUpload() 91 | } 92 | 93 | RunLoop.main.add(timer, forMode: .common) 94 | } 95 | 96 | // MARK: - Public 97 | 98 | func track(_ event: AnalyticsEvent) { 99 | event.add(commonFields: commonParameters) 100 | workQueue.addOperation { [weak self] in 101 | guard let self else { return } 102 | self.store.insert(event: event) 103 | if self.isBatchSizeExceeded == true { 104 | self.uploadEvents() 105 | } 106 | } 107 | } 108 | 109 | // MARK: - Private 110 | 111 | @objc 112 | private func enqueueUpload() { 113 | workQueue.addOperation { [weak self] in 114 | self?.uploadEvents() 115 | } 116 | } 117 | 118 | private func uploadEvents() { 119 | precondition(OperationQueue.current == workQueue) 120 | guard store.count > 0 else { return } 121 | let events = store.fetch(limit: eventBatchSize) 122 | store.remove(events.map(\.id)) 123 | client.upload(appName: appName, events: events) { [weak self] _ in 124 | guard let self else { return } 125 | if self.isBatchSizeExceeded == true { 126 | self.enqueueUpload() 127 | } 128 | } 129 | } 130 | } 131 | 132 | extension EventStream2 { 133 | static let appName = "paykitsdk-ios" 134 | 135 | enum CommonFields: String { 136 | case clientID = "client_id" 137 | case platform = "platform" 138 | case sdkVersion = "sdk_version" 139 | case clientUA = "client_ua" 140 | case environment = "environment" 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /Sources/PayKit/ObjcWrapper/ObjCWrapper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ObjCWrapper.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import Foundation 18 | 19 | @objc public protocol CAPCashAppPayObserver: NSObjectProtocol { 20 | func stateDidChange(to state: CAPCashAppPayState) 21 | } 22 | 23 | @objc(CAPCashAppPay) 24 | public final class ObjCWrapper: NSObject { 25 | @objc public static var sdkVersion: String { 26 | CashAppPay.version 27 | } 28 | 29 | @objc public static var RedirectNotification: Notification.Name { 30 | CashAppPay.RedirectNotification 31 | } 32 | 33 | private let cashAppPay: CashAppPay 34 | private var observations = [ObjectIdentifier: Observation]() 35 | 36 | @objc public var endpoint: CAPEndpoint { 37 | cashAppPay.endpoint.capEndpoint 38 | } 39 | 40 | @objc public convenience init(clientID: String, endpoint: CAPEndpoint = .production) { 41 | let cashAppPay = CashAppPay( 42 | clientID: clientID, 43 | endpoint: endpoint.endpoint 44 | ) 45 | self.init(cashAppPay: cashAppPay) 46 | } 47 | 48 | init(cashAppPay: CashAppPay) { 49 | self.cashAppPay = cashAppPay 50 | super.init() 51 | cashAppPay.addObserver(self) 52 | } 53 | 54 | @objc public func retrieveCustomerRequest( 55 | id: String, 56 | completion: @escaping (CAPCustomerRequest?, NSError?) -> Void 57 | ) { 58 | cashAppPay.retrieveCustomerRequest(id: id) { result in 59 | switch result { 60 | case .success(let customerRequest): 61 | let capCustomerRequest = CAPCustomerRequest(customerRequest: customerRequest) 62 | completion(capCustomerRequest, nil) 63 | case .failure(let error): 64 | completion(nil, error.cashAppPayObjCError) 65 | } 66 | } 67 | } 68 | 69 | @objc public func createCustomerRequest( 70 | params: CAPCreateCustomerRequestParams 71 | ) { 72 | cashAppPay.createCustomerRequest(params: params.createCustomerRequestParams) 73 | } 74 | 75 | @objc public func updateCustomerRequest( 76 | _ request: CAPCustomerRequest, 77 | with params: CAPUpdateCustomerRequestParams 78 | ) { 79 | cashAppPay.updateCustomerRequest( 80 | request.customerRequest, 81 | with: params.updateCustomerRequestParams 82 | ) 83 | } 84 | 85 | @objc public func authorizeCustomerRequest( 86 | _ request: CAPCustomerRequest 87 | ) { 88 | cashAppPay.authorizeCustomerRequest(request.customerRequest) 89 | } 90 | } 91 | 92 | // MARK: - Observations 93 | 94 | extension ObjCWrapper { 95 | private struct Observation { 96 | weak var observer: CAPCashAppPayObserver? 97 | } 98 | 99 | @objc public func addObserver(_ observer: CAPCashAppPayObserver) { 100 | let id = ObjectIdentifier(observer) 101 | observations[id] = Observation(observer: observer) 102 | } 103 | 104 | @objc func removeObserver(_ observer: CAPCashAppPayObserver) { 105 | let id = ObjectIdentifier(observer) 106 | observations.removeValue(forKey: id) 107 | } 108 | } 109 | 110 | // MARK: - CashAppPayObserver 111 | 112 | extension ObjCWrapper: CashAppPayObserver { 113 | public func stateDidChange(to state: CashAppPayState) { 114 | for (id, observation) in observations { 115 | // Clean up any observer that is no longer in memory 116 | guard let observer = observation.observer else { 117 | observations.removeValue(forKey: id) 118 | continue 119 | } 120 | observer.stateDidChange(to: state.asCAPCashAppPayState) 121 | } 122 | } 123 | } 124 | 125 | // MARK: - CAPEndpoint 126 | 127 | @objc public enum CAPEndpoint: Int { 128 | case production 129 | case sandbox 130 | case staging 131 | 132 | var endpoint: CashAppPay.Endpoint { 133 | switch self { 134 | case .production: return .production 135 | case .sandbox: return .sandbox 136 | case .staging: return .staging 137 | } 138 | } 139 | } 140 | 141 | private extension CashAppPay.Endpoint { 142 | var capEndpoint: CAPEndpoint { 143 | switch self { 144 | case .production: return .production 145 | case .sandbox: return .sandbox 146 | case .staging: return .staging 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /Tests/PayKitTests/AccessModifierTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccessModifierTests.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import PayKit 18 | import XCTest 19 | 20 | class AccessModifierTests: XCTestCase { 21 | func test_create_customer_request_params() throws { 22 | let url = try XCTUnwrap(URL(string: "https://block.xyz/")) 23 | let request = CreateCustomerRequestParams( 24 | actions: [ 25 | .onFilePayment( 26 | scopeID: "scope", 27 | accountReferenceID: "reference" 28 | ), 29 | ], 30 | channel: .IN_APP, 31 | redirectURL: url, 32 | referenceID: "reference", 33 | metadata: ["meta": "data"] 34 | ) 35 | 36 | XCTAssertEqual(request.actions.count, 1) 37 | XCTAssertEqual(request.channel, .IN_APP) 38 | XCTAssertEqual(request.redirectURL, url) 39 | XCTAssertEqual(request.referenceID, "reference") 40 | XCTAssertEqual(request.metadata, ["meta": "data"]) 41 | } 42 | 43 | func test_update_customer_request_params() { 44 | let request = UpdateCustomerRequestParams( 45 | actions: [ 46 | .onFilePayment( 47 | scopeID: "scope", 48 | accountReferenceID: 49 | "reference" 50 | ), 51 | ], 52 | referenceID: "reference", 53 | metadata: ["meta": "data"] 54 | ) 55 | 56 | XCTAssertEqual(request.actions.count, 1) 57 | XCTAssertEqual(request.referenceID, "reference") 58 | XCTAssertEqual(request.metadata, ["meta": "data"]) 59 | } 60 | 61 | func test_customer_request() { 62 | let request = TestValues.customerRequest 63 | 64 | XCTAssertEqual(request.id, "GRR_mg3saamyqdm29jj9pqjqkedm") 65 | XCTAssertEqual(request.status, .PENDING) 66 | XCTAssertEqual(request.actions.count, 1) 67 | XCTAssertNotNil(request.authFlowTriggers) 68 | XCTAssertEqual(request.redirectURL, URL(string: "paykitdemo://callback")!) 69 | XCTAssertNotNil(request.createdAt) 70 | XCTAssertNotNil(request.updatedAt) 71 | XCTAssertNotNil(request.expiresAt) 72 | XCTAssertEqual(request.channel, .IN_APP) 73 | XCTAssertNotNil(request.origin) 74 | XCTAssertNotNil(request.authFlowTriggers) 75 | XCTAssertEqual(request.referenceID, "refer_to_me") 76 | XCTAssertNotNil(request.requesterProfile) 77 | XCTAssertNil(request.customerProfile) 78 | XCTAssertNotNil(request.metadata) 79 | } 80 | 81 | func test_auth_flow_triggers() throws { 82 | let flowTriggers = try XCTUnwrap(TestValues.customerRequest.authFlowTriggers) 83 | XCTAssertNotNil(flowTriggers.qrCodeImageURL) 84 | XCTAssertNotNil(flowTriggers.qrCodeSVGURL) 85 | XCTAssertNotNil(flowTriggers.mobileURL) 86 | XCTAssertNotNil(flowTriggers.refreshesAt) 87 | } 88 | 89 | func test_origin() throws { 90 | let origin = try XCTUnwrap(TestValues.customerRequest.origin) 91 | XCTAssertNil(origin.id) 92 | XCTAssertEqual(origin.type, .DIRECT) 93 | } 94 | 95 | func test_grants() throws { 96 | let grant = try XCTUnwrap(TestValues.approvedRequestGrants.first) 97 | 98 | XCTAssertEqual( 99 | grant.id, 100 | "GRG_AZYyHv2DwQltw0SiCLTaRb73y40XFe" 101 | + "2dWM690WDF9Btqn-uTCYAUROa4ciwCdDnZcG4PuY1m_i3gwHODiO8D" 102 | + "Sf9zdMmRl1T0SM267vzuldnBs246-duHZhcehhXtmhfU8g" 103 | ) 104 | XCTAssertEqual(grant.customerID, "CST_AYVkuLw-sT3OKZ7a_nhNTC_L2ekahLgGrS-EM_QhW4OTrGMbi59X1eCclH0cjaxoLObc") 105 | XCTAssertEqual( 106 | grant.action, 107 | .onFilePayment(scopeID: "BRAND_9kx6p0mkuo97jnl025q9ni94t", accountReferenceID: "account4") 108 | ) 109 | } 110 | 111 | func test_payment_action() { 112 | let onfilePayment = PaymentAction.onFilePayment(scopeID: "scope", accountReferenceID: "reference") 113 | XCTAssertEqual(onfilePayment.scopeID, "scope") 114 | XCTAssertNil(onfilePayment.money) 115 | XCTAssertEqual(onfilePayment.type, .ON_FILE_PAYMENT) 116 | XCTAssertEqual(onfilePayment.accountReferenceID, "reference") 117 | } 118 | 119 | func test_money() { 120 | let money = Money(amount: 100, currency: .USD) 121 | XCTAssertEqual(money.amount, 100) 122 | XCTAssertEqual(money.currency, .USD) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Demo/PayKitDemo/ComponentsViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ComponentsViewController.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import Foundation 18 | import PayKitUI 19 | import UIKit 20 | 21 | class ComponentsViewController: UITableViewController { 22 | override func viewDidLoad() { 23 | super.viewDidLoad() 24 | self.title = "Components" 25 | tableView.separatorStyle = .none 26 | tableView.backgroundColor = .lightGray 27 | } 28 | 29 | override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 30 | let cell: UITableViewCell 31 | switch indexPath.row { 32 | case 0: 33 | let smallButton = CashAppPayButton(size: .small, onClickHandler: {}) 34 | smallButton.isEnabled = false 35 | cell = CashAppDemoCell(title: "Small Button", view: smallButton) 36 | case 1: cell = CashAppDemoCell(title: "Large Button", view: CashAppPayButton(size: .large, onClickHandler: {})) 37 | case 2: 38 | cell = CashAppDemoCell( 39 | title: "Small Button", 40 | view: CashAppPayButton(size: .small, onClickHandler: {}, usePolychromeAsset: true) 41 | ) 42 | case 3: 43 | let largeButton = CashAppPayButton(size: .large, onClickHandler: {}, usePolychromeAsset: true) 44 | largeButton.isEnabled = false 45 | cell = CashAppDemoCell(title: "Large Button", view: largeButton) 46 | case 4: cell = CashAppDemoCell( 47 | title: "Small Payment Method", 48 | view: CashAppPaymentMethod(size: .small, cashTag: "$jack") 49 | ) 50 | case 5: cell = CashAppDemoCell( 51 | title: "Large Payment Method", 52 | view: CashAppPaymentMethod(size: .large, cashTag: "$jack") 53 | ) 54 | case 6: cell = CashAppDemoCell( 55 | title: "Small Payment Method", 56 | view: CashAppPaymentMethod(size: .small, cashTag: "$jack", usePolychromeAsset: true) 57 | ) 58 | case 7: cell = CashAppDemoCell( 59 | title: "Large Payment Method", 60 | view: CashAppPaymentMethod(size: .large, cashTag: "$jack", usePolychromeAsset: true) 61 | ) 62 | default: 63 | cell = UITableViewCell() 64 | } 65 | cell.overrideUserInterfaceStyle = (indexPath.section == 0) ? .light : .dark 66 | cell.backgroundColor = .lightGray 67 | cell.selectionStyle = .none 68 | return cell 69 | } 70 | 71 | // MARK: - Table View 72 | 73 | override func numberOfSections(in tableView: UITableView) -> Int { 74 | 2 75 | } 76 | 77 | override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 78 | 8 79 | } 80 | 81 | override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { 82 | (section == 0) ? "Light Mode" : "Dark Mode" 83 | } 84 | } 85 | 86 | // MARK: - View Building 87 | 88 | extension ComponentsViewController { 89 | final class CashAppDemoCell: UITableViewCell { 90 | init(title: String, view: UIView) { 91 | super.init(style: .default, reuseIdentifier: nil) 92 | let titleLabel = UILabel() 93 | titleLabel.font = .boldSystemFont(ofSize: 18) 94 | titleLabel.translatesAutoresizingMaskIntoConstraints = false 95 | titleLabel.text = title 96 | 97 | let stack = UIStackView(arrangedSubviews: [view]) 98 | stack.axis = .vertical 99 | stack.distribution = .fillProportionally 100 | stack.alignment = .center 101 | stack.translatesAutoresizingMaskIntoConstraints = false 102 | 103 | contentView.addSubview(titleLabel) 104 | contentView.addSubview(stack) 105 | NSLayoutConstraint.activate([ 106 | titleLabel.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor), 107 | titleLabel.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), 108 | titleLabel.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), 109 | stack.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), 110 | stack.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), 111 | stack.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4), 112 | stack.bottomAnchor.constraint(equalTo: contentView.layoutMarginsGuide.bottomAnchor), 113 | ]) 114 | } 115 | 116 | @available(*, unavailable) 117 | required init?(coder: NSCoder) { 118 | fatalError("init(coder:) has not been implemented") 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /RELEASE-NOTES.md: -------------------------------------------------------------------------------- 1 | ## Paykit 0.6.3 Release Notes 2 | 3 | Pay Kit 0.6.3 supports iOS and requires Xcode 11 or later. The minimum supported Base SDK is 12.0. 4 | 5 | Pay Kit 0.6.3 includes the following new features and enhancements. 6 | 7 | - **PayKitUI Update** 8 | 9 | This is a complete UI Update to button and payment method styles to coincide with Afterpay + Cash App brand convergence UI. An additional parameter called `usePolychromeAsset` will toggle between the two convergence UI styles and will default to false. Since this is not a breaking API change, it's recommended to view and select which asset type (`usePolychromeAsset`) to use. See `ComponentsViewController` to view all variations. 10 | 11 | ## PayKit 0.6.2 Release Notes 12 | 13 | Pay Kit 0.6.2 supports iOS and requires Xcode 11 or later. The minimum supported Base SDK is 12.0. 14 | 15 | Pay Kit 0.6.2 includes the following new features and enhancements. 16 | 17 | - **Privacy Manifest** 18 | 19 | PayKit and PayKitUI now each contain a Privacy Manifest. 20 | 21 | ## PayKit 0.6.1 Release Notes 22 | 23 | Pay Kit 0.6.1 supports iOS and requires Xcode 11 or later. The minimum supported Base SDK is 12.0. 24 | 25 | Pay Kit 0.6.1 includes the following new features and enhancements. 26 | 27 | - **Objective-C Improvements** 28 | 29 | Fixed a crash in the Objective-C wrapper where calls to `retrieveCustomerRequest` would crash if an error was encountered. 30 | 31 | ## PayKit 0.6.0 Release Notes 32 | 33 | Pay Kit 0.6.0 supports iOS and requires Xcode 11 or later. The minimum supported Base SDK is 12.0. 34 | 35 | Pay Kit 0.6.0 includes the following new features and enhancements. 36 | 37 | - **PayKit Supports Objective-C** 38 | 39 | PayKit now provides Objective-C bindings. 40 | 41 | ## PayKit 0.5.1 Release Notes 42 | 43 | Pay Kit 0.5.1 supports iOS and requires Xcode 11 or later. The minimum supported Base SDK is 12.0. 44 | 45 | Pay Kit 0.5.1 includes the following new features and enhancements. 46 | 47 | - **Dropped Support for iOS 11 in PayKitUI** 48 | 49 | Increased the minimum supported iOS version to 12. 50 | 51 | - **`PayKitDemo` Demo** 52 | 53 | The *`PayKitDemo` App* includes a button to dismiss the keyboard and better support for light and dark themes. 54 | 55 | ## PayKit 0.5.0 Release Notes 56 | 57 | Pay Kit 0.5.0 supports iOS and requires Xcode 11 or later. The minimum supported Base SDK is 12.0. 58 | 59 | Pay Kit 0.5.0 includes the following new features and enhancements. 60 | 61 | - **Dropped Support for iOS 11** 62 | 63 | Increased the minimum supported iOS version to 12. 64 | 65 | ## PayKit 0.4.1 Release Notes 66 | 67 | Pay Kit 0.4.1 supports iOS and requires Xcode 9 or later. The minimum supported Base SDK is 11.0. 68 | 69 | Pay Kit 0.4.1 includes the following new features and enhancements. 70 | 71 | - **Redacting PII** 72 | 73 | Added redactions for personally identifiable information. 74 | 75 | ## PayKit 0.4.0 Release Notes 76 | 77 | Pay Kit 0.4.0 supports iOS and requires Xcode 9 or later. The minimum supported Base SDK is 11.0. 78 | 79 | Pay Kit 0.4.0 includes the following new features and enhancements. 80 | 81 | - **Adds `refreshing` to `CashAppPayState`** 82 | 83 | When calling `authorizeCustomerRequest()` for a CustomerRequest with expired `AuthFlowTriggers` the state machine refreshes the CustomerRequest before redirecting. 84 | 85 | This is a breaking change and clients updating from an older version should show a loading state here. 86 | 87 | ## PayKit 0.3.3 Release Notes 88 | 89 | Pay Kit 0.3.3 supports iOS and requires Xcode 9 or later. The minimum supported Base SDK is 11.0. 90 | 91 | Pay Kit 0.3.3 includes the following new features and enhancements. 92 | 93 | - **`retrieveCustomerRequest`** 94 | 95 | Adds a method to retrieve an existing CustomerRequest by ID. 96 | 97 | ## PayKit 0.3.2 Release Notes 98 | 99 | Pay Kit 0.3.2 supports iOS and requires Xcode 9 or later. The minimum supported Base SDK is 11.0. 100 | 101 | Pay Kit 0.3.2 includes the following new features and enhancements. 102 | 103 | - **Cocoapods** 104 | 105 | `PayKit` and `PayKitUI` support iOS 11 when imported through Cocoapods. 106 | 107 | ## PayKit 0.3.1 Release Notes 108 | 109 | Pay Kit 0.3.1 supports iOS and requires Xcode 9 or later. The minimum supported Base SDK is 11.0. 110 | 111 | Pay Kit 0.3.1 includes the following new features and enhancements. 112 | 113 | - **`PayKitDemo` Demo** 114 | 115 | The *`PayKitDemo` App* now includes a toggle to test in the staging environment. 116 | 117 | ## Pay Kit 0.3.0 Release Notes 118 | Pay Kit 0.3.0 supports iOS and requires Xcode 9 or later. The minimum supported Base SDK is 11.0. 119 | 120 | Pay Kit 0.3.0 includes the following new features and enhancements. 121 | 122 | - **Name changes** 123 | 124 | `PayKit` class has been renamed to `CashAppPay`. 125 | 126 | ## Pay Kit 0.2.0 Release Notes 127 | 128 | Pay Kit 0.2.0 supports iOS and requires Xcode 9 or later. The minimum supported Base SDK is 11.0. 129 | 130 | Pay Kit 0.2.0 includes the following new features and enhancements. 131 | 132 | - **Pay Kit Compiles in iOS 11** 133 | 134 | Pay Kit now supports a minimum base SDK version of 11.0 however `PayKitUI` still requires SDK version 13.0. 135 | 136 | - **`CashAppPayButton` Disabled State** 137 | 138 | The `CashAppPayButton` now supports a disabled state when the button is not tappable. 139 | 140 | ## Pay Kit 0.1.0 Release Notes 141 | 142 | Pay Kit 0.1.0 supports iOS and requires Xcode 11 or later. The minimum supported Base SDK is 13.0. 143 | 144 | Pay Kit 0.1.0 includes the following new features and enhancements. 145 | 146 | - **Pay Kit** 147 | 148 | Pay Kit allows you to accept Cash App Pay in your App. 149 | 150 | - **Pay Kit UI** 151 | 152 | `PayKitUI` contains views provided the user to launch a Pay Kit payment and to present the Cashtag. 153 | 154 | - **Test App** 155 | 156 | The `PayKitDemo` App (`PayKitDemo` project in the Pay Kit workspace) serves as an application to test different modules and features from 157 | the Pay Kit framework. 158 | -------------------------------------------------------------------------------- /Tests/PayKitTests/Errors+ObjCTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Errors+ObjCTests.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | @testable import PayKit 18 | import XCTest 19 | 20 | final class Errors_ObjCTests: XCTestCase { 21 | 22 | // MARK: - APIError 23 | 24 | func test_apiError() { 25 | let objcError = CAPApiError(apiError: TestValues.internalServerError) 26 | XCTAssertEqual(objcError.category, TestValues.internalServerError.category.capApiErrorCategory) 27 | XCTAssertEqual(objcError.code, TestValues.internalServerError.code.capApiErrorCode.rawValue) 28 | XCTAssertEqual(objcError.detail, TestValues.internalServerError.detail) 29 | XCTAssertEqual(objcError.field, TestValues.internalServerError.field) 30 | } 31 | 32 | func test_apiError_category() { 33 | XCTAssertEqual(APIError.Category.API_ERROR.capApiErrorCategory, .API_ERROR) 34 | } 35 | 36 | func test_apiError_code() { 37 | XCTAssertEqual(APIError.ErrorCode.INTERNAL_SERVER_ERROR.capApiErrorCode, .INTERNAL_SERVER_ERROR) 38 | XCTAssertEqual(APIError.ErrorCode.SERVICE_UNAVAILABLE.capApiErrorCode, .SERVICE_UNAVAILABLE) 39 | XCTAssertEqual(APIError.ErrorCode.GATEWAY_TIMEOUT.capApiErrorCode, .GATEWAY_TIMEOUT) 40 | } 41 | 42 | // MARK: - IntegrationError 43 | 44 | func test_integrationError() { 45 | let objcError = CAPIntegrationError(integrationError: TestValues.brandNotFoundError) 46 | XCTAssertEqual(objcError.category, TestValues.brandNotFoundError.category.capIntegrationErrorCategory) 47 | XCTAssertEqual(objcError.code, TestValues.brandNotFoundError.code.capIntegrationErrorCode.rawValue) 48 | XCTAssertEqual(objcError.detail, TestValues.brandNotFoundError.detail) 49 | XCTAssertEqual(objcError.field, TestValues.brandNotFoundError.field) 50 | } 51 | 52 | func test_integrationError_category() { 53 | XCTAssertEqual( 54 | IntegrationError.Category.AUTHENTICATION_ERROR.capIntegrationErrorCategory, 55 | .AUTHENTICATION_ERROR 56 | ) 57 | XCTAssertEqual(IntegrationError.Category.BRAND_ERROR.capIntegrationErrorCategory, .BRAND_ERROR) 58 | XCTAssertEqual(IntegrationError.Category.MERCHANT_ERROR.capIntegrationErrorCategory, .MERCHANT_ERROR) 59 | XCTAssertEqual( 60 | IntegrationError.Category.INVALID_REQUEST_ERROR.capIntegrationErrorCategory, 61 | .INVALID_REQUEST_ERROR 62 | ) 63 | XCTAssertEqual(IntegrationError.Category.RATE_LIMIT_ERROR.capIntegrationErrorCategory, .RATE_LIMIT_ERROR) 64 | } 65 | 66 | func test_integrationError_code() { 67 | XCTAssertEqual(IntegrationError.ErrorCode.UNAUTHORIZED.capIntegrationErrorCode, .UNAUTHORIZED) 68 | XCTAssertEqual(IntegrationError.ErrorCode.CLIENT_DISABLED.capIntegrationErrorCode, .CLIENT_DISABLED) 69 | XCTAssertEqual(IntegrationError.ErrorCode.FORBIDDEN.capIntegrationErrorCode, .FORBIDDEN) 70 | XCTAssertEqual(IntegrationError.ErrorCode.VALUE_TOO_LONG.capIntegrationErrorCode, .VALUE_TOO_LONG) 71 | XCTAssertEqual(IntegrationError.ErrorCode.VALUE_TOO_SHORT.capIntegrationErrorCode, .VALUE_TOO_SHORT) 72 | XCTAssertEqual(IntegrationError.ErrorCode.VALUE_EMPTY.capIntegrationErrorCode, .VALUE_EMPTY) 73 | XCTAssertEqual(IntegrationError.ErrorCode.VALUE_REGEX_MISMATCH.capIntegrationErrorCode, .VALUE_REGEX_MISMATCH) 74 | XCTAssertEqual(IntegrationError.ErrorCode.INVALID_URL.capIntegrationErrorCode, .INVALID_URL) 75 | XCTAssertEqual(IntegrationError.ErrorCode.VALUE_TOO_HIGH.capIntegrationErrorCode, .VALUE_TOO_HIGH) 76 | XCTAssertEqual(IntegrationError.ErrorCode.VALUE_TOO_LOW.capIntegrationErrorCode, .VALUE_TOO_LOW) 77 | XCTAssertEqual(IntegrationError.ErrorCode.ARRAY_LENGTH_TOO_LONG.capIntegrationErrorCode, .ARRAY_LENGTH_TOO_LONG) 78 | XCTAssertEqual( 79 | IntegrationError.ErrorCode.ARRAY_LENGTH_TOO_SHORT.capIntegrationErrorCode, 80 | .ARRAY_LENGTH_TOO_SHORT 81 | ) 82 | XCTAssertEqual(IntegrationError.ErrorCode.INVALID_ARRAY_TYPE.capIntegrationErrorCode, .INVALID_ARRAY_TYPE) 83 | XCTAssertEqual(IntegrationError.ErrorCode.NOT_FOUND.capIntegrationErrorCode, .NOT_FOUND) 84 | XCTAssertEqual(IntegrationError.ErrorCode.CONFLICT.capIntegrationErrorCode, .CONFLICT) 85 | XCTAssertEqual( 86 | IntegrationError.ErrorCode.INVALID_STATE_TRANSITION.capIntegrationErrorCode, 87 | .INVALID_STATE_TRANSITION 88 | ) 89 | XCTAssertEqual(IntegrationError.ErrorCode.CLIENT_NOT_FOUND.capIntegrationErrorCode, .CLIENT_NOT_FOUND) 90 | XCTAssertEqual(IntegrationError.ErrorCode.RATE_LIMITED.capIntegrationErrorCode, .RATE_LIMITED) 91 | XCTAssertEqual(IntegrationError.ErrorCode.BRAND_NOT_FOUND.capIntegrationErrorCode, .BRAND_NOT_FOUND) 92 | XCTAssertEqual( 93 | IntegrationError.ErrorCode.MERCHANT_MISSING_ADDRESS_OR_SITE.capIntegrationErrorCode, 94 | .MERCHANT_MISSING_ADDRESS_OR_SITE 95 | ) 96 | } 97 | 98 | // MARK: - UnexpectedError 99 | 100 | func test_unexpectedError() { 101 | let objcError = CAPUnexpectedError(unexpectedError: TestValues.idempotencyKeyReusedError) 102 | XCTAssertEqual(objcError.category, TestValues.idempotencyKeyReusedError.category) 103 | XCTAssertEqual(objcError.codeMessage, TestValues.idempotencyKeyReusedError.code) 104 | XCTAssertEqual(objcError.detail, TestValues.idempotencyKeyReusedError.detail) 105 | XCTAssertEqual(objcError.field, TestValues.idempotencyKeyReusedError.field) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /Tests/PayKitTests/LoggableTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoggableTests.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | @testable import PayKit 18 | import XCTest 19 | 20 | class LoggableTests: XCTestCase { 21 | func test_loggable_type_object() { 22 | XCTAssert(LoggableType.string("test").object is String) 23 | XCTAssert(LoggableType.int(5).object is Int) 24 | XCTAssert(LoggableType.uint(7).object is UInt) 25 | XCTAssert(LoggableType.bool(false).object is Bool) 26 | } 27 | 28 | func test_loggable_type_description() { 29 | XCTAssertEqual(LoggableType.string("test").description, "test") 30 | XCTAssertEqual(LoggableType.int(5).description, "5") 31 | XCTAssertEqual(LoggableType.uint(7).description, "7") 32 | XCTAssertEqual(LoggableType.bool(false).description, "false") 33 | } 34 | 35 | func test_loggable_int() { 36 | XCTAssertEqual(5.loggableDescription, .int(5)) 37 | } 38 | 39 | func test_loggable_bool() { 40 | XCTAssertEqual(true.loggableDescription, .bool(true)) 41 | } 42 | 43 | func test_loggable_string() { 44 | XCTAssertEqual("test".loggableDescription, .string("test")) 45 | } 46 | 47 | func test_loggable_url() throws { 48 | let url = try XCTUnwrap("https://block.xyz/") 49 | XCTAssertEqual(url.loggableDescription, .string("https://block.xyz/")) 50 | } 51 | 52 | func test_loggable_date() throws { 53 | let date = try XCTUnwrap( 54 | DateComponents( 55 | calendar: Calendar(identifier: .gregorian), 56 | timeZone: .init(secondsFromGMT: 0), 57 | year: 2022, 58 | month: 4, 59 | day: 20, 60 | hour: 8, 61 | minute: 30, 62 | second: 45 63 | ).date 64 | ) 65 | XCTAssertEqual(date.loggableDescription, .uint(1650443445000000)) 66 | } 67 | 68 | func test_loggable_dictionary() { 69 | let loggable = ["Hello": "world", "Any": "value"].loggableDescription 70 | XCTAssertEqual(loggable, .string("{\"Any\":\"value\",\"Hello\":\"world\"}")) 71 | } 72 | 73 | func test_loggable_array() { 74 | let loggable = ["Hello", "world", "Any", "value"].loggableDescription 75 | XCTAssertEqual(loggable, .string("[\"Hello\",\"world\",\"Any\",\"value\"]")) 76 | } 77 | 78 | func test_loggable_money() { 79 | let loggable = Money(amount: 100, currency: .USD).loggableDescription 80 | XCTAssertEqual(loggable, .string("{\"amount\":100,\"currency\":\"USD\"}")) 81 | } 82 | 83 | func test_loggable_payment_action_one_time_payment() { 84 | let paymentAction = PaymentAction.oneTimePayment( 85 | scopeID: "test", 86 | money: Money(amount: 100, currency: .USD) 87 | ) 88 | let loggable = LoggablePaymentAction(paymentAction: paymentAction).loggableDescription 89 | // swiftlint:disable:next line_length 90 | XCTAssertEqual(loggable, .string("{\"amount\":100,\"currency\":\"USD\",\"scope_id\":\"test\",\"type\":\"ONE_TIME_PAYMENT\"}")) 91 | } 92 | 93 | func test_loggable_payment_action_on_file_payment() { 94 | let paymentAction = PaymentAction.onFilePayment( 95 | scopeID: "test", 96 | accountReferenceID: "account4" 97 | ) 98 | let loggable = LoggablePaymentAction(paymentAction: paymentAction).loggableDescription 99 | // swiftlint:disable:next line_length 100 | XCTAssertEqual(loggable, .string(#"{"account_reference_id":"FILTERED","scope_id":"test","type":"ON_FILE_PAYMENT"}"#)) 101 | } 102 | 103 | func test_loggable_grant() throws { 104 | let grant = try XCTUnwrap(TestValues.approvedRequestGrants.first) 105 | let loggable = LoggableGrant(grant: grant).loggableDescription 106 | // swiftlint:disable:next line_length 107 | XCTAssertEqual(loggable, .string("{\"action\":{\"account_reference_id\":\"FILTERED\",\"scope_id\":\"BRAND_9kx6p0mkuo97jnl025q9ni94t\",\"type\":\"ON_FILE_PAYMENT\"},\"channel\":\"IN_APP\",\"created_at\":1666299823249000,\"customer_id\":\"CST_AYVkuLw-sT3OKZ7a_nhNTC_L2ekahLgGrS-EM_QhW4OTrGMbi59X1eCclH0cjaxoLObc\",\"expires_at\":1823979823159000,\"id\":\"GRG_AZYyHv2DwQltw0SiCLTaRb73y40XFe2dWM690WDF9Btqn-uTCYAUROa4ciwCdDnZcG4PuY1m_i3gwHODiO8DSf9zdMmRl1T0SM267vzuldnBs246-duHZhcehhXtmhfU8g\",\"status\":\"ACTIVE\",\"type\":\"EXTENDED\",\"updated_at\":1666299823249000}")) 108 | } 109 | 110 | func test_loggable_grant_init() throws { 111 | let grant = try XCTUnwrap(TestValues.approvedRequestGrants.first) 112 | let loggableGrant = LoggableGrant(grant: grant) 113 | 114 | XCTAssertEqual(grant.id, loggableGrant.id) 115 | XCTAssertEqual(grant.customerID, loggableGrant.customerID) 116 | XCTAssertEqual(grant.status, loggableGrant.status) 117 | XCTAssertEqual(grant.type, loggableGrant.type) 118 | XCTAssertEqual(grant.channel, loggableGrant.channel) 119 | XCTAssertEqual(grant.createdAt, loggableGrant.createdAt) 120 | XCTAssertEqual(grant.updatedAt, loggableGrant.updatedAt) 121 | XCTAssertEqual(grant.expiresAt, loggableGrant.expiresAt) 122 | } 123 | 124 | func test_loggable_payment_action_init() { 125 | let paymentAction = PaymentAction( 126 | type: .ONE_TIME_PAYMENT, 127 | scopeID: "scopeID", 128 | money: Money(amount: 100, currency: .USD), 129 | accountReferenceID: "account", 130 | clearing: true 131 | ) 132 | let loggablePaymentAction = LoggablePaymentAction(paymentAction: paymentAction) 133 | 134 | XCTAssertEqual(loggablePaymentAction.accountReferenceID, "FILTERED") 135 | XCTAssertEqual(paymentAction.scopeID, loggablePaymentAction.scopeID) 136 | XCTAssertEqual(paymentAction.clearing, loggablePaymentAction.clearing) 137 | XCTAssertEqual(paymentAction.money, loggablePaymentAction.money) 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /Sources/PayKitUI/Shared/Generated/Assets.swift: -------------------------------------------------------------------------------- 1 | // swiftlint:disable all 2 | // Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen 3 | 4 | #if os(macOS) 5 | import AppKit 6 | #elseif os(iOS) 7 | import UIKit 8 | #elseif os(tvOS) || os(watchOS) 9 | import UIKit 10 | #endif 11 | #if canImport(SwiftUI) 12 | import SwiftUI 13 | #endif 14 | 15 | // Deprecated typealiases 16 | @available(*, deprecated, renamed: "ColorAsset.Color", message: "This typealias will be removed in SwiftGen 7.0") 17 | internal typealias AssetColorTypeAlias = ColorAsset.Color 18 | @available(*, deprecated, renamed: "ImageAsset.Image", message: "This typealias will be removed in SwiftGen 7.0") 19 | internal typealias AssetImageTypeAlias = ImageAsset.Image 20 | 21 | // swiftlint:disable superfluous_disable_command file_length implicit_return 22 | 23 | // MARK: - Asset Catalogs 24 | 25 | // swiftlint:disable identifier_name line_length nesting type_body_length type_name 26 | internal enum Asset { 27 | internal enum Colors { 28 | internal static let buttonTextPrimary = ColorAsset(name: "ButtonTextPrimary") 29 | internal static let polyChrome = ColorAsset(name: "PolyChrome") 30 | internal static let surfacePrimary = ColorAsset(name: "SurfacePrimary") 31 | internal static let surfacePrimaryDisabled = ColorAsset(name: "SurfacePrimaryDisabled") 32 | internal static let surfaceSecondary = ColorAsset(name: "SurfaceSecondary") 33 | internal static let textPrimary = ColorAsset(name: "TextPrimary") 34 | internal static let textSecondary = ColorAsset(name: "TextSecondary") 35 | internal static let textTernary = ColorAsset(name: "TextTernary") 36 | } 37 | internal enum Images { 38 | internal static let monoChromeLogo = ImageAsset(name: "MonoChromeLogo") 39 | internal static let monoChromeLogoReverse = ImageAsset(name: "MonoChromeLogoReverse") 40 | internal static let polyChromeLogo = ImageAsset(name: "PolyChromeLogo") 41 | } 42 | } 43 | // swiftlint:enable identifier_name line_length nesting type_body_length type_name 44 | 45 | // MARK: - Implementation Details 46 | 47 | internal final class ColorAsset { 48 | internal fileprivate(set) var name: String 49 | 50 | #if os(macOS) 51 | internal typealias Color = NSColor 52 | #elseif os(iOS) || os(tvOS) || os(watchOS) 53 | internal typealias Color = UIColor 54 | #endif 55 | 56 | @available(iOS 11.0, tvOS 11.0, watchOS 4.0, macOS 10.13, *) 57 | internal private(set) lazy var color: Color = { 58 | guard let color = Color(asset: self) else { 59 | fatalError("Unable to load color asset named \(name).") 60 | } 61 | return color 62 | }() 63 | 64 | #if os(iOS) || os(tvOS) 65 | @available(iOS 11.0, tvOS 11.0, *) 66 | internal func color(compatibleWith traitCollection: UITraitCollection) -> Color { 67 | let bundle = BundleToken.bundle 68 | guard let color = Color(named: name, in: bundle, compatibleWith: traitCollection) else { 69 | fatalError("Unable to load color asset named \(name).") 70 | } 71 | return color 72 | } 73 | #endif 74 | 75 | #if canImport(SwiftUI) 76 | @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) 77 | internal var swiftUIColor: SwiftUI.Color { 78 | SwiftUI.Color(asset: self) 79 | } 80 | #endif 81 | 82 | fileprivate init(name: String) { 83 | self.name = name 84 | } 85 | } 86 | 87 | internal extension ColorAsset.Color { 88 | @available(iOS 11.0, tvOS 11.0, watchOS 4.0, macOS 10.13, *) 89 | convenience init?(asset: ColorAsset) { 90 | let bundle = BundleToken.bundle 91 | #if os(iOS) || os(tvOS) 92 | self.init(named: asset.name, in: bundle, compatibleWith: nil) 93 | #elseif os(macOS) 94 | self.init(named: NSColor.Name(asset.name), bundle: bundle) 95 | #elseif os(watchOS) 96 | self.init(named: asset.name) 97 | #endif 98 | } 99 | } 100 | 101 | #if canImport(SwiftUI) 102 | @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) 103 | internal extension SwiftUI.Color { 104 | init(asset: ColorAsset) { 105 | let bundle = BundleToken.bundle 106 | self.init(asset.name, bundle: bundle) 107 | } 108 | } 109 | #endif 110 | 111 | internal struct ImageAsset { 112 | internal fileprivate(set) var name: String 113 | 114 | #if os(macOS) 115 | internal typealias Image = NSImage 116 | #elseif os(iOS) || os(tvOS) || os(watchOS) 117 | internal typealias Image = UIImage 118 | #endif 119 | 120 | @available(iOS 8.0, tvOS 9.0, watchOS 2.0, macOS 10.7, *) 121 | internal var image: Image { 122 | let bundle = BundleToken.bundle 123 | #if os(iOS) || os(tvOS) 124 | let image = Image(named: name, in: bundle, compatibleWith: nil) 125 | #elseif os(macOS) 126 | let name = NSImage.Name(self.name) 127 | let image = (bundle == .main) ? NSImage(named: name) : bundle.image(forResource: name) 128 | #elseif os(watchOS) 129 | let image = Image(named: name) 130 | #endif 131 | guard let result = image else { 132 | fatalError("Unable to load image asset named \(name).") 133 | } 134 | return result 135 | } 136 | 137 | #if os(iOS) || os(tvOS) 138 | @available(iOS 8.0, tvOS 9.0, *) 139 | internal func image(compatibleWith traitCollection: UITraitCollection) -> Image { 140 | let bundle = BundleToken.bundle 141 | guard let result = Image(named: name, in: bundle, compatibleWith: traitCollection) else { 142 | fatalError("Unable to load image asset named \(name).") 143 | } 144 | return result 145 | } 146 | #endif 147 | 148 | #if canImport(SwiftUI) 149 | @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) 150 | internal var swiftUIImage: SwiftUI.Image { 151 | SwiftUI.Image(asset: self) 152 | } 153 | #endif 154 | } 155 | 156 | internal extension ImageAsset.Image { 157 | @available(iOS 8.0, tvOS 9.0, watchOS 2.0, *) 158 | @available(macOS, deprecated, 159 | message: "This initializer is unsafe on macOS, please use the ImageAsset.image property") 160 | convenience init?(asset: ImageAsset) { 161 | #if os(iOS) || os(tvOS) 162 | let bundle = BundleToken.bundle 163 | self.init(named: asset.name, in: bundle, compatibleWith: nil) 164 | #elseif os(macOS) 165 | self.init(named: NSImage.Name(asset.name)) 166 | #elseif os(watchOS) 167 | self.init(named: asset.name) 168 | #endif 169 | } 170 | } 171 | 172 | #if canImport(SwiftUI) 173 | @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) 174 | internal extension SwiftUI.Image { 175 | init(asset: ImageAsset) { 176 | let bundle = BundleToken.bundle 177 | self.init(asset.name, bundle: bundle) 178 | } 179 | 180 | init(asset: ImageAsset, label: Text) { 181 | let bundle = BundleToken.bundle 182 | self.init(asset.name, bundle: bundle, label: label) 183 | } 184 | 185 | init(decorative asset: ImageAsset) { 186 | let bundle = BundleToken.bundle 187 | self.init(decorative: asset.name, bundle: bundle) 188 | } 189 | } 190 | #endif 191 | 192 | // swiftlint:disable convenience_type 193 | private final class BundleToken { 194 | static let bundle: Bundle = { 195 | #if SWIFT_PACKAGE 196 | return Bundle.module 197 | #else 198 | return Bundle(for: BundleToken.self) 199 | #endif 200 | }() 201 | } 202 | // swiftlint:enable convenience_type 203 | 204 | -------------------------------------------------------------------------------- /Sources/PayKit/CashAppPay.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PayKit.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import Foundation 18 | import UIKit 19 | 20 | public class CashAppPay { 21 | 22 | public static let version = "0.6.3" 23 | 24 | public static let RedirectNotification: Notification.Name = Notification.Name("CashAppPayRedirect") 25 | 26 | let stateMachine: StateMachine 27 | 28 | private let networkManager: NetworkManager 29 | 30 | public convenience init(clientID: String, endpoint: Endpoint = .production) { 31 | let networkManager = NetworkManager(clientID: clientID, endpoint: endpoint) 32 | let analytics = EventStream2( 33 | appName: EventStream2.appName, 34 | commonParameters: [ 35 | EventStream2.CommonFields.clientID.rawValue: clientID, 36 | EventStream2.CommonFields.platform.rawValue: "iOS", 37 | EventStream2.CommonFields.sdkVersion.rawValue: CashAppPay.version, 38 | EventStream2.CommonFields.clientUA.rawValue: UserAgent.userAgent, 39 | EventStream2.CommonFields.environment.rawValue: endpoint.analyticsField, 40 | ], 41 | store: AnalyticsDataSource(), 42 | client: AnalyticsClient(restService: ResilientRESTService(), endpoint: endpoint.analyticsEndpoint) 43 | ) 44 | 45 | let stateMachine = StateMachine(networkManager: networkManager, analyticsService: analytics) 46 | self.init(stateMachine: stateMachine, networkManager: networkManager, endpoint: endpoint) 47 | } 48 | 49 | init(stateMachine: StateMachine, networkManager: NetworkManager, endpoint: Endpoint) { 50 | self.stateMachine = stateMachine 51 | self.networkManager = networkManager 52 | self.endpoint = endpoint 53 | } 54 | 55 | public enum Endpoint { 56 | case production 57 | case sandbox 58 | case staging 59 | } 60 | 61 | /// The endpoint that the requests are routed to 62 | public let endpoint: Endpoint 63 | 64 | /// Fetch an existing CustomerRequest by ID. 65 | public func retrieveCustomerRequest( 66 | id: String, 67 | completion: @escaping (Result) -> Void 68 | ) { 69 | networkManager.retrieveCustomerRequest(id: id, completionHandler: completion) 70 | } 71 | 72 | /// Create a customer request. Registered observers are notified of state changes as the request proceeds. 73 | public func createCustomerRequest( 74 | params: CreateCustomerRequestParams 75 | ) { 76 | stateMachine.state = .creatingCustomerRequest(params) 77 | } 78 | 79 | /// Update an existing customer request. Registered observers are notified of state changes as the request proceeds. 80 | public func updateCustomerRequest( 81 | _ request: CustomerRequest, 82 | with params: UpdateCustomerRequestParams 83 | ) { 84 | switch request.status { 85 | case .APPROVED, .DECLINED: 86 | stateMachine.state = .integrationError(.terminalStateError) 87 | case .PENDING, .PROCESSING: 88 | stateMachine.state = .updatingCustomerRequest(request: request, params: params) 89 | } 90 | } 91 | 92 | public enum AuthorizationMethod { 93 | case DEEPLINK 94 | // case QR_CODE -- future 95 | } 96 | 97 | /// Authorize an existing customer request. 98 | /// Registered observers are notified of state changes as the request proceeds. 99 | public func authorizeCustomerRequest( 100 | _ request: CustomerRequest, 101 | method: AuthorizationMethod = .DEEPLINK 102 | ) { 103 | switch request.status { 104 | case .DECLINED: 105 | stateMachine.state = .integrationError(.terminalStateError) 106 | case .APPROVED: 107 | stateMachine.state = .redirecting(request) 108 | case .PENDING, .PROCESSING: 109 | if request.authFlowTriggers?.isExpired() == false { 110 | stateMachine.state = .redirecting(request) 111 | } else { 112 | stateMachine.state = .refreshing(request) 113 | } 114 | } 115 | } 116 | } 117 | 118 | public enum CashAppPayState: Equatable { 119 | /// Ready for a Create Customer Request to be initiated. 120 | case notStarted 121 | /// CustomerRequest is being created. For information only. 122 | case creatingCustomerRequest(CreateCustomerRequestParams) 123 | /// CustomerRequest is being updated. For information only. 124 | case updatingCustomerRequest(request: CustomerRequest, params: UpdateCustomerRequestParams) 125 | /// CustomerRequest has been created, waiting for customer to press "Pay with Cash App Pay" button. 126 | case readyToAuthorize(CustomerRequest) 127 | /// SDK is redirecting to Cash App for authorization. Show loading indicator if desired. 128 | case redirecting(CustomerRequest) 129 | /// SDK is retrieving authorized CustomerRequest. Show loading indicator if desired. 130 | case polling(CustomerRequest) 131 | /// CustomerRequest was declined. Update UI to tell customer to try again. 132 | case declined(CustomerRequest) 133 | /// CustomerRequest was approved. Update UI to show payment info or $cashtag. 134 | case approved(request: CustomerRequest, grants: [CustomerRequest.Grant]) 135 | /// CustomerRequest is being refreshed as a result of the AuthFlowTriggers expiring. 136 | /// Show loading indicator if desired. 137 | case refreshing(CustomerRequest) 138 | /// An error with the Cash App Pay API that can manifest at runtime. 139 | /// If an `APIError` is received, the integration is degraded and Cash App Pay functionality 140 | /// should be temporarily removed from the app's UI. 141 | case apiError(APIError) 142 | /// An error in the integration that should be resolved before shipping to production. 143 | /// Examples include authorization issues, incorrect brand IDs, validation errors, etc. 144 | case integrationError(IntegrationError) 145 | /// A networking error, likely due to poor internet connectivity. 146 | case networkError(NetworkError) 147 | /// An unexpected error. Please report any errors of this kind (and what caused them) to Cash App Developer Support. 148 | case unexpectedError(UnexpectedError) 149 | } 150 | 151 | public protocol CashAppPayObserver: AnyObject { 152 | func stateDidChange(to state: CashAppPayState) 153 | } 154 | 155 | public extension CashAppPay { 156 | func addObserver(_ observer: CashAppPayObserver) { 157 | stateMachine.addObserver(observer) 158 | } 159 | 160 | func removeObserver(_ observer: CashAppPayObserver) { 161 | stateMachine.removeObserver(observer) 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /Sources/PayKitUI/SwiftUI/CashAppPaymentMethodView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CashAppPaymentMethodView.swift 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import Foundation 18 | import SwiftUI 19 | 20 | @available(iOS 13.0, *) 21 | public struct CashAppPaymentMethodView: View { 22 | 23 | // MARK: - Public Properties 24 | 25 | @ObservedObject public var viewModel: ViewModel 26 | 27 | private var currentAsset: SwiftUI.Image { 28 | viewModel.usePolychromeAsset ? 29 | Asset.Images.polyChromeLogo.swiftUIImage : 30 | Asset.Images.monoChromeLogoReverse.swiftUIImage 31 | } 32 | 33 | private var currentColor: SwiftUI.Color { 34 | viewModel.usePolychromeAsset ? 35 | Asset.Colors.polyChrome.swiftUIColor : 36 | Asset.Colors.surfaceSecondary.swiftUIColor 37 | } 38 | 39 | // MARK: - Lifecycle 40 | 41 | /** 42 | Initializes a view with the Cash App logo on the left and the Cash Tag representing 43 | the customer on the right or the bottom. 44 | 45 | - Parameters: 46 | - size: The size of the view where the `small` is vertically stacked while `large` is horizontally stacked. 47 | Defaults to `large`. 48 | - cashTag: The Customer ID. Defaults to `nil`. 49 | - cashTagFont: Cash Tag text font. 50 | - cashTagTextColor: Cash Tag text color. 51 | - usePolychromeAsset: Toggle usage of polychrome UI 52 | */ 53 | public init( 54 | size: SizingCategory = .large, 55 | cashTag: String, 56 | cashTagFont: Font = Constants.cashTagFont, 57 | cashTagTextColor: Color = Constants.cashTagTextColor, 58 | usePolychromeAsset: Bool = false 59 | ) { 60 | self.viewModel = ViewModel( 61 | size: size, 62 | cashTag: cashTag, 63 | cashTagFont: cashTagFont, 64 | cashTagTextColor: cashTagTextColor, 65 | usePolychromeAsset: usePolychromeAsset 66 | ) 67 | } 68 | 69 | public var body: some View { 70 | switch viewModel.size { 71 | case .small: 72 | VStack(alignment: .leading, spacing: Constants.verticalTextSpacing) { 73 | currentAsset 74 | .resizable() 75 | .aspectRatio(contentMode: .fill) 76 | .frame(width: Constants.titleWidth, height: Constants.titleHeight) 77 | .offset(y: Constants.titleVerticalOffset) 78 | .padding( 79 | EdgeInsets( 80 | top: Constants.verticalPadding, 81 | leading: Constants.horizontalPadding, 82 | bottom: .zero, 83 | trailing: Constants.horizontalPadding 84 | ) 85 | ) 86 | cashTagText 87 | .padding( 88 | EdgeInsets( 89 | top: .zero, 90 | leading: Constants.cashTagInset, 91 | bottom: Constants.verticalPadding, 92 | trailing: .zero 93 | ) 94 | ) 95 | }.background(currentColor) 96 | .cornerRadius(Constants.cornerRadius) 97 | case .large: 98 | HStack(alignment: .center) { 99 | currentAsset 100 | .resizable() 101 | .aspectRatio(contentMode: .fill) 102 | .frame(width: Constants.titleWidth, height: Constants.titleHeight) 103 | .padding( 104 | EdgeInsets( 105 | top: Constants.verticalPadding, 106 | leading: Constants.horizontalPadding, 107 | bottom: Constants.verticalPadding, 108 | trailing: .zero 109 | ) 110 | ) 111 | Spacer() 112 | cashTagText 113 | .padding(.trailing, Constants.horizontalPadding) 114 | }.background(currentColor) 115 | .cornerRadius(Constants.cornerRadius) 116 | } 117 | } 118 | 119 | private var cashTagText: some View { 120 | Text(viewModel.cashTag) 121 | .foregroundColor(viewModel.cashTagTextColor) 122 | .font(viewModel.cashTagFont) 123 | } 124 | 125 | public enum Constants { 126 | public static let cashTagFont = Font.system(size: 14) 127 | public static let cashTagTextColor = Asset.Colors.textTernary.swiftUIColor 128 | 129 | static let titleWidth: CGFloat = 127 130 | static let titleHeight: CGFloat = 20 131 | static let cashTagInset: CGFloat = 38 132 | static let titleVerticalOffset: CGFloat = 2 133 | static let horizontalPadding: CGFloat = 8 134 | static let verticalPadding: CGFloat = 12 135 | static let cornerRadius: CGFloat = 8 136 | static let verticalTextSpacing: CGFloat = 2 137 | } 138 | } 139 | 140 | // MARK: - View Model 141 | 142 | @available(iOS 13.0, *) 143 | extension CashAppPaymentMethodView { 144 | public class ViewModel: ObservableObject { 145 | @Published var size: SizingCategory 146 | @Published var cashTag: String 147 | @Published var cashTagFont: Font 148 | @Published var cashTagTextColor: Color 149 | @Published var usePolychromeAsset: Bool 150 | init( 151 | size: SizingCategory, 152 | cashTag: String, 153 | cashTagFont: Font, 154 | cashTagTextColor: Color, 155 | usePolychromeAsset: Bool 156 | ) { 157 | self.size = size 158 | self.cashTag = cashTag 159 | self.cashTagFont = cashTagFont 160 | self.cashTagTextColor = cashTagTextColor 161 | self.usePolychromeAsset = usePolychromeAsset 162 | } 163 | } 164 | } 165 | 166 | // MARK: - Preview 167 | 168 | @available(iOS 13.0, *) 169 | struct CashAppPaymentMethodView_Previews: PreviewProvider { 170 | static var previews: some View { 171 | VStack(alignment: .leading, spacing: 10) { 172 | CashAppPaymentMethodView(size: .large, cashTag: "$jack", usePolychromeAsset: false) 173 | .padding() 174 | CashAppPaymentMethodView(size: .large, cashTag: "", usePolychromeAsset: false) 175 | .padding() 176 | CashAppPaymentMethodView(size: .small, cashTag: "$jack", usePolychromeAsset: false) 177 | .padding() 178 | CashAppPaymentMethodView(size: .small, cashTag: "", usePolychromeAsset: false) 179 | .padding() 180 | CashAppPaymentMethodView(size: .large, cashTag: "$jack", usePolychromeAsset: false) 181 | .padding() 182 | }.background(Color.blue) 183 | } 184 | } 185 | --------------------------------------------------------------------------------