You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
cordova-plugin-stripe-payments/src/ios/StripePaymentsPlugin.swift

307 lines
13 KiB
Swift

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 = ""
private var customerContext: STPCustomerContext!
private var paymentContext: STPPaymentContext!
private var keyRetries: Int = 0
override func pluginInitialize() {
super.pluginInitialize()
}
@objc(addPaymentStatusObserver:)
func addPaymentStatusObserver(command: CDVInvokedUrlCommand) {
paymentStatusCallback = command.callbackId
let resultMsg = [
"status": "LISTENER_ADDED"
]
successCallback(paymentStatusCallback, resultMsg, keepCallback: true)
}
// MARK: Init Method
@objc(beginStripe:)
public func beginStripe(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.maximumKeyRetries = dict["maximumKeyRetries"] as? Int ?? 0
if let headersDict = dict["extraHTTPHeaders"] as? [String:String] {
PluginConfig.extraHTTPHeaders = headersDict
}
if !self.verifyConfig() {
errorCallback(command.callbackId, [ "status": "INIT_ERROR", "error": error ])
return
}
StripeAPIClient.shared.ephemeralKeyUrl = PluginConfig.ephemeralKeyUrl
STPPaymentConfiguration.shared().companyName = PluginConfig.companyName
STPPaymentConfiguration.shared().publishableKey = PluginConfig.publishableKey
if !PluginConfig.appleMerchantId.isEmpty {
STPPaymentConfiguration.shared().appleMerchantIdentifier = PluginConfig.appleMerchantId
}
successCallback(command.callbackId, [ "status": "INIT_SUCCESS" ])
}
func createPaymentContext() {
if (customerContext == nil || paymentContext == nil) {
customerContext = STPCustomerContext(keyProvider: StripeAPIClient.shared)
paymentContext = STPPaymentContext(customerContext: customerContext)
paymentContext.delegate = self
paymentContext.hostViewController = self.viewController
}
customerContext.clearCachedCustomer()
}
// 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
}
// Allow these to be overridden
if let headersDict = options["extraHTTPHeaders"] as? [String:String] {
PluginConfig.extraHTTPHeaders = headersDict
}
createPaymentContext()
let paymentOptions = StripePaymentOptions(dict: 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.presentPaymentOptionsViewController()
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
}
if (paymentContext == nil || customerContext == nil) {
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) {
keyRetries = 0
successCallback(callbackId, [ "status": "REQUEST_PAYMENT_STARTED" ], keepCallback: true)
paymentContext.requestPayment()
}
// MARK: STPPaymentContextDelegate
func paymentContext(_ paymentContext: STPPaymentContext, didFailToLoadWithError error: Error) {
var message = error.localizedDescription
var callbackMessage: String = ""
if let customerKeyError = error as? StripeAPIClient.CustomerKeyError {
switch customerKeyError {
case .ephemeralKeyUrl:
// Fail silently until base url string is set
callbackMessage = "[ERROR]: Please assign a value to `StripeAPIClient.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 `StripeAPIClient.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.localizedDescription)"
message = "Could not retrieve payment information"
}
print(callbackMessage)
if (keyRetries < PluginConfig.maximumKeyRetries) {
keyRetries += 1
let alertController = UIAlertController(
title: "",
message: message,
preferredStyle: .alert
)
let retry = UIAlertAction(title: "Retry", style: .default, handler: { (action) in
// Retry payment context loading
self.paymentContext.retryLoading()
})
alertController.addAction(retry)
self.viewController.present(alertController, animated: true, completion: nil)
} else {
errorCallback(paymentStatusCallback, ["error": callbackMessage], keepCallback: true)
}
}
func paymentContextDidChange(_ paymentContext: STPPaymentContext) {
let isLoading = paymentContext.loading
let isPaymentReady = paymentContext.selectedPaymentOption != nil
var label = ""
var image = ""
// https://stackoverflow.com/questions/11592313/how-do-i-save-a-uiimage-to-a-file
if let selectedPaymentOption = paymentContext.selectedPaymentOption {
label = selectedPaymentOption.label
image = ""
let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
if let filePath = paths.first?.appendingPathComponent("StripePaymentMethod.jpg") {
// Save image.
do {
try selectedPaymentOption.image.jpegData(compressionQuality: 1)?.write(to: filePath, options: .atomic)
image = filePath.absoluteString
}
catch { }
}
}
let resultMsg: [String : Any] = [
"status": "PAYMENT_STATUS_CHANGED",
"isLoading": isLoading,
"isPaymentReady": isPaymentReady,
"label": label,
"image": image
]
print("[StripePaymentsPlugin].paymentContextDidChange: \(resultMsg)")
successCallback(paymentStatusCallback, resultMsg, keepCallback: true)
}
// 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
]
print("[StripePaymentsPlugin].paymentContext.didCreatePaymentResult: \(resultMsg)")
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))");
resultMsg = [
"status": "PAYMENT_COMPLETED_ERROR",
"error": "[ERROR]: Unrecognized error while finishing payment: \(String(describing: error))"
]
print("[StripePaymentsPlugin].didFinishWith: \(resultMsg)")
errorCallback(paymentStatusCallback, resultMsg, keepCallback: true)
return
case .userCancellation:
resultMsg = [ "status": "PAYMENT_CANCELED" ]
}
print("[StripePaymentsPlugin].didFinishWith: \(resultMsg)")
successCallback(paymentStatusCallback, resultMsg, keepCallback: true)
}
func successCallback(_ callbackId: String, _ data: [String:Any?], keepCallback: Bool = false) {
let pluginResult = CDVPluginResult(
status: .ok,
messageAs: data as [AnyHashable : Any]
)
pluginResult?.setKeepCallbackAs(keepCallback)
print("[StripePaymentsPlugin](successCallback) sending result to \(callbackId), result: \(String(describing: pluginResult))")
self.commandDelegate!.send(pluginResult, callbackId: callbackId)
}
func errorCallback(_ callbackId: String, _ data: [String:Any?], keepCallback: Bool = false) {
let pluginResult = CDVPluginResult(
status: .error,
messageAs: data as [AnyHashable : Any]
)
pluginResult?.setKeepCallbackAs(keepCallback)
print("[StripePaymentsPlugin](errorCallback) sending result to \(callbackId), result: \(data)")
self.commandDelegate!.send(pluginResult, callbackId: callbackId)
}
func verifyConfig() -> Bool {
return !PluginConfig.publishableKey.isEmpty && !PluginConfig.ephemeralKeyUrl.isEmpty
}
}