commit c124d500c0586358992d01abc869763079fea5ea Author: codinronan Date: Thu Mar 21 17:47:00 2019 -0600 feat(ios): add initial API and iOS implementation diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f321b48 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +*.csproj.user +*.suo +*.cache +Thumbs.db +*.DS_Store + +*.bak +*.cache +*.log +*.swp +*.user + + +scripts/ios/build/ +scripts/ios/Pods + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..02e7783 --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# Stripe Native Payments +Plugin for Cordova to use the [native android SDK](https://github.com/stripe/stripe-android) in Java and the [native iOS SDK](https://github.com/stripe/stripe-ios) using Swift from [Stripe](https://www.stripe.com/) + +## Installing the plugin ## +``` +cordova plugin add cordova-plugin-filepickerio --save +``` + +## Using the plugin ## + + +### Response format ## + + +## Difference between iOS and Android + + + +## Contributing + +Thanks for considering contributing to this project. + +### Finding something to do + +Ask, or pick an issue and comment on it announcing your desire to work on it. Ideally wait until we assign it to you to minimize work duplication. + +### Reporting an issue + +- Search existing issues before raising a new one. + +- Include as much detail as possible. + +### Pull requests + +- Make it clear in the issue tracker what you are working on, so that someone else doesn't duplicate the work. + +- Use a feature branch, not master. + +- Rebase your feature branch onto origin/master before raising the PR. + +- Keep up to date with changes in master so your PR is easy to merge. + +- Be descriptive in your PR message: what is it for, why is it needed, etc. + +- Make sure the tests pass + +- Squash related commits as much as possible. + +### Coding style + +- Try to match the existing indent style. + +- Don't mix platform-specific stuff into the main code. + + + +## Licence ## + +The MIT License + +Copyright (c) 2019 Rolamix Inc + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/package.json b/package.json new file mode 100644 index 0000000..04944f4 --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "cordova-plugin-stripe-payments", + "description": "Stripe Card Entry plugin for Cordova. Available for Android and iOS.", + "version": "0.0.5", + "homepage": "https://github.com/rolamix/cordova-plugin-stripe-payments#readme", + "author": "Rolamix (https://rolamix.com)", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/rolamix/cordova-plugin-stripe-payments.git" + }, + "bugs": { + "url": "https://github.com/rolamix/cordova-plugin-stripe-payments/issues" + }, + "cordova": { + "id": "cordova-plugin-stripe-payments", + "platforms": [ + "android", + "ios" + ] + }, + "keywords": [ + "cordova", + "phonegap", + "swift", + "cordova-ios", + "cordova-android", + "stripe", + "stripe payments", + "card", + "apple pay", + "google pay", + "ach", + "credit card" + ] +} \ No newline at end of file diff --git a/plugin.xml b/plugin.xml new file mode 100644 index 0000000..4b88506 --- /dev/null +++ b/plugin.xml @@ -0,0 +1,73 @@ + + + + + Stripe Payments + Cordova plugin for Stripe payments using the native Android/iOS SDKs. Supports Apple Pay and card payments. + https://github.com/rolamix/cordova-plugin-stripe-payments + MIT + cordova,stripe,payments,apple pay,credit cards,checkout + https://github.com/rolamix/cordova-plugin-stripe-payments/issues + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/service/StripeService.java b/src/android/service/StripeService.java new file mode 100644 index 0000000..c149381 --- /dev/null +++ b/src/android/service/StripeService.java @@ -0,0 +1,23 @@ +package com.stripe.example.service; + +import java.util.Map; + +import okhttp3.ResponseBody; +import retrofit2.http.FieldMap; +import retrofit2.http.FormUrlEncoded; +import retrofit2.http.POST; +import rx.Observable; + +/** + * A Retrofit service used to communicate with a server. + */ +public interface StripeService { + + @FormUrlEncoded + @POST("ephemeral_keys") + Observable createEphemeralKey(@FieldMap Map apiVersionMap); + + @FormUrlEncoded + @POST("create_intent") + Observable createPaymentIntent(@FieldMap Map params); +} \ No newline at end of file diff --git a/src/ios/APIClient.swift b/src/ios/APIClient.swift new file mode 100644 index 0000000..a2326e0 --- /dev/null +++ b/src/ios/APIClient.swift @@ -0,0 +1,58 @@ +import Alamofire +import Stripe + +class APIClient: NSObject, STPCustomerEphemeralKeyProvider { + + static let shared = APIClient() + + var ephemeralKeyUrl = "" + + // MARK: STPCustomerEphemeralKeyProvider + enum CustomerKeyError: Error { + case ephemeralKeyUrl + case invalidResponse + } + + func createCustomerKey(withAPIVersion apiVersion: String, completion: @escaping STPJSONResponseCompletionBlock) { + let endpoint = ephemeralKeyUrl // "/api/passengers/me/ephemeral_keys" + + guard let url = URL(string: endpoint) else { + completion(nil, CustomerKeyError.ephemeralKeyUrl) + return + } + + let parameters: [String: Any] = ["api_version": apiVersion] + let headers: HTTPHeaders = [ + // "Authorization": "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==", + "Accept": "application/json" + ] + + // TODO need xcode for this. + if PluginConfig.extraHTTPHeaders.count > 0 { + // for each HTTPHeader in extraHTTPHeaders + // headers.add(header) + } + + Alamofire.request(url, method: .post, parameters: parameters, headers: headers) + .validate(statusCode: 200..<300) + .responseJSON { responseJSON in + switch responseJSON.result { + case .success(let json): + guard let data = json as? [String: AnyObject] else { + completion(nil, CustomerKeyError.invalidResponse) + return + } + completion(data, nil) + case .failure(let error): + completion(nil, error) + } + // The docs also have this approach, not sure which is correct: + // guard let json = responseJSON.result.value as? [AnyHashable: Any] else { + // completion(nil, CustomerKeyError.invalidResponse) + // return + // } + // completion(json, nil) + } + } + +} diff --git a/src/ios/AppDelegate.swift b/src/ios/AppDelegate.swift new file mode 100644 index 0000000..c419c29 --- /dev/null +++ b/src/ios/AppDelegate.swift @@ -0,0 +1,11 @@ + +import UIKit +import Stripe + +// For Cordova, we can create an AppDelegate extension: +// https://stackoverflow.com/a/29288792 + +//MARK: extension StripePaymentsPlugin +extension AppDelegate { + +} \ No newline at end of file diff --git a/src/ios/PaymentOptions.swift b/src/ios/PaymentOptions.swift new file mode 100644 index 0000000..16d6b6b --- /dev/null +++ b/src/ios/PaymentOptions.swift @@ -0,0 +1,15 @@ +public struct PaymentOptions { + + // must be in smallest unit e.g. 1000 for $10.00 + public var price: UInt32 = 0 + // 'USD', 'MXN', 'JPY', 'GBP' etc. uppercase. + public var currency: String = "USD" + // 'US', 'PH', the ISO 2-letter code, uppercase. + public var country: String = "US" + + init(dict: [String:Any]) { + price = dict["price"] as? UInt32 ?? 0 + currency = dict["currency"] as? String ?? "USD" + country = dict["country"] as? String ?? "US" + } +} \ No newline at end of file diff --git a/src/ios/PluginConfig.swift b/src/ios/PluginConfig.swift new file mode 100644 index 0000000..d302ffa --- /dev/null +++ b/src/ios/PluginConfig.swift @@ -0,0 +1,22 @@ +import Alamofire + +public class StripePaymentsPluginConfig { + public var publishableKey: String? = nil + public var ephemeralKeyUrl: String? = nil + public var appleMerchantId: String? = nil + public var companyName: String? = nil + public var requestPaymentImmediately: Boolean? = true + public var extraHTTPHeaders: [HTTPHeader]? = [] + // TODO: + // We can add an option to execute the charge API-side, in which case + // the developer would also need to provide their 'charge' endpoint, + // meaning that the success/fail return value becomes meaningful. + // The extraHTTPHeaders now allows us to do that, to be done later.. + + // TODO need xcode for this + func parseExtraHeaders(dict: [String:String]) { + // extraHTTPHeaders.push(new HTTPHeader(dict[something])) + } +} + +let PluginConfig = StripePaymentsPluginConfig() diff --git a/src/ios/StripePaymentsPlugin-Bridging-Header.h b/src/ios/StripePaymentsPlugin-Bridging-Header.h new file mode 100644 index 0000000..b156388 --- /dev/null +++ b/src/ios/StripePaymentsPlugin-Bridging-Header.h @@ -0,0 +1 @@ +#import diff --git a/src/ios/StripePaymentsPlugin.swift b/src/ios/StripePaymentsPlugin.swift new file mode 100644 index 0000000..443bfc2 --- /dev/null +++ b/src/ios/StripePaymentsPlugin.swift @@ -0,0 +1,273 @@ + +import UIKit +import Stripe + +// https://stripe.com/docs/apple-pay/apps +// https://stripe.com/docs/mobile/ios/standard +// https://github.com/zyra/cordova-plugin-stripe/blob/v2/src/ios/CordovaStripe.m +// https://github.com/stripe/stripe-connect-rocketrides/blob/master/server/routes/api/rides.js +// https://github.com/stripe/stripe-connect-rocketrides/blob/master/ios/RocketRides/RideRequestViewController.swift +// https://github.com/stripe/stripe-ios/blob/master/Example/Standard%20Integration%20(Swift)/CheckoutViewController.swift +// https://github.com/stripe/stripe-ios/blob/master/Example/Standard%20Integration%20(Swift)/MyAPIClient.swift + +@objc(StripePaymentsPlugin) class StripePaymentsPlugin: CDVPlugin, STPPaymentContextDelegate { + + private var paymentStatusCallback: String? = nil + private let customerContext: STPCustomerContext + private let paymentContext: STPPaymentContext + + override func pluginInitialize() { + super.pluginInitialize() + } + + @objc(addPaymentStatusObserver:) + func addPaymentStatusObserver(command: CDVInvokedUrlCommand) { + paymentStatusCallback = command.callbackId + } + + // MARK: Init Method + + @objc(init:) + public func init(command: CDVInvokedUrlCommand) { + let error = "The Stripe Publishable Key and ephemeral key generation URL are required" + + guard let dict = command.arguments[0] as? [String:Any] ?? nil else { + errorCallback(command.callbackId, [ "status": "INIT_ERROR", "error": error ]) + return + } + + // Would be nice to figure a way to customize the UI, as Rocket Rides did, + // https://github.com/stripe/stripe-connect-rocketrides/blob/master/ios/RocketRides/UIColor%2BPalette.swift + // but this would be alot of work and a clumsy API so put that on hold to come up with a better way. + PluginConfig.publishableKey = dict["publishableKey"] as? String ?? "" + PluginConfig.ephemeralKeyUrl = dict["ephemeralKeyUrl"] as? String ?? "" + PluginConfig.appleMerchantId = dict["appleMerchantId"] as? String ?? "" + PluginConfig.companyName = dict["companyName"] as? String ?? "" + PluginConfig.requestPaymentImmediately = dict["requestPaymentImmediately"] as? Boolean ?? true + + if headersDict = dict["extraHTTPHeaders"] as? [String:String] { + PluginConfig.parseExtraHeaders(headersDict) + } + + if !self.verifyConfig() { + errorCallback(command.callbackId, [ "status": "INIT_ERROR", "error": error ]) + return + } + + APIClient.shared.ephemeralKeyUrl = PluginConfig.ephemeralKeyUrl + STPPaymentConfiguration.shared().companyName = PluginConfig.companyName + STPPaymentConfiguration.shared().publishableKey = PluginConfig.publishableKey + + if !PluginConfig.appleMerchantId.isEmpty { + STPPaymentConfiguration.shared().appleMerchantIdentifier = PluginConfig.appleMerchantId + } + + customerContext = STPCustomerContext(keyProvider: APIClient.shared) + paymentContext = STPPaymentContext(customerContext: customerContext) + + paymentContext.delegate = self + paymentContext.hostViewController = self.viewController + + successCallback(command.callbackId, [ "status": "INIT_SUCCESS" ]) + } + + + // MARK: Public plugin API + + @objc(showPaymentDialog:) + public func showPaymentDialog(command: CDVInvokedUrlCommand) { + var error = "[CONFIG]: Error parsing payment options or they were not provided" + + // Ensure we have valid config. + guard let options = command.arguments[0] as? [String:Any] ?? nil else { + errorCallback(command.callbackId, [ "status": "PAYMENT_DIALOG_ERROR", "error": error ]) + return + } + + if !self.verifyConfig() { + error = "[CONFIG]: Config is not set, init() must be called before using plugin" + errorCallback(command.callbackId, [ "status": "PAYMENT_DIALOG_ERROR", "error": error ]) + return + } + + let paymentOptions = PaymentOptions(options) + paymentContext.paymentAmount = paymentOptions.price + paymentContext.paymentCurrency = paymentOptions.currency + paymentContext.paymentCountry = paymentOptions.country + + // This dialog collects a payment method from the user. When they close it, you get a context + // change event with the payment info. NO charge has been created at that point, NO source + // has been created from the payment method. All that has happened is the user entered + // payment data and clicked 'ok'. That's all. + // After that dialog closes - after paymentContextDidChange is called with + // a selectedPaymentMethod - THEN you want to call requestPayment. + paymentContext.presentPaymentMethodsViewController() + successCallback(command.callbackId, [ "status": "PAYMENT_DIALOG_SHOWN" ]) + } + + @objc(requestPayment:) + public func requestPayment(command: CDVInvokedUrlCommand) { + // Ensure we have valid config. + if !self.verifyConfig() { + let error = "[CONFIG]: Config is not set, init() must be called before using plugin" + errorCallback(command.callbackId, [ "status": "REQUEST_PAYMENT_ERROR", "error": error ]) + return + } + + doRequestPayment(command.callbackId) + } + + func doRequestPayment(_ callbackId: String) { + paymentContext.requestPayment() + successCallback(callbackId, [ "status": "REQUEST_PAYMENT_STARTED" ]) + } + + + // MARK: STPPaymentContextDelegate + + func paymentContext(_ paymentContext: STPPaymentContext, didFailToLoadWithError error: Error) { + let alertController = UIAlertController( + preferredStyle: .alert, + retryHandler: { (action) in + // Retry payment context loading + paymentContext.retryLoading() + } + ) + + var message = error?.localizedDescription ?? "" + var callbackMessage: String = "" + + if let customerKeyError = error as? APIClient.CustomerKeyError { + switch customerKeyError { + case .ephemeralKeyUrl: + // Fail silently until base url string is set + callbackMessage = "[ERROR]: Please assign a value to `APIClient.shared.ephemeralKeyUrl` before continuing. See `StripePaymentsPlugin.swift`." + case .invalidResponse: + // Use customer key specific error message + callbackMessage = "[ERROR]: Missing or malformed response when attempting to call `APIClient.shared.createCustomerKey`. Please check internet connection and backend response." + message = "Could not retrieve customer information" + } + } + else { + // Use generic error message + callbackMessage = "[ERROR]: Unrecognized error while loading payment context: \(error)" + message = error.localizedDescription ?? "Could not retrieve payment information" + } + + print(callbackMessage) + errorCallback(paymentStatusCallback, ["error": callbackMessage], keepCallback: true) + + alertController.setMessage(message) // ?? + self.viewController.present(alertController, animated: true, completion: nil) + } + + func paymentContextDidChange(_ paymentContext: STPPaymentContext) { + var isLoading = paymentContext.isLoading + var isPaymentReady = paymentContext.selectedPaymentMethod != nil + var label = "" + var image = "" + + // https://stackoverflow.com/questions/11592313/how-do-i-save-a-uiimage-to-a-file + if selectedPaymentMethod = paymentContext.selectedPaymentMethod { + label = selectedPaymentMethod.label + image = "" + let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) + if let filePath = paths.first?.appendingPathComponent("StripePaymentMethod.jpg") { + // Save image. + do { + try UIImageJPEGRepresentation(selectedPaymentMethod.image, 1)?.write(to: filePath, options: .atomic) + image = filePath + } + catch { } + } + } + + let resultMsg: [String : Any] = [ + "status": "PAYMENT_STATUS_CHANGED", + "isLoading": isLoading, + "isPaymentReady": isPaymentReady, + "label": label, + "image": image + ] + + successCallback(paymentStatusCallback, resultMsg, keepCallback: true) + + if isPaymentReady && PluginConfig.requestPaymentImmediately { + doRequestPayment(paymentStatusCallback) + } + } + + // This callback is triggered when requestPayment() completes successfully to create a Source. + // This Source can then be used by the app to process a payment (create a charge, subscription etc.) + func paymentContext(_ paymentContext: STPPaymentContext, didCreatePaymentResult paymentResult: STPPaymentResult, completion: @escaping STPErrorBlock) { + // Create charge using payment result + let resultMsg: [String : Any] = [ + "status": "PAYMENT_CREATED", + "source": paymentResult.source.stripeID + ] + + successCallback(paymentStatusCallback, resultMsg, keepCallback: true) + completion(nil) + } + + // This callback triggers due to: + // a) the result of the payment info prompt, if the user cancels payment method selection + // b) the result of requestPayment, if the user was prompted for more data and cancels + // c) the result of requestPayment, if they attempt to verify a payment method and it fails + // d) the output of paymentContext(didCreatePaymentResult:), in our case, always called with success. + // In a full iOS app, in paymentContext(didCreatePaymentResult:) you would call your backend, + // and return an appropriate error or success; however for the plugin, we are returning the + // payment Source to the app, so we don't need paymentContext(didCreatePaymentResult:) to do anything + // besides return success. + // In later versions we may add the option for that method to call your backend directly so you + // don't have to. + func paymentContext(_ paymentContext: STPPaymentContext, didFinishWith status: STPPaymentStatus, error: Error?) { + var resultMsg: [String : Any] = [:] + + switch status { + case .success: + resultMsg = [ "status": "PAYMENT_COMPLETED_SUCCESS" ] + case .error: + // Use generic error message + print("[ERROR]: Unrecognized error while finishing payment: \(String(describing: error))"); + self.viewController.present(UIAlertController(message: "Could not complete payment"), animated: true) + + resultMsg = [ + "status": "PAYMENT_COMPLETED_ERROR", + error: "[ERROR]: Unrecognized error while finishing payment: \(String(describing: error))" + ] + + errorCallback(paymentStatusCallback, resultMsg, keepCallback: true) + return + case .userCancellation: + resultMsg = [ "status": "PAYMENT_CANCELED" ] + } + + successCallback(paymentStatusCallback, resultMsg, keepCallback: true) + } + + func successCallback(_ callbackId: String, _ data: [String:Any?], keepCallback: Bool = false) { + var pluginResult = CDVPluginResult( + status: .ok, + messageAs: data + ) + pluginResult?.setKeepCallbackAs(keepCallback) + self.commandDelegate!.send(pluginResult, callbackId: callbackId) + } + + func errorCallback(_ callbackId: String, _ data: [String:Any?], keepCallback: Bool = false) { + var pluginResult = CDVPluginResult( + status: .error, + messageAs: data + ) + pluginResult?.setKeepCallbackAs(keepCallback) + self.commandDelegate!.send(pluginResult, callbackId: callbackId) + } + + func verifyConfig() -> Bool { + return PluginConfig.publishableKey != nil && !PluginConfig.publishableKey!.isEmpty + && PluginConfig.ephemeralKeyUrl != nil && !PluginConfig.ephemeralKeyUrl!.isEmpty + } + +} + diff --git a/www/StripePaymentsPlugin.js b/www/StripePaymentsPlugin.js new file mode 100644 index 0000000..0b4a57d --- /dev/null +++ b/www/StripePaymentsPlugin.js @@ -0,0 +1,74 @@ +var exec = require('cordova/exec'); + +var StripePaymentsPlugin = function () { }; + +StripePaymentsPlugin._paymentStatusObserverList = []; + +StripePaymentsPlugin._processFunctionList = function (array, param) { + for (var i = 0; i < array.length; i++) + array[i](param); +}; + +var paymentStatusCallbackProcessor = function (state) { + StripePaymentsPlugin._processFunctionList(StripePaymentsPlugin._paymentStatusObserverList, state); +}; + +/** + * Set the high level plugin config. + * THIS METHOD MUST BE CALLED BEFORE ANY OTHER METHODS ON THE PLUGIN. + * @param {object} config {publishableKey, ephemeralKeyUrl, appleMerchantId, companyName} + */ +StripePaymentsPlugin.prototype.init = function (config, successCallback, errorCallback) { + exec(successCallback, errorCallback, 'StripePaymentsPlugin', 'init', [config]); +}; + +/** + * Adds an observer to the stream of events returned while the payment request windows are open. + */ +StripePaymentsPlugin.prototype.addPaymentStatusObserver = function (callback) { + StripePaymentsPlugin._paymentStatusObserverList.push(callback); + exec(paymentStatusCallbackProcessor, function () { }, 'StripePaymentsPlugin', 'addPaymentStatusObserver', []); +}; + +/** + * Prompts user for Media Library permissions, or returns immediately with their existing + * permission if the dialog has already been shown in the lifetime of the app + * This will prompt AGAIN if the user has changed permissions outside the app and returned + * to the app. + * @param {object} paymentOptions Options for the payment to collect, in the format + * { price, currency, country }. Price must be in the smallest unit of currency, e.g. + * 1000 for $10.00 USD; currency must be the 3-letter currency code, uppercase, e.g. 'USD'; + * country must be the ISO 2-letter country code e.g. 'US'. + * @param {function} successCallback Success callback + * @param {function} errorCallback Error callback + */ +StripePaymentsPlugin.prototype.showPaymentDialog = function (paymentOptions, successCallback, errorCallback) { + if (!paymentOptions) { + return errorCallback({ status: "PAYMENT_ERROR", error: '[CONFIG]: Payment options are required ' }); + } + exec(successCallback, errorCallback, 'StripePaymentsPlugin', 'showPaymentDialog', [paymentOptions]); +}; + +/** + * Finalize the payment. If this requires additional user input, Stripe will take care of it. + * @param {function} successCallback Success callback + * @param {function} errorCallback Error callback + */ +StripePaymentsPlugin.prototype.requestPayment = function (successCallback, errorCallback) { + exec(successCallback, errorCallback, 'StripePaymentsPlugin', 'requestPayment', []); +}; + + +//------------------------------------------------------------------- + +if (!window.plugins) { + window.plugins = {}; +} + +if (!window.plugins.StripePaymentsPlugin) { + window.plugins.StripePaymentsPlugin = new StripePaymentsPlugin(); +} + +if (typeof module != 'undefined' && module.exports) { + module.exports = StripePaymentsPlugin; +}