feat(ios): add initial API and iOS implementation

This commit is contained in:
codinronan 2019-03-21 17:47:00 -06:00
commit c124d500c0
12 changed files with 686 additions and 0 deletions

21
.gitignore vendored Normal file
View File

@ -0,0 +1,21 @@
*.csproj.user
*.suo
*.cache
Thumbs.db
*.DS_Store
*.bak
*.cache
*.log
*.swp
*.user
scripts/ios/build/
scripts/ios/Pods

79
README.md Normal file
View File

@ -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.

36
package.json Normal file
View File

@ -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 <contact@rolamix.com> (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"
]
}

73
plugin.xml Normal file
View File

@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8"?>
<plugin xmlns="http://apache.org/cordova/ns/plugins/1.0"
xmlns:android="http://schemas.android.com/apk/res/android"
id="cordova-plugin-stripe-payments"
version="0.0.5">
<name>Stripe Payments</name>
<description>Cordova plugin for Stripe payments using the native Android/iOS SDKs. Supports Apple Pay and card payments.</description>
<repo>https://github.com/rolamix/cordova-plugin-stripe-payments</repo>
<license>MIT</license>
<keywords>cordova,stripe,payments,apple pay,credit cards,checkout</keywords>
<issue>https://github.com/rolamix/cordova-plugin-stripe-payments/issues</issue>
<engines>
<engine name="cordova" version=">=7.1.0"/>
<engine name="cordova-android" version=">=7.1.0"/>
<!-- If installing on Cordova IOS 5.0, make sure to add
<preference name="SwiftVersion" value="4.1" />
to your config.xml under the ios platform section.
If installing on Cordova iOS < 5.0, you need to add cordova-plugin-add-swift-support
to your project and specify <preference name="UseSwiftLanguageVersion" value="4" />
in your config.xml file under the ios platform section. -->
<engine name="cordova-ios" version=">=4.5.0"/>
</engines>
<js-module src="www/StripePaymentsPlugin.js" name="StripePaymentsPlugin">
<clobbers target="window.plugins.StripePaymentsPlugin" />
</js-module>
<!-- Android -->
<platform name="android">
<config-file target="res/xml/config.xml" parent="/*">
<feature name="StripePaymentsPlugin">
<param name="android-package" value="com.rolamix.plugins.stripe.StripePaymentsPlugin"/>
</feature>
</config-file>
<source-file src="src/android/StripePaymentsPlugin.java" target-dir="src/com/rolamix/plugins/stripe/" />
<framework src="src/android/StripePaymentsPlugin.gradle" custom="true" type="gradleReference" />
</platform>
<!-- iOS -->
<platform name="ios">
<config-file target="config.xml" parent="/*">
<feature name="StripePaymentsPlugin">
<param name="ios-package" value="StripePaymentsPlugin"/>
</feature>
</config-file>
<framework src="Stripe" type="podspec" spec="~> 15.0.0" />
<framework src="Alamofire" type="podspec" spec="~> 5.0.0-beta.3" />
<!-- https://github.com/cordova-develop/cordova-plugin-pods3/blob/master/plugin.xml -->
<!-- <pods use-frameworks="true">
<pod name="Stripe" spec="" />
<pod name="Alamofire" spec="" />
</pods> -->
<source-file src="src/ios/APIClient.swift" />
<source-file src="src/ios/AppDelegate.swift" />
<source-file src="src/ios/PaymentOptions.swift" />
<source-file src="src/ios/PluginConfig.swift" />
<source-file src="src/ios/StripePaymentsPlugin.swift" />
<header-file type="BridgingHeader" src="src/ios/StripePaymentsPlugin-Bridging-Header.h" />
<!-- <framework src="Foundation.framework" /> -->
</platform>
</plugin>

View File

@ -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<ResponseBody> createEphemeralKey(@FieldMap Map<String, String> apiVersionMap);
@FormUrlEncoded
@POST("create_intent")
Observable<ResponseBody> createPaymentIntent(@FieldMap Map<String, Object> params);
}

58
src/ios/APIClient.swift Normal file
View File

@ -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)
}
}
}

11
src/ios/AppDelegate.swift Normal file
View File

@ -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 {
}

View File

@ -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"
}
}

View File

@ -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()

View File

@ -0,0 +1 @@
#import <Cordova/CDV.h>

View File

@ -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
}
}

View File

@ -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;
}