diff --git a/CHANGELOG b/CHANGELOG index 2558431..a23010d 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,19 @@ +=== 3.1.0 2026-10-21 + +* This SDK is deprecated in favour of https://github.com/PaystackHQ/paystack-sdk-ios. All deprecation messages, including PSTCKCard.fingerprint and the PSTCKCard setters, now point to the new SDK. They remain warnings, not errors +* CocoaPods trunk stops accepting new podspecs on 2 December 2026 (https://blog.cocoapods.org/CocoaPods-Specs-Repo/). Existing versions stay installable, but no new versions of this pod can be published after that date; move to the new SDK for future updates +* Requires iOS 15.0+ and Xcode 26 or later. Apps built with the current iOS SDK must adopt the UIScene life cycle (the Example app shows how) +* Example app no longer needs a backend: set paystackPublicKey and accessCode in ViewController.swift +* Raise minimum iOS deployment target to 15.0 (SDK, tests, Example and podspec); required to build with current Xcode +* Stop treating compiler warnings as errors in the iOS SDK and test targets so the deprecated Security padding APIs in PSTCKRSA compile +* Requery now uses charge/requery/{access_code}. The access code from PSTCKTransactionParams.access_code is stored for the whole charge +* Charges started without an access code temporarily fall back to charge/requery/{trans}; this will be removed in a future release, so initialize transactions on your server (transaction/initialize) and pass access_code +* Requery polls every 5s while the status is pending, up to 12 times (60s), then ends with an error; success, failed and error statuses end the charge immediately +* Authorization: Bearer and Paystack-Version (the SDK version, e.g. 3.1.0) are now set on every request, including the address verification states lookup; changing publicKey after init now takes effect on the next request +* A second chargeCard call while a charge is in progress no longer overwrites the running charge's callbacks; optional callbacks are reset per charge +* Fix crashes when requerying without a transaction id and when a response body is not valid UTF-8 +* fetchStates now URL-encodes the country and always calls its completion + === 3.0.13 2019-11-4 * Updates deprecated class UIWebView to WKWebView diff --git a/Example/Paystack iOS Example.xcodeproj/project.pbxproj b/Example/Paystack iOS Example.xcodeproj/project.pbxproj index 833a015..f9afe50 100644 --- a/Example/Paystack iOS Example.xcodeproj/project.pbxproj +++ b/Example/Paystack iOS Example.xcodeproj/project.pbxproj @@ -402,9 +402,9 @@ ); GCC_TREAT_WARNINGS_AS_ERRORS = YES; INFOPLIST_FILE = "$(SRCROOT)/Paystack iOS Example/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 3.0.13; + MARKETING_VERSION = 3.1.0; PRODUCT_BUNDLE_IDENTIFIER = "com.paystack.$(PRODUCT_NAME:rfc1034identifier)"; PRODUCT_NAME = "Paystack iOS Example"; PROVISIONING_PROFILE = ""; @@ -431,9 +431,9 @@ ); GCC_TREAT_WARNINGS_AS_ERRORS = YES; INFOPLIST_FILE = "$(SRCROOT)/Paystack iOS Example/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 3.0.13; + MARKETING_VERSION = 3.1.0; PRODUCT_BUNDLE_IDENTIFIER = "com.paystack.$(PRODUCT_NAME:rfc1034identifier)"; PRODUCT_NAME = "Paystack iOS Example"; PROVISIONING_PROFILE = ""; diff --git a/Example/Paystack iOS Example.xcodeproj/xcshareddata/xcschemes/Paystack iOS Application Tests.xcscheme b/Example/Paystack iOS Example.xcodeproj/xcshareddata/xcschemes/Paystack iOS Application Tests.xcscheme index 352bed2..3365da0 100644 --- a/Example/Paystack iOS Example.xcodeproj/xcshareddata/xcschemes/Paystack iOS Application Tests.xcscheme +++ b/Example/Paystack iOS Example.xcodeproj/xcshareddata/xcschemes/Paystack iOS Application Tests.xcscheme @@ -23,8 +23,6 @@ - - - - Bool { return true } + // MARK: UISceneSession lifecycle + + func application(_ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions) -> UISceneConfiguration { + // Matches the "Default Configuration" entry in Info.plist's UIApplicationSceneManifest. + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } } +/// Owns the window now that the app uses the UIScene life cycle (required by the current iOS SDK). +/// Info.plist points UISceneStoryboardFile at Main.storyboard, so UIKit creates the window and +/// loads the initial view controller; nothing else is needed here. +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + guard scene is UIWindowScene else { return } + } +} diff --git a/Example/Paystack iOS Example/Info.plist b/Example/Paystack iOS Example/Info.plist index 64e2267..beacd4a 100644 --- a/Example/Paystack iOS Example/Info.plist +++ b/Example/Paystack iOS Example/Info.plist @@ -24,11 +24,28 @@ UILaunchStoryboardName LaunchScreen - UIMainStoryboardFile - Main + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + UIRequiredDeviceCapabilities - armv7 + arm64 UISupportedInterfaceOrientations diff --git a/Example/Paystack iOS Example/ViewController.swift b/Example/Paystack iOS Example/ViewController.swift index f239570..5687412 100644 --- a/Example/Paystack iOS Example/ViewController.swift +++ b/Example/Paystack iOS Example/ViewController.swift @@ -9,14 +9,16 @@ import Paystack class ViewController: UIViewController, PSTCKPaymentCardTextFieldDelegate { // MARK: REPLACE THESE - // Replace these values with your application's keys - // Find this at https://dashboard.paystack.co/#/settings/developer - let paystackPublicKey = "" - // let paystackPublicKey = "pk_live_ed8a7004bb85bf636a0a22c635ad9d1788caa5de" - - // To set this up, see https://github.com/PaystackHQ/sample-charge-card-backend - let backendURLString = "" - // let backendURLString = "https://calm-scrubland-33409.herokuapp.com" + // This sample runs without a backend: it charges with a hard-coded public key and access code. + // Find your public key at https://dashboard.paystack.co/#/settings/developer + // set Public key here + let paystackPublicKey = "public_key" + + // set access code here + // Get one from a transaction/initialize call (response `data.access_code`) made with your secret key, + // in the same mode (test/live) as the public key. Access codes are single-use: initialize a new + // transaction for each test charge. + let accessCode = "access_code" let card : PSTCKCard = PSTCKCard() @@ -74,13 +76,12 @@ class ViewController: UIViewController, PSTCKPaymentCardTextFieldDelegate { if cardDetailsForm.isValid { - if backendURLString != "" { - fetchAccessCodeAndChargeCard() + // Make sure the access code has been set + if accessCode == "" { + showOkayableMessage("You need to set an access code.", message:"Initialize a transaction with your secret key and paste data.access_code into accessCode in ViewController.swift.") return } - showOkayableMessage("Backend not configured", message:"To run this sample, please configure your backend.") -// chargeWithSDK(newCode:""); - + chargeWithSDK(accessCode: accessCode) } } @@ -95,33 +96,10 @@ class ViewController: UIViewController, PSTCKPaymentCardTextFieldDelegate { } } - func fetchAccessCodeAndChargeCard(){ - if let url = URL(string: backendURLString + "/new-access-code") { - self.makeBackendRequest(url: url, message: "fetching access code", completion: { str in - self.outputOnLabel(str: "Fetched access code: "+str) - self.chargeWithSDK(newCode: str as NSString) - }) - } - } - - func chargeWithSDK(newCode: NSString){ + func chargeWithSDK(accessCode: String){ + outputOnLabel(str: "Charging with access code " + accessCode) let transactionParams = PSTCKTransactionParams.init(); - transactionParams.access_code = newCode as String; - //transactionParams.additionalAPIParameters = ["enforce_otp": "true"]; -// transactionParams.email = "ibrahim@paystack.co"; -// transactionParams.amount = 2000; -// let dictParams: NSMutableDictionary = [ -// "recurring": true -// ]; -// let arrParams: NSMutableArray = [ -// "0","go" -// ]; -// do { -// try transactionParams.setMetadataValueDict(dictParams, forKey: "custom_filters"); -// try transactionParams.setMetadataValueArray(arrParams, forKey: "custom_array"); -// } catch { -// print(error) -// } + transactionParams.access_code = accessCode; // use library to create charge and get its reference PSTCKAPIClient.shared().chargeCard(self.cardDetailsForm.cardParams, forTransaction: transactionParams, on: self, didEndWithError: { (error, reference) in self.outputOnLabel(str: "Charge errored") @@ -140,7 +118,6 @@ class ViewController: UIViewController, PSTCKPaymentCardTextFieldDelegate { if let reference=reference { self.showOkayableMessage("An error occured while completing "+reference, message: errorString) self.outputOnLabel(str: reference + ": " + errorString) - self.verifyTransaction(reference: reference) } else { self.showOkayableMessage("An error occured", message: errorString) self.outputOnLabel(str: errorString) @@ -160,41 +137,10 @@ class ViewController: UIViewController, PSTCKPaymentCardTextFieldDelegate { }) { (reference) in self.outputOnLabel(str: "succeeded: " + reference) self.chargeCardButton.isEnabled = true; - self.verifyTransaction(reference: reference) + // Verify on your server before giving value: + // GET https://api.paystack.co/transaction/verify/ with your secret key. + self.outputOnLabel(str: "verify " + reference + " on your server (transaction/verify)") } return } - - func verifyTransaction(reference: String){ - if let url = URL(string: backendURLString + "/verify/" + reference) { - makeBackendRequest(url: url, message: "verifying " + reference, completion:{(str) -> Void in - self.outputOnLabel(str: "Message from paystack on verifying " + reference + ": " + str) - }) - } - } - - func makeBackendRequest(url: URL, message: String, completion: @escaping (_ result: String) -> Void){ - let session = URLSession(configuration: URLSessionConfiguration.default) - self.outputOnLabel(str: "Backend: " + message) - session.dataTask(with: url, completionHandler: { data, response, error in - let successfulResponse = (response as? HTTPURLResponse)?.statusCode == 200 - if successfulResponse && error == nil && data != nil { - if let str = NSString(data: data!, encoding: String.Encoding.utf8.rawValue){ - completion(str as String) - } else { - self.outputOnLabel(str: " while "+message) - print("") - } - } else { - if let error = error { - print(error.localizedDescription) - self.outputOnLabel(str: error.localizedDescription) - } else { - // There was no error returned though status code was not 200 - print("There was an error communicating with your payment backend.") - self.outputOnLabel(str: "There was an error communicating with your payment backend while "+message) - } - } - }).resume() - } } diff --git a/GUIDE.md b/GUIDE.md index 7ae89f3..a40d1a1 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -1,5 +1,13 @@ # Guide +> **This SDK is deprecated.** New integrations should use the new Paystack iOS SDK: https://github.com/PaystackHQ/paystack-sdk-ios. +> +> **CocoaPods trunk stops accepting new versions on 2 December 2026** ([announcement](https://blog.cocoapods.org/CocoaPods-Specs-Repo/)). +> Existing versions of this pod stay installable, but no new versions can be published after that date. +> +> Requirements: iOS 15.0+, Xcode 26 or later, Keychain Sharing entitlement. Always pass an `access_code` from your server's +> `transaction/initialize` call: charges without one will stop working in a future release. + If you want to build mobile apps like [Taxify](http://www.taxify.eu), [Afro](http://www.getafrocab.com), [Okada Books](https://www.okadabooks.com) and enable people to make purchases directly in your app, our iOS and [Android](https://github.com/PaystackHQ/paystack-android) libraries can help. Accepting payments in your app after collecting card information can be achieved by charging the card with our SDK. Reusable `authorization code`s from such transaction from can be used from your backend to charge the cards directly. @@ -47,6 +55,10 @@ We recommend using [CocoaPods](https://cocoapods.org/) to install the Paystack i If you haven't set up Cocoapods before, their site has installation instructions. Then, add pod 'Paystack' to your Podfile, and run pod install. +> **Note:** CocoaPods trunk becomes read-only on 2 December 2026 ([announcement](https://blog.cocoapods.org/CocoaPods-Specs-Repo/)). +> `pod install` keeps working for versions already published, but this pod won't receive new versions through CocoaPods after +> that date. Plan your move to the [new SDK](https://github.com/PaystackHQ/paystack-sdk-ios), which supports Swift Package Manager. + (Don't forget to use the .xcworkspace file to open your project in Xcode, instead of the .xcodeproj file, from here on out.) #### Using Carthage diff --git a/Paystack.podspec b/Paystack.podspec index 9d4a6d0..3064d0c 100644 --- a/Paystack.podspec +++ b/Paystack.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'Paystack' - s.version = '3.0.17' + s.version = '3.1.0' s.summary = 'Paystack is a web-based API helping African Businesses accept payments online.' s.description = <<-DESC Paystack makes it easy for African Businesses to accept Mastercard, Visa and Verve cards from anyone, anywhere in the world. @@ -14,7 +14,7 @@ Pod::Spec.new do |s| s.ios.weak_frameworks = 'PassKit', 'AddressBook' s.requires_arc = true s.default_subspecs = 'Core' - s.ios.deployment_target = '11.0' + s.ios.deployment_target = '15.0' s.swift_versions = '5.0' diff --git a/Paystack.xcodeproj/project.pbxproj b/Paystack.xcodeproj/project.pbxproj index 14ee8fc..7d4ea24 100644 --- a/Paystack.xcodeproj/project.pbxproj +++ b/Paystack.xcodeproj/project.pbxproj @@ -115,6 +115,7 @@ 04415C651A6605B5001225ED /* PSTCKToken.m in Sources */ = {isa = PBXBuildFile; fileRef = 04CDB4CD1A5F30A700B854EE /* PSTCKToken.m */; }; 04415C661A6605B5001225ED /* PaystackError.m in Sources */ = {isa = PBXBuildFile; fileRef = 04CDB4CF1A5F30A700B854EE /* PaystackError.m */; }; 04415C671A6605B5001225ED /* PSTCKAPIClientTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 04CDB51E1A5F3A9300B854EE /* PSTCKAPIClientTest.m */; }; + A1B2C3D4E5F6071829304A5C /* PSTCKChargeRequeryTest.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6071829304A5B /* PSTCKChargeRequeryTest.m */; }; 04415C701A6605B5001225ED /* PSTCKTokenTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 04CDB5271A5F3A9300B854EE /* PSTCKTokenTest.m */; }; 04415C721A6605D9001225ED /* Paystack.h in Headers */ = {isa = PBXBuildFile; fileRef = 04CDB4A91A5F30A700B854EE /* Paystack.h */; settings = {ATTRIBUTES = (Public, ); }; }; 04415C7F1A6605D9001225ED /* PSTCKAPIClient.h in Headers */ = {isa = PBXBuildFile; fileRef = 04CDB4C21A5F30A700B854EE /* PSTCKAPIClient.h */; }; @@ -402,6 +403,7 @@ 04CDB4CE1A5F30A700B854EE /* PaystackError.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = PaystackError.h; path = PublicHeaders/PaystackError.h; sourceTree = ""; }; 04CDB4CF1A5F30A700B854EE /* PaystackError.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = PaystackError.m; sourceTree = ""; }; 04CDB51E1A5F3A9300B854EE /* PSTCKAPIClientTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = PSTCKAPIClientTest.m; sourceTree = ""; }; + A1B2C3D4E5F6071829304A5B /* PSTCKChargeRequeryTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = PSTCKChargeRequeryTest.m; sourceTree = ""; }; 04CDB5271A5F3A9300B854EE /* PSTCKTokenTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = PSTCKTokenTest.m; sourceTree = ""; }; 04CDE5B41BC1F1F100548833 /* PSTCKCardParams.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = PSTCKCardParams.m; sourceTree = ""; }; 04CDE5BB1BC1F21500548833 /* PSTCKCardParams.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = PSTCKCardParams.h; path = PublicHeaders/PSTCKCardParams.h; sourceTree = ""; }; @@ -644,6 +646,7 @@ isa = PBXGroup; children = ( 04CDB51E1A5F3A9300B854EE /* PSTCKAPIClientTest.m */, + A1B2C3D4E5F6071829304A5B /* PSTCKChargeRequeryTest.m */, 0438EF4A1B741B0100D506CC /* PSTCKCardValidatorTest.m */, 045A62AA1B8E7259000165CE /* PSTCKPaymentCardTextFieldTest.m */, 0438EF4B1B741B0100D506CC /* PSTCKPaymentCardTextFieldViewModelTest.m */, @@ -1176,6 +1179,7 @@ 08C260AF1E9C214A002AE28C /* PSTCKAuthViewController.m in Sources */, 04415C661A6605B5001225ED /* PaystackError.m in Sources */, 04415C671A6605B5001225ED /* PSTCKAPIClientTest.m in Sources */, + A1B2C3D4E5F6071829304A5C /* PSTCKChargeRequeryTest.m in Sources */, 0438EF361B7416BB00D506CC /* PSTCKPaymentCardTextField.m in Sources */, 04415C701A6605B5001225ED /* PSTCKTokenTest.m in Sources */, ); @@ -1353,9 +1357,9 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - MARKETING_VERSION = 3.0.17; + MARKETING_VERSION = 3.1.0; PRODUCT_BUNDLE_IDENTIFIER = "com.paystack.paystack-ios"; SWIFT_OBJC_BRIDGING_HEADER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -1372,9 +1376,9 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - MARKETING_VERSION = 3.0.17; + MARKETING_VERSION = 3.1.0; PRODUCT_BUNDLE_IDENTIFIER = "com.paystack.paystack-ios"; SWIFT_OBJC_BRIDGING_HEADER = ""; SWIFT_VERSION = 5.0; diff --git a/Paystack/BuildConfigurations/PaystackiOS Tests-Shared.xcconfig b/Paystack/BuildConfigurations/PaystackiOS Tests-Shared.xcconfig index c2b1731..91b4053 100644 --- a/Paystack/BuildConfigurations/PaystackiOS Tests-Shared.xcconfig +++ b/Paystack/BuildConfigurations/PaystackiOS Tests-Shared.xcconfig @@ -21,7 +21,7 @@ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR -IPHONEOS_DEPLOYMENT_TARGET = 8.0 +IPHONEOS_DEPLOYMENT_TARGET = 15.0 // Code Signing Identity @@ -43,7 +43,7 @@ CODE_SIGN_IDENTITY = iPhone Developer FRAMEWORK_SEARCH_PATHS = $(inherited) -GCC_TREAT_WARNINGS_AS_ERRORS = YES +GCC_TREAT_WARNINGS_AS_ERRORS = NO GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR diff --git a/Paystack/BuildConfigurations/PaystackiOS-Shared.xcconfig b/Paystack/BuildConfigurations/PaystackiOS-Shared.xcconfig index da2d697..c9409dd 100644 --- a/Paystack/BuildConfigurations/PaystackiOS-Shared.xcconfig +++ b/Paystack/BuildConfigurations/PaystackiOS-Shared.xcconfig @@ -74,7 +74,7 @@ DYLIB_CURRENT_VERSION = 1 DYLIB_INSTALL_NAME_BASE = @rpath -GCC_TREAT_WARNINGS_AS_ERRORS = YES +GCC_TREAT_WARNINGS_AS_ERRORS = NO GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR diff --git a/Paystack/PSTCKAPIClient+Private.h b/Paystack/PSTCKAPIClient+Private.h index 9e0377f..c2a4ff4 100644 --- a/Paystack/PSTCKAPIClient+Private.h +++ b/Paystack/PSTCKAPIClient+Private.h @@ -1,14 +1,30 @@ // // PSTCKAPIClient+Private.h // Paystack -// -// Copyright © 2016 Paystack, Inc. -// + #import +#import "PSTCKAPIClient.h" @interface PSTCKAPIClient () @property (nonatomic, readwrite, nonnull) NSURL *apiURL; @property (nonatomic, readwrite, nonnull) NSURLSession *urlSession; + +/** + * Rebuilds `urlSession` from `configuration`, adding the SDK's static headers. + * Used by init with the default configuration; tests pass one with stub protocol classes. + */ +- (void)useSessionConfiguration:(nonnull NSURLSessionConfiguration *)configuration; + +/** + * A request carrying the per-request headers every Paystack call needs: + * `Authorization: Bearer ` and `Paystack-Version: `. + * Returns nil when `publicKey` is nil or empty, so callers fail instead of sending `Bearer `. + */ +- (nullable NSMutableURLRequest *)authorizedRequestWithURL:(nonnull NSURL *)url method:(nonnull NSString *)httpMethod; + +/// Value sent as the `Paystack-Version` header: the SDK version from the project config. ++ (nonnull NSString *)paystackVersionHeaderValue; + @end diff --git a/Paystack/PSTCKAPIClient.m b/Paystack/PSTCKAPIClient.m index b387c0a..bc8fc45 100644 --- a/Paystack/PSTCKAPIClient.m +++ b/Paystack/PSTCKAPIClient.m @@ -22,6 +22,7 @@ #import "PSTCKAPIResponseDecodable.h" #import "PSTCKAuthViewController.h" #import "PSTCKAPIPostRequest.h" +#import "PSTCKAPIClient+Private.h" #import #ifdef PSTCK_STATIC_LIBRARY_BUILD @@ -35,7 +36,8 @@ static NSString *const avsEndpoint = @"charge/avs"; static NSString *const validateEndpoint = @"charge/validate"; static NSString *const requeryEndpoint = @"charge/requery/"; -static NSString *const paystackAPIVersion = @"2017-05-25"; +static const NSInteger PSTCKMaxRequeryAttempts = 12; +static const NSTimeInterval PSTCKRequeryIntervalSeconds = 5; static NSString *PSTCKDefaultPublicKey; static Boolean PROCESSING = false; @@ -56,22 +58,23 @@ + (NSString *)defaultPublicKey { @end -@interface PSTCKAPIClient() -@property (nonatomic, readwrite) NSURL *apiURL; -@property (nonatomic, readwrite) NSURLSession *urlSession; -@end - @interface PSTCKServerTransaction : NSObject @property (nonatomic, readwrite, nullable) NSString *id; @property (nonatomic, readwrite, nullable) NSString *reference; +/// The access code for this charge, from PSTCKTransactionParams.access_code (merchant backend's +/// transaction/initialize). Stored once per charge and used for every follow-up call. +@property (nonatomic, readwrite, nullable) NSString *accessCode; @end @implementation PSTCKServerTransaction - (instancetype)init { - _id = nil; - _reference = nil; - + self = [super init]; + if (self) { + _id = nil; + _reference = nil; + _accessCode = nil; + } return self; } @end @@ -89,6 +92,8 @@ @interface PSTCKAPIClient () @property(nonatomic, copy) PSTCKTransactionCompletionBlock successCompletion; @property int INVALID_DATA_SENT_RETRIES; +@property (nonatomic) NSInteger requeryAttempts; +@property (nonatomic) NSTimeInterval requeryInterval; @end @implementation PSTCKAPIClient @@ -117,19 +122,49 @@ - (instancetype)initWithPublicKey:(NSString *)publicKey { _apiURL = [NSURL URLWithString:[NSString stringWithFormat:@"https://%@", apiURLBase]]; _publicKey = [publicKey copy]; _operationQueue = [NSOperationQueue mainQueue]; - NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration]; - NSString *auth = [@"Bearer " stringByAppendingString:self.publicKey]; - config.HTTPAdditionalHeaders = @{ - @"X-Paystack-User-Agent": [self.class paystackUserAgentDetails], - @"Paystack-Version": paystackAPIVersion, - @"Authorization": auth, - @"X-Paystack-Build": PSTCKSDKBuild, - }; - _urlSession = [NSURLSession sessionWithConfiguration:config delegate:self delegateQueue:_operationQueue]; + _requeryInterval = PSTCKRequeryIntervalSeconds; + [self useSessionConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration]]; } return self; } +- (void)useSessionConfiguration:(NSURLSessionConfiguration *)configuration { + // Static headers only. Authorization and Paystack-Version are set on each request in + // -authorizedRequestWithURL:method: (Apple does not guarantee Authorization is sent when + // set through HTTPAdditionalHeaders, and a per-request header always uses the current key). + configuration.HTTPAdditionalHeaders = @{ + @"X-Paystack-User-Agent": [self.class paystackUserAgentDetails], + @"X-Paystack-Build": PSTCKSDKBuild, + }; + _urlSession = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:_operationQueue]; +} + +- (NSMutableURLRequest *)authorizedRequestWithURL:(NSURL *)url method:(NSString *)httpMethod { + if (self.publicKey.length == 0) { + return nil; + } + NSMutableURLRequest *request = [[NSMutableURLRequest alloc] initWithURL:url]; + request.HTTPMethod = httpMethod; + [request setValue:[@"Bearer " stringByAppendingString:self.publicKey] forHTTPHeaderField:@"Authorization"]; + [request setValue:[self.class paystackVersionHeaderValue] forHTTPHeaderField:@"Paystack-Version"]; + return request; +} + ++ (NSString *)paystackVersionHeaderValue { + // The SDK version from the project config: the framework's CFBundleShortVersionString, which + // Info.plist takes from $(MARKETING_VERSION) and CocoaPods from the podspec's s.version. + // Static-library builds have no bundle of their own, so fall back to PSTCKSDKVersion. + // Keep in sync with pstckPaystackVersionHeaderValue() in PSTCKAPIClientExtension.swift. + NSBundle *bundle = [NSBundle bundleForClass:[PSTCKAPIClient class]]; + if (bundle != [NSBundle mainBundle]) { + NSString *version = bundle.infoDictionary[@"CFBundleShortVersionString"]; + if (version.length > 0) { + return version; + } + } + return PSTCKSDKVersion; +} + - (void)setOperationQueue:(NSOperationQueue *)operationQueue { @@ -200,7 +235,6 @@ typedef NS_ENUM(NSInteger, PSTCKChargeStage) { PSTCKChargeStagePlusHandle, PSTCKChargeStageValidateToken, PSTCKChargeStageRequery, - PSTCKChargeStageAuthorize, PSTCKChargeStageAVS, }; @@ -213,25 +247,14 @@ - (void)chargeCard:(nonnull PSTCKCardParams *)card onViewController:(nonnull UIViewController *)viewController didEndWithError:(nonnull PSTCKErrorCompletionBlock)errorCompletion didTransactionSuccess:(nonnull PSTCKTransactionCompletionBlock)successCompletion { - NSCAssert(card != nil, @"'card' is required for a charge"); - NSCAssert(errorCompletion != nil, @"'errorCompletion' is required to handle any errors encountered while charging"); - NSCAssert(viewController != nil, @"'viewController' is required to show any alerts that may be needed"); - NSCAssert(transaction != nil, @"'transaction' is required so we may know who to charge"); - NSCAssert(successCompletion != nil, @"'successCompletion' is required so you can continue the process after charge succeeds. Remember to verify on server before giving value."); - [self startWithCard:card forTransaction:transaction onViewController:viewController didEndWithError:errorCompletion didTransactionSuccess:successCompletion]; - - if(PROCESSING){ - [self didEndWithProcessingError]; - return; - } - PROCESSING = YES; - self.INVALID_DATA_SENT_RETRIES = 0; - NSData *data = [PSTCKFormEncoder formEncryptedDataForCard:card - andTransaction:transaction - usePublicKey:[self publicKey] - onThisDevice:[self.class device_id]]; - - [self makeChargeRequest:data atStage:PSTCKChargeStageNoHandle]; + [self beginChargeWithCard:card + forTransaction:transaction + onViewController:viewController + didEndWithError:errorCompletion + didRequestValidation:nil + willPresentDialog:nil + dismissedDialog:nil + didTransactionSuccess:successCompletion]; } - (void)setProcessingStatus:(Boolean)status { @@ -246,11 +269,14 @@ - (void)chargeCard:(nonnull PSTCKCardParams *)card willPresentDialog:(nonnull PSTCKNotifyCompletionBlock)showingDialogCompletion dismissedDialog:(nonnull PSTCKNotifyCompletionBlock)dialogDismissedCompletion didTransactionSuccess:(nonnull PSTCKTransactionCompletionBlock)successCompletion { - self.beforeValidateCompletion = beforeValidateCompletion; - self.showingDialogCompletion = showingDialogCompletion; - self.dialogDismissedCompletion = dialogDismissedCompletion; - [self chargeCard:card forTransaction:transaction onViewController:viewController didEndWithError:errorCompletion didTransactionSuccess:successCompletion]; - + [self beginChargeWithCard:card + forTransaction:transaction + onViewController:viewController + didEndWithError:errorCompletion + didRequestValidation:beforeValidateCompletion + willPresentDialog:showingDialogCompletion + dismissedDialog:dialogDismissedCompletion + didTransactionSuccess:successCompletion]; } - (void)chargeCard:(nonnull PSTCKCardParams *)card @@ -260,10 +286,14 @@ - (void)chargeCard:(nonnull PSTCKCardParams *)card willPresentDialog:(nonnull PSTCKNotifyCompletionBlock)showingDialogCompletion dismissedDialog:(nonnull PSTCKNotifyCompletionBlock)dialogDismissedCompletion didTransactionSuccess:(nonnull PSTCKTransactionCompletionBlock)successCompletion { - self.showingDialogCompletion = showingDialogCompletion; - self.dialogDismissedCompletion = dialogDismissedCompletion; - [self chargeCard:card forTransaction:transaction onViewController:viewController didEndWithError:errorCompletion didTransactionSuccess:successCompletion]; - + [self beginChargeWithCard:card + forTransaction:transaction + onViewController:viewController + didEndWithError:errorCompletion + didRequestValidation:nil + willPresentDialog:showingDialogCompletion + dismissedDialog:dialogDismissedCompletion + didTransactionSuccess:successCompletion]; } - (void)chargeCard:(nonnull PSTCKCardParams *)card @@ -272,9 +302,52 @@ - (void)chargeCard:(nonnull PSTCKCardParams *)card didEndWithError:(nonnull PSTCKErrorCompletionBlock)errorCompletion didRequestValidation:(nonnull PSTCKTransactionCompletionBlock)beforeValidateCompletion didTransactionSuccess:(nonnull PSTCKTransactionCompletionBlock)successCompletion { + [self beginChargeWithCard:card + forTransaction:transaction + onViewController:viewController + didEndWithError:errorCompletion + didRequestValidation:beforeValidateCompletion + willPresentDialog:nil + dismissedDialog:nil + didTransactionSuccess:successCompletion]; +} + +/// Single entry point for every chargeCard: overload. Rejects a second charge while one is in +/// flight *before* touching any state, so it can't overwrite the running charge's callbacks or +/// access code. Optional callbacks not passed by the overload are reset to nil, so they never +/// leak in from a previous charge on the shared client. +- (void)beginChargeWithCard:(nonnull PSTCKCardParams *)card + forTransaction:(nonnull PSTCKTransactionParams *)transaction + onViewController:(nonnull UIViewController *)viewController + didEndWithError:(nonnull PSTCKErrorCompletionBlock)errorCompletion + didRequestValidation:(nullable PSTCKTransactionCompletionBlock)beforeValidateCompletion + willPresentDialog:(nullable PSTCKNotifyCompletionBlock)showingDialogCompletion + dismissedDialog:(nullable PSTCKNotifyCompletionBlock)dialogDismissedCompletion + didTransactionSuccess:(nonnull PSTCKTransactionCompletionBlock)successCompletion { + NSCAssert(card != nil, @"'card' is required for a charge"); + NSCAssert(errorCompletion != nil, @"'errorCompletion' is required to handle any errors encountered while charging"); + NSCAssert(viewController != nil, @"'viewController' is required to show any alerts that may be needed"); + NSCAssert(transaction != nil, @"'transaction' is required so we may know who to charge"); + NSCAssert(successCompletion != nil, @"'successCompletion' is required so you can continue the process after charge succeeds. Remember to verify on server before giving value."); + + if(PROCESSING){ + [self didEndWithProcessingError:errorCompletion]; + return; + } + PROCESSING = YES; + self.beforeValidateCompletion = beforeValidateCompletion; - [self chargeCard:card forTransaction:transaction onViewController:viewController didEndWithError:errorCompletion didTransactionSuccess:successCompletion]; + self.showingDialogCompletion = showingDialogCompletion; + self.dialogDismissedCompletion = dialogDismissedCompletion; + [self startWithCard:card forTransaction:transaction onViewController:viewController didEndWithError:errorCompletion didTransactionSuccess:successCompletion]; + + self.INVALID_DATA_SENT_RETRIES = 0; + NSData *data = [PSTCKFormEncoder formEncryptedDataForCard:card + andTransaction:transaction + usePublicKey:[self publicKey] + onThisDevice:[self.class device_id]]; + [self makeChargeRequest:data atStage:PSTCKChargeStageNoHandle]; } - (void)startWithCard:(nonnull PSTCKCardParams *)card @@ -288,10 +361,36 @@ - (void)startWithCard:(nonnull PSTCKCardParams *)card self.errorCompletion = errorCompletion; self.successCompletion = successCompletion; self.serverTransaction = [PSTCKServerTransaction new]; - + self.requeryAttempts = 0; + + // Store the access code for the whole charge. It comes from the merchant backend's + // transaction/initialize call via PSTCKTransactionParams.access_code; every follow-up call in + // this charge (requery now; AVS and OTP validate later) reads it from serverTransaction. + // A new serverTransaction per charge means a code is never carried into the next charge. + if (transaction.access_code.length > 0) { + self.serverTransaction.accessCode = transaction.access_code; + } +#ifdef DEBUG + else { + FAUXPAS_IGNORED_IN_METHOD(NSLogUsed); + NSLog(@"⚠️ Paystack: charging without an access_code. Initialize the transaction on your server " + @"(transaction/initialize) and set PSTCKTransactionParams.access_code. Charges without an " + @"access code will stop working in a future release."); + } +#endif } - +/// The identifier used in charge/requery/{identifier}: the access code, or (temporary fallback for +/// app-initialized charges) the server transaction id. Nil when neither is known. +- (nullable NSString *)requeryIdentifier { + if (self.serverTransaction.accessCode.length > 0) { + return self.serverTransaction.accessCode; + } + if (self.serverTransaction.id.length > 0) { + return self.serverTransaction.id; + } + return nil; +} - (void) makeChargeRequest:(NSData *)data atStage:(PSTCKChargeStage) stage @@ -310,11 +409,17 @@ - (void) makeChargeRequest:(NSData *)data endpoint = validateEndpoint; httpMethod = @"POST"; break; - case PSTCKChargeStageRequery: - case PSTCKChargeStageAuthorize: - endpoint = [requeryEndpoint stringByAppendingString:self.serverTransaction.id] ; + case PSTCKChargeStageRequery: { + NSString *identifier = [self requeryIdentifier]; + if (identifier == nil) { + // Previously a nil transaction id threw in stringByAppendingString:. + [self didEndWithErrorMessage:@"Could not check the transaction status because no access code or transaction id is available."]; + return; + } + endpoint = [requeryEndpoint stringByAppendingString:identifier]; httpMethod = @"GET"; break; + } case PSTCKChargeStageAVS: endpoint = avsEndpoint; httpMethod = @"POST"; @@ -491,33 +596,55 @@ - (void) handleResponse:(PSTCKTransaction * _Nonnull)responseObject{ } else if([[responseObject status] isEqual:@"2"] || [[responseObject auth].lowercaseString isEqual:@"pin"]){ [self requestPin]; return; - } else if([self.serverTransaction id] != nil){ - if([[responseObject auth].lowercaseString isEqual:@"3ds"] && [self validUrl:[responseObject otpmessage]]){ - [self requestAuth:[responseObject otpmessage]]; - return; - } else if([[responseObject status] isEqual:@"3"] - || ([[responseObject auth].lowercaseString isEqual:@"otp"] && [responseObject otpmessage] != nil) - || ([[responseObject auth].lowercaseString isEqual:@"phone"] && [responseObject otpmessage] != nil)){ - [self requestOtp:([responseObject otpmessage] != nil ? [responseObject otpmessage] : [responseObject message])]; - return; - } else if([[responseObject status].lowercaseString isEqual:@"requery"]) { - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC), - dispatch_get_main_queue(), ^{ - [self.operationQueue addOperationWithBlock:^{ - [self makeChargeRequest:nil - atStage:PSTCKChargeStageRequery]; - }]; - }); + } else { + if([self.serverTransaction id] != nil){ + if([[responseObject auth].lowercaseString isEqual:@"3ds"] && [self validUrl:[responseObject otpmessage]]){ + [self requestAuth:[responseObject otpmessage]]; + return; + } else if([[responseObject status] isEqual:@"3"] + || ([[responseObject auth].lowercaseString isEqual:@"otp"] && [responseObject otpmessage] != nil) + || ([[responseObject auth].lowercaseString isEqual:@"phone"] && [responseObject otpmessage] != nil)){ + [self requestOtp:([responseObject otpmessage] != nil ? [responseObject otpmessage] : [responseObject message])]; + return; + } + } + // Not final yet: poll charge/requery/{access code} until a terminal status or the cap. + if([self.class isPendingStatus:[responseObject status]]) { + [self scheduleRequery]; return; } } - if([[responseObject status] isEqual:@"0"] || [[responseObject status] isEqual:@"error"] || [[responseObject status] isEqual:@"timeout"]){ - [self didEndWithErrorMessage:[responseObject message]]; + if([[responseObject status] isEqual:@"0"] || [[responseObject status] isEqual:@"error"] + || [[responseObject status] isEqual:@"timeout"] || [[responseObject status].lowercaseString isEqual:@"failed"]){ + [self didEndWithErrorMessage:[responseObject message] ?: @"The transaction failed."]; } else { // this is an invalid status - [self didEndWithErrorMessage:[@"The response status from Paystack had an unknown status. Status was: " stringByAppendingString:[responseObject status]]]; + [self didEndWithErrorMessage:[@"The response status from Paystack had an unknown status. Status was: " stringByAppendingString:[responseObject status] ?: @"(none)"]]; + } +} + +/// "pending" (requery by access code) and the legacy "requery" both mean: ask again later. ++ (BOOL)isPendingStatus:(nullable NSString *)status { + NSString *lowercased = status.lowercaseString; + return [lowercased isEqualToString:@"pending"] || [lowercased isEqualToString:@"requery"]; +} + +/// Polls requery again after requeryInterval, up to PSTCKMaxRequeryAttempts. Terminal statuses +/// (success, failed, error) and request errors end the charge in handleResponse/makeChargeRequest. +- (void)scheduleRequery { + if (self.requeryAttempts >= PSTCKMaxRequeryAttempts) { + [self didEndWithErrorMessage:@"The transaction is still pending. Verify its final status on your server using the transaction reference."]; + return; } + self.requeryAttempts += 1; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(self.requeryInterval * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + [self.operationQueue addOperationWithBlock:^{ + [self makeChargeRequest:nil + atStage:PSTCKChargeStageRequery]; + }]; + }); } - (Boolean) validUrl:(NSString *) candidate{ @@ -582,13 +709,15 @@ - (void)didEndWithErrorMessage:(NSString *)errorString{ [self didEndWithError:[[NSError alloc] initWithDomain:PaystackDomain code:PSTCKCardErrorProcessingError userInfo:userInfo]]; } -- (void)didEndWithProcessingError{ +/// Reports a rejected concurrent charge to *that* caller's error block, leaving the in-flight +/// charge's callbacks and state untouched. +- (void)didEndWithProcessingError:(nonnull PSTCKErrorCompletionBlock)errorCompletion{ NSDictionary *userInfo = @{ NSLocalizedDescriptionKey: PSTCKCardErrorProcessingTransactionMessage, PSTCKErrorMessageKey: PSTCKCardErrorProcessingTransactionMessage }; [self.operationQueue addOperationWithBlock:^{ - self.errorCompletion([[NSError alloc] initWithDomain:PaystackDomain code:PSTCKConflictError userInfo:userInfo], self.serverTransaction.reference); + errorCompletion([[NSError alloc] initWithDomain:PaystackDomain code:PSTCKConflictError userInfo:userInfo], nil); }]; } diff --git a/Paystack/PSTCKAPIClientExtension.swift b/Paystack/PSTCKAPIClientExtension.swift index 6138f70..7bef378 100644 --- a/Paystack/PSTCKAPIClientExtension.swift +++ b/Paystack/PSTCKAPIClientExtension.swift @@ -1,39 +1,62 @@ // // PSTCKAPIClientExtension.swift // PaystackiOS -// -// Created by Jubril Olambiwonnu on 6/19/20. -// Copyright © 2020 Paystack, Inc. All rights reserved. -// import Foundation @objc extension PSTCKAPIClient { - public func fetchStates(country: String, completion: @escaping ([PSTCKState], Error?) -> Void) { - let url = URL(string: "https://api.paystack.co/address_verification/states?country=\(country)")! + public func fetchStates(country: String, completion: @escaping ([PSTCKState], Error?) -> Void) { + var components = URLComponents(string: "https://api.paystack.co/address_verification/states") + components?.queryItems = [URLQueryItem(name: "country", value: country)] + guard let url = components?.url else { + completion([PSTCKState](), StringError("Could not build the address verification request")) + return + } + guard let key = publicKey, !key.isEmpty else { + completion([PSTCKState](), StringError("A Paystack public key is required")) + return + } + // Same per-request headers as PSTCKAPIClient -authorizedRequestWithURL:method: (ObjC, private). var request = URLRequest(url: url) request.httpMethod = "GET" - URLSession.shared.dataTask(with: request, completionHandler: { data, response, error in - guard let data = data, error == nil else { + request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization") + request.setValue(pstckPaystackVersionHeaderValue(), forHTTPHeaderField: "Paystack-Version") + URLSession.shared.dataTask(with: request, completionHandler: { data, _, error in + if let error = error { completion([PSTCKState](), error) return } - let responseJSON = try? JSONSerialization.jsonObject(with: data, options: []) - if let responseJSON = responseJSON as? [String: Any] { - guard responseJSON["status"] as? Bool == true else { - completion([PSTCKState](), StringError(responseJSON["message"] as? String ?? "Could not fetch issuing country states")) - return - } - if let data = responseJSON["data"] as? [[String : Any]] { - let states = data.compactMap{PSTCKState(dict: $0)} - completion(states, nil) - } + guard let data = data, + let responseJSON = (try? JSONSerialization.jsonObject(with: data, options: [])) as? [String: Any] else { + completion([PSTCKState](), StringError("Could not fetch issuing country states")) + return + } + guard responseJSON["status"] as? Bool == true else { + completion([PSTCKState](), StringError(responseJSON["message"] as? String ?? "Could not fetch issuing country states")) + return + } + guard let items = responseJSON["data"] as? [[String: Any]] else { + completion([PSTCKState](), StringError("Could not fetch issuing country states")) + return } + completion(items.compactMap { PSTCKState(dict: $0) }, nil) }).resume() } } +/// Mirrors +[PSTCKAPIClient paystackVersionHeaderValue]: the SDK version from the project config +/// (framework CFBundleShortVersionString), falling back to PSTCKSDKVersion for static builds. +fileprivate func pstckPaystackVersionHeaderValue() -> String { + let bundle = Bundle(for: PSTCKAPIClient.self) + if bundle != Bundle.main, + let version = bundle.infoDictionary?["CFBundleShortVersionString"] as? String, + !version.isEmpty { + return version + } + return PSTCKSDKVersion +} + @objc public class PSTCKState: NSObject { public var name: String diff --git a/Paystack/PSTCKAPIPostRequest.m b/Paystack/PSTCKAPIPostRequest.m index 32fcf53..63d6903 100644 --- a/Paystack/PSTCKAPIPostRequest.m +++ b/Paystack/PSTCKAPIPostRequest.m @@ -18,15 +18,23 @@ + (void)startWithAPIClient:(PSTCKAPIClient *)apiClient completion:(void (^)(id, NSError *))completion { NSURL *url = [apiClient.apiURL URLByAppendingPathComponent:endpoint]; - NSMutableURLRequest *request = [[NSMutableURLRequest alloc] initWithURL:url]; - request.HTTPMethod = httpMethod; // @"POST" + NSMutableURLRequest *request = [apiClient authorizedRequestWithURL:url method:httpMethod]; + if (request == nil) { + NSString *message = @"A Paystack public key is required. Call [Paystack setDefaultPublicKey:] or use initWithPublicKey:."; + NSError *keyError = [[NSError alloc] initWithDomain:PaystackDomain + code:PSTCKAPIError + userInfo:@{NSLocalizedDescriptionKey: message, PSTCKErrorMessageKey: message}]; + [apiClient.operationQueue addOperationWithBlock:^{ + completion(nil, keyError); + }]; + return; + } request.HTTPBody = postData; -// NSLog(@"%@",postData); [[apiClient.urlSession dataTaskWithRequest:request completionHandler:^(NSData * _Nullable body, __unused NSURLResponse * _Nullable response, NSError * _Nullable error) { NSError *someerror; NSDictionary *jsonDictionary = body ? [NSJSONSerialization JSONObjectWithData:body options:NSJSONReadingAllowFragments error:&someerror] : nil; - NSString *bodyString = [[NSString alloc] initWithData:body encoding:NSUTF8StringEncoding]; + NSString *bodyString = (body ? [[NSString alloc] initWithData:body encoding:NSUTF8StringEncoding] : nil) ?: @""; id responseObject = [[serializer class] decodedObjectFromAPIResponse:jsonDictionary]; NSError *returnedError = error; if (!responseObject && !returnedError) { diff --git a/Paystack/PublicHeaders/PSTCKAPIClient.h b/Paystack/PublicHeaders/PSTCKAPIClient.h index fd08463..53dcb07 100644 --- a/Paystack/PublicHeaders/PSTCKAPIClient.h +++ b/Paystack/PublicHeaders/PSTCKAPIClient.h @@ -8,7 +8,7 @@ #import #endif -static NSString *const __nonnull PSTCKSDKVersion = @"3.0.17"; +static NSString *const __nonnull PSTCKSDKVersion = @"3.1.0"; static NSString *const __nonnull PSTCKSDKBuild = @"1"; @class PSTCKCard, PSTCKCardParams, PSTCKTransactionParams, PSTCKToken, PSTCKState; diff --git a/Paystack/PublicHeaders/PSTCKCard.h b/Paystack/PublicHeaders/PSTCKCard.h index b95d492..f67f339 100644 --- a/Paystack/PublicHeaders/PSTCKCard.h +++ b/Paystack/PublicHeaders/PSTCKCard.h @@ -90,7 +90,7 @@ typedef NS_ENUM(NSInteger, PSTCKCardFundingType) { * A proxy for the card's number, this uniquely identifies the credit card and can be used to compare different cards. * @deprecated This field will no longer be present in responses when using your public key. If you want to access the value of this field, you can look it up on your backend using your secret key. */ -@property (nonatomic, readonly, nullable) NSString *fingerprint __attribute__((deprecated("This field will no longer be present in responses when using your public key. If you want to access the value of this field, you can look it up on your backend using your secret key."))); +@property (nonatomic, readonly, nullable) NSString *fingerprint __attribute__((deprecated("This SDK has been deprecated, Please refer to our new SDK: https://github.com/PaystackHQ/paystack-sdk-ios (this field is not returned when using your public key; look it up on your backend with your secret key)"))); /** * Two-letter ISO code representing the issuing country of the card. @@ -104,7 +104,7 @@ typedef NS_ENUM(NSInteger, PSTCKCardFundingType) { #pragma mark - deprecated properties -#define DEPRECATED_IN_FAVOR_OF_PSTCKCARDPARAMS __attribute__((deprecated("For collecting your users' credit card details, you should use an PSTCKCardParams object instead of an PSTCKCard."))) +#define DEPRECATED_IN_FAVOR_OF_PSTCKCARDPARAMS __attribute__((deprecated("This SDK has been deprecated, Please refer to our new SDK: https://github.com/PaystackHQ/paystack-sdk-ios (to collect card details in this SDK, use PSTCKCardParams instead of PSTCKCard)"))) @property (nonatomic, copy, nullable) NSString *number DEPRECATED_IN_FAVOR_OF_PSTCKCARDPARAMS; @property (nonatomic, copy, nullable) NSString *cvc DEPRECATED_IN_FAVOR_OF_PSTCKCARDPARAMS; diff --git a/README.md b/README.md index 4e1ca48..e48b9ea 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,90 @@ -# Paystack iOS SDK - +# Paystack iOS SDK (legacy) [![CocoaPods](https://img.shields.io/cocoapods/v/Paystack.svg?style=flat)](https://cocoapods.org/pods/Paystack) -[![Carthage compatible](https://img.shields.io/badge/Carthage-compatible-4BC51D.svg?style=flat)](https://github.com/Carthage/Carthage) [![CocoaPods](https://img.shields.io/cocoapods/l/Paystack.svg?style=flat)](https://github.com/paystackhq/paystack-ios/blob/master/LICENSE) [![CocoaPods](https://img.shields.io/cocoapods/p/Paystack.svg?style=flat)](https://github.com/paystackhq/paystack-ios#) -The Paystack iOS SDK make it easy to collect your users' credit card details inside your iOS app. By charging the card immediately on our -servers, Paystack handles the bulk of PCI compliance by preventing sensitive card data from hitting your server. +> **This SDK is deprecated.** It receives maintenance fixes only. New integrations should use the new Paystack iOS SDK: +> **https://github.com/PaystackHQ/paystack-sdk-ios**. The `chargeCard` methods and other key APIs carry a deprecation warning pointing there. +> The warnings are warnings only: your app keeps building unless you treat warnings as errors. -This library helps collect card details on iOS, completing a charge. This shoulders the burden of PCI compliance by helping you avoid the -need to send card data directly to your server. Instead you send to Paystack's server and get a reference which you can verify in your -server-side code. The verify call returns an `authorization_code`. Subsequent charges can then be made using the `authorization_code`. +> **CocoaPods trunk stops accepting new versions on 2 December 2026.** CocoaPods is moving its central Specs repo to read-only. +> Versions already published stay installable with `pod install`, but no new versions can be published after that date. +> See the CocoaPods announcement: [CocoaPods Specs Repo](https://blog.cocoapods.org/CocoaPods-Specs-Repo/). +> To keep getting updates, move to the [new SDK](https://github.com/PaystackHQ/paystack-sdk-ios), which supports Swift Package Manager. +The Paystack iOS SDK makes it easy to collect your users' card details inside your iOS app and charge them on Paystack's servers, +so sensitive card data never touches your server. You get a transaction reference back, which you verify from your server-side code. -## Requirements -Our SDK is compatible with iOS apps supporting iOS 8.0 and above. It requires Xcode 8.0+ to build the source. +## What's new in 3.1.0 -**You will also need to add Keychain Sharing entitlements for your app.** +- **Requery by access code.** Status checks now call `charge/requery/{access_code}`, using the access code you pass in + `PSTCKTransactionParams.access_code`. Charges started without an access code temporarily fall back to the transaction id. That fallback + will be removed in a future release, so initialize transactions on your server and always pass `access_code`. +- **Pending charges are polled.** A `pending` status is checked again every 5 seconds, up to 12 times (60 seconds), then the charge ends with + an error that includes the reference so you can verify it on your server. +- **Public key on every request.** `Authorization: Bearer ` and `Paystack-Version` (the SDK version) are now sent on every call, + including the address-verification lookup. Changing `publicKey` on a client takes effect on the next request. +- **Fixes:** a second `chargeCard` call while one is running no longer overwrites the running charge's callbacks; crashes on a missing + transaction id and on non-UTF-8 responses are fixed; the address-verification lookup always completes. +- **Build updates:** minimum iOS raised to 15.0; builds with current Xcode. See [Requirements](#requirements). -## Integration +No public API changed. See the [CHANGELOG](CHANGELOG) for the full list. -We've written a [guide](GUIDE.md) that explains everything from installation, to charging cards and more. +## Requirements -## Example app +| | Minimum | +| --- | --- | +| iOS deployment target | **15.0** (was 11.0). Apps supporting older iOS versions should stay on 3.0.17 | +| Xcode | **Xcode 26** or later (latest stable recommended) | +| Swift | 5.0 | +| Entitlements | **Keychain Sharing** must be enabled for your app (the SDK uses the keychain to encrypt card data) | -There is an example app included in the repository: -- Paystack iOS Example shows a minimal Swift integration with our iOS SDK using PSTCKPaymentCardTextField, a native credit card UI form component we provide. It uses a small example backend to make charges. +Building with the current iOS SDK also requires your app to use the **UIScene life cycle**: apps without a scene manifest fail to launch +with "UIScene life cycle is required for apps built with this SDK". The example app shows the setup (`SceneDelegate` in `AppDelegate.swift` +and `UIApplicationSceneManifest` in `Info.plist`). See Apple's +[Transitioning to the UIKit scene-based life cycle](https://developer.apple.com/documentation/uikit/transitioning-to-the-uikit-scene-based-life-cycle). -To build and run the example apps, open `Paystack.xcworkspace` and choose the appropriate scheme. +## Integration -### Getting started with the Simple iOS Example App +The [guide](GUIDE.md) covers installation, charging cards and handling each authentication step. In short: -Note: The example app requires Xcode 8.0 to build and run. +1. Your **server** calls [`transaction/initialize`](https://paystack.com/docs/api/transaction/#initialize) with your secret key and returns + `data.access_code` to the app. +2. Your **app** sets the public key once: `Paystack.setDefaultPublicKey("pk_test_…")`. +3. Your **app** sets `transactionParams.access_code` and calls `PSTCKAPIClient.shared().chargeCard(…)`. The SDK handles PIN, OTP, 3DS and + address verification. +4. Your **server** verifies the returned reference with [`transaction/verify`](https://paystack.com/docs/api/transaction/#verify) before + giving value. -Before you can run the app, you need to provide it with your Paystack public key. +## Example app -1. If you haven't already, sign up for a [Paystack account](https://dashboard.paystack.com/#/signup) (it takes seconds). Then go to https://dashboard.paystack.co/#/settings/developer. -2. Replace the `paystackPublicKey` constant in ViewController.swift (for the Sample app) with your Test Public Key. -3. Head to https://github.com/paystackhq/sample-charge-card-backend and click "Deploy to Heroku" (you may have to sign up for a Heroku account as part of this process). Provide your Paystack test secret key for the `PAYSTACK_TEST_SECRET_KEY` field under 'Env'. Click "Deploy for Free". -4. Replace the `backendURLString` variable in the example iOS app with the app URL Heroku provides you with (e.g. "https://my-example-app.herokuapp.com") **WITHOUT THE TRAILING '/'** +`Example/Paystack iOS Example` is a minimal Swift integration using `PSTCKPaymentCardTextField`. It **doesn't need a backend**: it charges +with a public key and an access code that you paste into `ViewController.swift`. -### Making a test Charge +1. Get your **test** public key from the [Paystack Dashboard](https://dashboard.paystack.com/#/settings/developer). +2. Initialize a test transaction with your **test** secret key (for example with `curl` against `transaction/initialize`) and copy + `data.access_code`. +3. In `ViewController.swift`, set `paystackPublicKey` (under `// set Public key here`) and `accessCode` (under `// set access code here`). +4. Open `Paystack.xcworkspace`, choose the example scheme and run. -After completing the steps required above, you can (and should) test your implementation of the Paystack iOS library in your iOS app. You need the details of an actual debit/credit card to do this, so we provide ##_test cards_## for your use instead of using your own debit/credit cards. +Access codes are single-use: initialize a new transaction for each test charge. The key and the access code must be from the same mode +(test or live). Don't commit real keys or access codes. -You will find test cards on [this Paystack documentation page](https://developers.paystack.co/docs/test-cards). +### Making a test charge -To try out the OTP flow, we have provided a test "verve" card: +Use Paystack's [test cards](https://paystack.com/docs/payments/test-payments/) instead of a real card. To try the PIN + OTP flow: ``` -50606 66666 66666 6666 +5060 6666 6666 6666 666 CVV: 123 PIN: 1234 -TOKEN: 123456 +OTP: 123456 ``` -You can then view the payments in your Paystack Dashboard! - -## Misc. notes - -### Handling errors - -See [PaystackError.h](https://github.com/paystackhq/paystack-ios/blob/master/Paystack/PublicHeaders/PaystackError.h) for a list of error codes that may be returned from the Paystack API. +You can then see the payments in your Paystack Dashboard. +## Handling errors +See [PaystackError.h](Paystack/PublicHeaders/PaystackError.h) for the error codes the SDK can return, for example +`PSTCKExpiredAccessCodeError` when an access code has already been used, and `PSTCKConflictError` when a charge is started while another +is in progress. diff --git a/Tests/Tests/PSTCKChargeRequeryTest.m b/Tests/Tests/PSTCKChargeRequeryTest.m new file mode 100644 index 0000000..8386644 --- /dev/null +++ b/Tests/Tests/PSTCKChargeRequeryTest.m @@ -0,0 +1,276 @@ +// +// PSTCKChargeRequeryTest.m +// Paystack +// +// Requery by access code (with the temporary trans fallback), status-based polling, +// and the per-request Authorization / Paystack-Version headers. +// + +@import XCTest; +@import UIKit; + +#import "PSTCKAPIClient.h" +#import "PSTCKAPIClient+Private.h" +#import "PSTCKCardParams.h" +#import "PSTCKTransactionParams.h" +#import "PaystackError.h" + +#pragma mark - Stub network layer + +typedef NSDictionary * _Nonnull (^PSTCKStubResponder)(NSURLRequest * _Nonnull request); + +static PSTCKStubResponder PSTCKStubCurrentResponder; +static NSMutableArray *PSTCKStubRecordedRequests; + +/// Records every request and answers with the JSON the current responder returns. +@interface PSTCKStubURLProtocol : NSURLProtocol +@end + +@implementation PSTCKStubURLProtocol + ++ (BOOL)canInitWithRequest:(__unused NSURLRequest *)request { + return YES; +} + ++ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request { + return request; +} + +- (void)startLoading { + @synchronized ([PSTCKStubURLProtocol class]) { + [PSTCKStubRecordedRequests addObject:self.request]; + } + NSDictionary *json = PSTCKStubCurrentResponder ? PSTCKStubCurrentResponder(self.request) : @{@"status": @"0", @"message": @"no stub"}; + NSData *body = [NSJSONSerialization dataWithJSONObject:json options:0 error:NULL]; + NSHTTPURLResponse *response = [[NSHTTPURLResponse alloc] initWithURL:self.request.URL + statusCode:200 + HTTPVersion:@"HTTP/1.1" + headerFields:@{@"Content-Type": @"application/json"}]; + [self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed]; + [self.client URLProtocol:self didLoadData:body]; + [self.client URLProtocolDidFinishLoading:self]; +} + +- (void)stopLoading { +} + +@end + +#pragma mark - Private API under test + +typedef NS_ENUM(NSInteger, PSTCKTestChargeStage) { + PSTCKTestChargeStageRequery = 3, // matches PSTCKChargeStageRequery in PSTCKAPIClient.m +}; + +@interface PSTCKAPIClient (RequeryTesting) +@property (nonatomic) NSTimeInterval requeryInterval; +- (void)startWithCard:(nonnull PSTCKCardParams *)card + forTransaction:(nonnull PSTCKTransactionParams *)transaction + onViewController:(nonnull UIViewController *)viewController + didEndWithError:(nonnull PSTCKErrorCompletionBlock)errorCompletion +didTransactionSuccess:(nonnull PSTCKTransactionCompletionBlock)successCompletion; +- (void)makeChargeRequest:(nullable NSData *)data atStage:(NSInteger)stage; +- (nullable NSString *)requeryIdentifier; +- (id)serverTransaction; +@end + +#pragma mark - Tests + +/// Collects how a charge ended (blocks write here instead of through stack pointers). +@interface PSTCKChargeOutcome : NSObject +@property (nonatomic) BOOL succeeded; +@property (nonatomic, nullable) NSError *error; +@end +@implementation PSTCKChargeOutcome +@end + +@interface PSTCKChargeRequeryTest : XCTestCase +@property (nonatomic) PSTCKAPIClient *client; +@end + +@implementation PSTCKChargeRequeryTest + +- (void)setUp { + [super setUp]; + PSTCKStubRecordedRequests = [NSMutableArray array]; + PSTCKStubCurrentResponder = nil; + self.client = [[PSTCKAPIClient alloc] initWithPublicKey:@"pk_test_requery"]; + NSURLSessionConfiguration *config = [NSURLSessionConfiguration ephemeralSessionConfiguration]; + config.protocolClasses = @[[PSTCKStubURLProtocol class]]; + [self.client useSessionConfiguration:config]; + self.client.requeryInterval = 0.01; +} + +- (void)tearDown { + PSTCKStubCurrentResponder = nil; + [super tearDown]; +} + +/// Starts a charge's bookkeeping without the encrypted mobile_charge call (no keychain needed). +- (PSTCKChargeOutcome *)startChargeWithAccessCode:(NSString *)accessCode + expectation:(XCTestExpectation *)expectation { + PSTCKChargeOutcome *outcome = [PSTCKChargeOutcome new]; + PSTCKTransactionParams *params = [PSTCKTransactionParams new]; + if (accessCode) { + params.access_code = accessCode; + } + [self.client startWithCard:[PSTCKCardParams new] + forTransaction:params + onViewController:[UIViewController new] + didEndWithError:^(NSError * _Nonnull error, __unused NSString * _Nullable reference) { + outcome.error = error; + [expectation fulfill]; + } didTransactionSuccess:^(__unused NSString * _Nonnull reference) { + outcome.succeeded = YES; + [expectation fulfill]; + }]; + return outcome; +} + +- (NSArray *)requeryRequests { + NSPredicate *isRequery = [NSPredicate predicateWithBlock:^BOOL(NSURLRequest *request, __unused NSDictionary *bindings) { + return [request.URL.path hasPrefix:@"/charge/requery/"]; + }]; + return [PSTCKStubRecordedRequests filteredArrayUsingPredicate:isRequery]; +} + +#pragma mark Access code resolution + +- (void)testRequeryUsesAccessCode { + PSTCKStubCurrentResponder = ^NSDictionary *(__unused NSURLRequest *request) { + return @{@"status": @"success", @"message": @"Approved", @"reference": @"ref_1"}; + }; + XCTestExpectation *done = [self expectationWithDescription:@"charge ends"]; + PSTCKChargeOutcome *outcome = [self startChargeWithAccessCode:@"ac_123" expectation:done]; + [[self.client serverTransaction] setValue:@"trans_999" forKey:@"id"]; + + [self.client makeChargeRequest:nil atStage:PSTCKTestChargeStageRequery]; + [self waitForExpectationsWithTimeout:2 handler:nil]; + + XCTAssertTrue(outcome.succeeded); + XCTAssertEqualObjects([self requeryRequests].firstObject.URL.path, @"/charge/requery/ac_123"); + XCTAssertEqualObjects([self requeryRequests].firstObject.HTTPMethod, @"GET"); +} + +- (void)testRequeryFallsBackToTransWithoutAccessCode { + PSTCKStubCurrentResponder = ^NSDictionary *(__unused NSURLRequest *request) { + return @{@"status": @"success", @"message": @"Approved"}; + }; + XCTestExpectation *done = [self expectationWithDescription:@"charge ends"]; + [self startChargeWithAccessCode:nil expectation:done]; + [[self.client serverTransaction] setValue:@"trans_999" forKey:@"id"]; + + XCTAssertEqualObjects([self.client requeryIdentifier], @"trans_999"); + [self.client makeChargeRequest:nil atStage:PSTCKTestChargeStageRequery]; + [self waitForExpectationsWithTimeout:2 handler:nil]; + + XCTAssertEqualObjects([self requeryRequests].firstObject.URL.path, @"/charge/requery/trans_999"); +} + +- (void)testRequeryWithoutAnyIdentifierFailsWithoutCrashing { + XCTestExpectation *done = [self expectationWithDescription:@"charge ends"]; + PSTCKChargeOutcome *outcome = [self startChargeWithAccessCode:nil expectation:done]; + + XCTAssertNil([self.client requeryIdentifier]); + [self.client makeChargeRequest:nil atStage:PSTCKTestChargeStageRequery]; + [self waitForExpectationsWithTimeout:2 handler:nil]; + + XCTAssertNotNil(outcome.error); + XCTAssertEqual([self requeryRequests].count, (NSUInteger)0); +} + +- (void)testAccessCodeIsNotCarriedIntoNextCharge { + XCTestExpectation *unused = [self expectationWithDescription:@"unused"]; + unused.inverted = YES; + [self startChargeWithAccessCode:@"ac_first" expectation:unused]; + XCTAssertEqualObjects([self.client requeryIdentifier], @"ac_first"); + + [self startChargeWithAccessCode:nil expectation:unused]; + XCTAssertNil([self.client requeryIdentifier]); + [self waitForExpectationsWithTimeout:0.1 handler:nil]; +} + +#pragma mark Status-based polling + +- (void)testPendingRequeriesUntilSuccess { + __block NSInteger calls = 0; + PSTCKStubCurrentResponder = ^NSDictionary *(__unused NSURLRequest *request) { + calls += 1; + return calls < 3 ? @{@"status": @"pending", @"message": @"Pending"} : @{@"status": @"success", @"message": @"Approved"}; + }; + XCTestExpectation *done = [self expectationWithDescription:@"charge ends"]; + PSTCKChargeOutcome *outcome = [self startChargeWithAccessCode:@"ac_123" expectation:done]; + + [self.client makeChargeRequest:nil atStage:PSTCKTestChargeStageRequery]; + [self waitForExpectationsWithTimeout:5 handler:nil]; + + XCTAssertTrue(outcome.succeeded); + XCTAssertEqual([self requeryRequests].count, (NSUInteger)3); +} + +- (void)testFailedStatusIsTerminal { + PSTCKStubCurrentResponder = ^NSDictionary *(__unused NSURLRequest *request) { + return @{@"status": @"failed", @"message": @"Declined"}; + }; + XCTestExpectation *done = [self expectationWithDescription:@"charge ends"]; + PSTCKChargeOutcome *outcome = [self startChargeWithAccessCode:@"ac_123" expectation:done]; + + [self.client makeChargeRequest:nil atStage:PSTCKTestChargeStageRequery]; + [self waitForExpectationsWithTimeout:2 handler:nil]; + + XCTAssertEqualObjects(outcome.error.localizedDescription, @"Declined"); + XCTAssertEqual([self requeryRequests].count, (NSUInteger)1); +} + +- (void)testPendingStopsAtAttemptCap { + PSTCKStubCurrentResponder = ^NSDictionary *(__unused NSURLRequest *request) { + return @{@"status": @"pending", @"message": @"Pending"}; + }; + XCTestExpectation *done = [self expectationWithDescription:@"charge ends"]; + PSTCKChargeOutcome *outcome = [self startChargeWithAccessCode:@"ac_123" expectation:done]; + + [self.client makeChargeRequest:nil atStage:PSTCKTestChargeStageRequery]; + [self waitForExpectationsWithTimeout:5 handler:nil]; + + XCTAssertNotNil(outcome.error); + // The first requery plus 12 scheduled retries. + XCTAssertEqual([self requeryRequests].count, (NSUInteger)13); +} + +#pragma mark Headers + +- (void)testEveryRequestCarriesAuthorizationAndVersionHeaders { + PSTCKStubCurrentResponder = ^NSDictionary *(__unused NSURLRequest *request) { + return @{@"status": @"success", @"message": @"Approved"}; + }; + XCTestExpectation *done = [self expectationWithDescription:@"charge ends"]; + [self startChargeWithAccessCode:@"ac_123" expectation:done]; + + [self.client makeChargeRequest:nil atStage:PSTCKTestChargeStageRequery]; + [self waitForExpectationsWithTimeout:2 handler:nil]; + + NSURLRequest *request = [self requeryRequests].firstObject; + XCTAssertEqualObjects([request valueForHTTPHeaderField:@"Authorization"], @"Bearer pk_test_requery"); + XCTAssertEqualObjects([request valueForHTTPHeaderField:@"Paystack-Version"], [PSTCKAPIClient paystackVersionHeaderValue]); +} + +- (void)testAuthorizationHeaderFollowsCurrentPublicKey { + self.client.publicKey = @"pk_test_rotated"; + NSURLRequest *request = [self.client authorizedRequestWithURL:[NSURL URLWithString:@"https://standard.paystack.co/charge/requery/x"] + method:@"GET"]; + XCTAssertEqualObjects([request valueForHTTPHeaderField:@"Authorization"], @"Bearer pk_test_rotated"); +} + +- (void)testNoRequestIsBuiltWithoutPublicKey { + self.client.publicKey = @""; + XCTAssertNil([self.client authorizedRequestWithURL:[NSURL URLWithString:@"https://standard.paystack.co/charge/requery/x"] + method:@"GET"]); +} + +- (void)testPaystackVersionIsSemanticVersionNotDate { + NSString *version = [PSTCKAPIClient paystackVersionHeaderValue]; + XCTAssertFalse([version isEqualToString:@"2017-05-25"]); + XCTAssertTrue([version componentsSeparatedByString:@"."].count >= 2, @"expected x.y.z, got %@", version); +} + +@end diff --git a/VERSION b/VERSION index eea30e5..fd2a018 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.0.13 +3.1.0