How to use Stripe and Apple Pay in iOS

Issue #219

Show basic add card in iOS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import UIKit
import Stripe

final class MainController: UIViewController {

func showPayment() {
let addCardViewController = STPAddCardViewController()
addCardViewController.delegate = self
let navigationController = UINavigationController(rootViewController: addCardViewController)

present(navigationController, animated: true, completion: nil)
}
}

extension MainController: STPAddCardViewControllerDelegate {
func addCardViewControllerDidCancel(_ addCardViewController: STPAddCardViewController) {
dismiss(animated: true, completion: nil)
}

func addCardViewController(_ addCardViewController: STPAddCardViewController, didCreateToken token: STPToken, completion: @escaping STPErrorBlock) {
_ = token.tokenId
completion(nil)
dismiss(animated: true, completion: nil)
}
}

Generate ephemeral key

https://stripe.com/docs/mobile/ios/standard#ephemeral-key

In order for our prebuilt UI elements to function, you’ll need to provide them with an ephemeral key, a short-lived API key with restricted API access. You can think of an ephemeral key as a session, authorizing the SDK to retrieve and update a specific Customer object for the duration of the session.

Backend in Go

https://github.com/stripe/stripe-go

Need a secret key by going to Stripe dashboard -> Developers -> API keys -> Secret key

1
stripe.Key = "sk_key"

Need customer id. We can manually create one in Stripe dashboard -> Customers

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
package main

import (
"net"
"encoding/json"
"fmt"
"net/http"
"github.com/stripe/stripe-go"
"github.com/stripe/stripe-go/ephemeralkey"
)

func main() {
stripe.Key = "sk_test_mM2MkqO61n7vvbVRfeYmBgWm00Si2PtWab"

http.HandleFunc("/ephemeral_keys", generateEphemeralKey)
if err := http.ListenAndServe(":8080", nil); err != nil {
panic(err)
}
}

type EphemeralKeysRequest struct {
ApiVersion string `json:"api_version"`
}

func generateEphemeralKey(w http.ResponseWriter, r *http.Request) {
customerId := "cus_Eys6aeP5xR89ab"

decoder := json.NewDecoder(r.Body)
var t EphemeralKeysRequest
err := decoder.Decode(&t)
if err != nil {
panic(err)
}

stripeVersion := t.ApiVersion
if stripeVersion == "" {
log.Printf("Stripe-Version not found\n")
w.WriteHeader(400)
return
}
params := &stripe.EphemeralKeyParams{
Customer: stripe.String(customerId),
StripeVersion: stripe.String(stripeVersion),
}

key, err := ephemeralkey.New(params)
if err != nil {
log.Printf("Stripe bindings call failed, %v\n", err)
w.WriteHeader(500)
return
}
w.Write(key.RawJSON)
}

iOS client

Networking client uses How to make simple networking client in Swift

Need an object that conforms to STPCustomerEphemeralKeyProvider

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
final class EphemeralKeyClient: NSObject, STPCustomerEphemeralKeyProvider {
let client = NetworkClient(baseUrl: URL(string: "http://localhost:8080")!)

func createCustomerKey(withAPIVersion apiVersion: String, completion: @escaping STPJSONResponseCompletionBlock) {
var options = Options()
options.httpMethod = .post
options.path = "ephemeral_keys"
options.parameters = [
"api_version": apiVersion
]

client.makeJson(options: options, completion: { result in
switch result {
case .success(let json):
completion(json, nil)
case .failure(let error):
completion(nil, error)
}
})
}
}

Setting up STPCustomerContext and STPPaymentContext

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
final class MainController: UIViewController {
let client = EphemeralKeyClient()
let customerContext: STPCustomerContext
let paymentContext: STPPaymentContext

init() {
self.customerContext = STPCustomerContext(keyProvider: client)
self.paymentContext = STPPaymentContext(customerContext: customerContext)
super.init(nibName: nil, bundle: nil)
paymentContext.delegate = self
paymentContext.hostViewController = self
}

required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

func start() {
paymentContext.presentShippingViewController()
}
}

Handle charge

https://stripe.com/docs/charges

Backend in Go

If we use stripe_id from card, which has the form of card_xxx, we need to include customer info

If we use token, which has the form tok_xxx, then no need for customer info

From STPPaymentResult

When you’re using STPPaymentContext to request your user’s payment details, this is the object that will be returned to your application when they’ve successfully made a payment. It currently just contains a source, but in the future will include any relevant metadata as well. You should pass source.stripeID to your server, and call the charge creation endpoint. This assumes you are charging a Customer, so you should specify the customer parameter to be that customer’s ID and the source parameter to the value returned here. For more information, see https://stripe.com/docs/api#create_charge

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package main

import (
"net"
"encoding/json"
"fmt"
"net/http"
"log"
"os"
"github.com/stripe/stripe-go/charge"
)

func main() {
stripe.Key = "sk_test_mM2MkqO61n7vvbVRfeYmBgWm00Si2PtWab"

http.HandleFunc("/request_charge", handleCharge)
if err := http.ListenAndServe(":8080", nil); err != nil {
panic(err)
}
}

var customerId = "cus_Eys6aeP5xR89ab"

type PaymentResult struct {
StripeId string `json:"stripe_id"`
}

func handleCharge(w http.ResponseWriter, r *http.Request) {
decoder := json.NewDecoder(r.Body)
var t PaymentResult
err := decoder.Decode(&t)
if err != nil {
panic(err)
}

params := &stripe.ChargeParams{
Amount: stripe.Int64(150),
Currency: stripe.String(string(stripe.CurrencyUSD)),
Description: stripe.String("Charge from my Go backend"),
Customer: stripe.String(customerId),
}

params.SetSource(t.StripeId)
ch, err := charge.New(params)
if err != nil {
fmt.Fprintf(w, "Could not process payment: %v", err)
fmt.Println(ch)
w.WriteHeader(400)
}

w.WriteHeader(200)
}

iOS client

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
final class PaymentClient {
let client = NetworkClient(baseUrl: URL(string: "http://192.168.210.219:8080")!)

func requestCharge(source: STPSourceProtocol, completion: @escaping (Result<(), Error>) -> Void) {
var options = Options()
options.httpMethod = .post
options.path = "request_charge"
options.parameters = [
"stripe_id": source.stripeID
]

client.makeJson(options: options, completion: { result in
completion(result.map({ _ in () }))
})
}
}

paymentContext.requestPayment()

extension MainController: STPPaymentContextDelegate {
func paymentContext(_ paymentContext: STPPaymentContext, didCreatePaymentResult paymentResult: STPPaymentResult, completion: @escaping STPErrorBlock) {
client.requestCharge(source: paymentResult.source, completion: { result in
switch result {
case .success:
completion(nil)
case .failure(let error):
completion(error)
}
})
}
}

Token from card

Use STPAPIClient.shared().createToken to get token from card https://stripe.com/docs/mobile/ios/custom#collecting-card-details

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
let cardParams = STPCardParams()
cardParams.number = "4242424242424242"
cardParams.expMonth = 10
cardParams.expYear = 2021
cardParams.cvc = "123"

STPAPIClient.shared().createToken(withCard: cardParams) { (token: STPToken?, error: Error?) in
guard let token = token, error == nil else {
// Present error to user...
return
}

submitTokenToBackend(token, completion: { (error: Error?) in
if let error = error {
// Present error to user...
}
else {
// Continue with payment...
}
})
}

Payment options and shipping view controllers

Instead of using paymentContext

1
2
paymentContext.pushShippingViewController()
paymentContext.pushPaymentOptionsViewController()

We can use view controllers https://stripe.com/docs/mobile/ios/custom#stppaymentoptionsviewcontroller directly with STPPaymentOptionsViewController and STPShippingAddressViewController. Then implement STPPaymentOptionsViewControllerDelegate and STPShippingAddressViewControllerDelegate

Register merchant Id and Apple Pay certificate

https://stripe.com/docs/apple-pay/apps

Get Certificate signing request file from Stripe https://dashboard.stripe.com/account/apple_pay

We can’t register merchant id with Enterprise account

Use Apple Pay

Go backend

Use token

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
type ApplePayRequest struct {
Token string `json:"token"`
}

func handleChargeUsingApplePay(w http.ResponseWriter, r *http.Request) {
decoder := json.NewDecoder(r.Body)
var t ApplePayRequest
err := decoder.Decode(&t)
if err != nil {
panic(err)
}

params := &stripe.ChargeParams{
Amount: stripe.Int64(150),
Currency: stripe.String(string(stripe.CurrencyUSD)),
Description: stripe.String("Charge from my Go backend for Apple Pay"),
}

params.SetSource(t.Token)
ch, err := charge.New(params)
if err != nil {
fmt.Fprintf(w, "Could not process payment: %v", err)
fmt.Println(ch)
w.WriteHeader(400)
}

w.WriteHeader(200)
}

iOS client

Update client to send STPToken

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
final class PaymentClient: NSObject {
let client = NetworkClient(baseUrl: URL(string: "localhost:8080")!)

func requestCharge(token: STPToken, completion: @escaping (Result<(), Error>) -> Void) {
var options = Options()
options.httpMethod = .post
options.path = "request_charge_apple_pay"
options.parameters = [
"token": token.tokenId
]

client.make(options: options, completion: { result in
completion(result.map({ _ in () }))
})
}

func useApplePay(payment: PKPayment, completion: @escaping (Result<(), Error>) -> Void) {
STPAPIClient.shared().createToken(with: payment, completion: { (token: STPToken?, error: Error?) in
guard let token = token, error == nil else {
return
}

self.requestCharge(token: token, completion: completion)
})
}
}

Use PKPaymentAuthorizationViewController, not PKPaymentAuthorizationController

https://developer.apple.com/documentation/passkit/pkpaymentauthorizationcontroller

The PKPaymentAuthorizationController class performs the same role as the PKPaymentAuthorizationViewController class, but it does not depend on the UIKit framework. This means that the authorization controller can be used in places where a view controller cannot (for example, in watchOS apps or in SiriKit extensions).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
extension MainController {
func showApplePay() {
let merchantId = "merchant.com.onmyway133.MyApp"
let paymentRequest = Stripe.paymentRequest(withMerchantIdentifier: merchantId, country: "US", currency: "USD")
paymentRequest.paymentSummaryItems = [
PKPaymentSummaryItem(label: "Rubber duck", amount: 1.5)
]

guard Stripe.canSubmitPaymentRequest(paymentRequest) else {
assertionFailure()
return
}

guard let authorizationViewController = PKPaymentAuthorizationViewController(paymentRequest: paymentRequest) else {
assertionFailure()
return
}

authorizationViewController.delegate = self
innerNavigationController.present(authorizationViewController, animated: true, completion: nil)
}
}

extension MainController: PKPaymentAuthorizationViewControllerDelegate {
func paymentAuthorizationViewControllerDidFinish(_ controller: PKPaymentAuthorizationViewController) {
controller.dismiss(animated: true, completion: nil)
}

func paymentAuthorizationViewController(
_ controller: PKPaymentAuthorizationViewController,
didAuthorizePayment payment: PKPayment,
handler completion: @escaping (PKPaymentAuthorizationResult) -> Void) {

client.useApplePay(payment: payment, completion: { result in
switch result {
case .success:
completion(.init(status: .success, errors: nil))
case .failure(let error):
completion(.init(status: .failure, errors: [error]))
}
})
}
}

Showing Apple Pay option

From appleMerchantIdentifier

The Apple Merchant Identifier to use during Apple Pay transactions. To create one of these, see our guide at https://stripe.com/docs/mobile/apple-pay . You must set this to a valid identifier in order to automatically enable Apple Pay.

1
2
3
4
5
if Stripe.deviceSupportsApplePay() {
STPPaymentConfiguration.shared().appleMerchantIdentifier = "merchant.com.onmyway133.MyApp"
}

paymentContext.pushPaymentOptionsViewController()

requestPayment not showing UI

From requestPayment

Requests payment from the user. This may need to present some supplemental UI to the user, in which case it will be presented on the payment context’s hostViewController. For instance, if they’ve selected Apple Pay as their payment method, calling this method will show the payment sheet. If the user has a card on file, this will use that without presenting any additional UI. After this is called, the paymentContext:didCreatePaymentResult:completion: and paymentContext:didFinishWithStatus:error: methods will be called on the context’s delegate.

Use STPPaymentOptionsViewController to show cards and Apple Pay options

Code for requestPayment

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
- (void)requestPayment {
WEAK(self);
[[[self.didAppearPromise voidFlatMap:^STPPromise * _Nonnull{
STRONG(self);
return self.loadingPromise;
}] onSuccess:^(__unused STPPaymentOptionTuple *tuple) {
STRONG(self);
if (!self) {
return;
}

if (self.state != STPPaymentContextStateNone) {
return;
}

if (!self.selectedPaymentOption) {
[self presentPaymentOptionsViewControllerWithNewState:STPPaymentContextStateRequestingPayment];
}
else if ([self requestPaymentShouldPresentShippingViewController]) {
[self presentShippingViewControllerWithNewState:STPPaymentContextStateRequestingPayment];
}
else if ([self.selectedPaymentOption isKindOfClass:[STPCard class]] ||
[self.selectedPaymentOption isKindOfClass:[STPSource class]]) {
self.state = STPPaymentContextStateRequestingPayment;
STPPaymentResult *result = [[STPPaymentResult alloc] initWithSource:(id<STPSourceProtocol>)self.selectedPaymentOption];
[self.delegate paymentContext:self didCreatePaymentResult:result completion:^(NSError * _Nullable error) {
stpDispatchToMainThreadIfNecessary(^{
if (error) {
[self didFinishWithStatus:STPPaymentStatusError error:error];
} else {
[self didFinishWithStatus:STPPaymentStatusSuccess error:nil];
}
});
}];
}
else if ([self.selectedPaymentOption isKindOfClass:[STPApplePayPaymentOption class]]) {
// ....

Payment options

1
2
3
func paymentOptionsViewController(_ paymentOptionsViewController: STPPaymentOptionsViewController, didSelect paymentOption: STPPaymentOption) {
// No op
}

After user selects payment option, the change is saved in dashboard https://dashboard.stripe.com/test/customers, but for card only. Select Apple Pay does not reflect change in web dashboard.

Apple pay option is added manually locally, from STPCustomer+SourceTuple.m 😲

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
- (STPPaymentOptionTuple *)filteredSourceTupleForUIWithConfiguration:(STPPaymentConfiguration *)configuration {
id<STPPaymentOption> _Nullable selectedMethod = nil;
NSMutableArray<id<STPPaymentOption>> *methods = [NSMutableArray array];
for (id<STPSourceProtocol> customerSource in self.sources) {
if ([customerSource isKindOfClass:[STPCard class]]) {
STPCard *card = (STPCard *)customerSource;
[methods addObject:card];
if ([card.stripeID isEqualToString:self.defaultSource.stripeID]) {
selectedMethod = card;
}
}
else if ([customerSource isKindOfClass:[STPSource class]]) {
STPSource *source = (STPSource *)customerSource;
if (source.type == STPSourceTypeCard
&& source.cardDetails != nil) {
[methods addObject:source];
if ([source.stripeID isEqualToString:self.defaultSource.stripeID]) {
selectedMethod = source;
}
}
}
}

return [STPPaymentOptionTuple tupleWithPaymentOptions:methods
selectedPaymentOption:selectedMethod
addApplePayOption:configuration.applePayEnabled];
}

STPApplePayPaymentOptionis not available inpaymentContext.paymentOptions` immediately

Change selected payment option

In STPPaymentContext

setSelectedPaymentOption is read only and trigger paymentContextDidChange, but it checks if the new selected payment option is equal to existing selected payment option

1
2
3
4
5
6
7
8
9
10
11
- (void)setSelectedPaymentOption:(id<STPPaymentOption>)selectedPaymentOption {
if (selectedPaymentOption && ![self.paymentOptions containsObject:selectedPaymentOption]) {
self.paymentOptions = [self.paymentOptions arrayByAddingObject:selectedPaymentOption];
}
if (![_selectedPaymentOption isEqual:selectedPaymentOption]) {
_selectedPaymentOption = selectedPaymentOption;
stpDispatchToMainThreadIfNecessary(^{
[self.delegate paymentContextDidChange:self];
});
}
}

There is retryLoading which is called at init

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
- (void)retryLoading {
// Clear any cached customer object before refetching
if ([self.apiAdapter isKindOfClass:[STPCustomerContext class]]) {
STPCustomerContext *customerContext = (STPCustomerContext *)self.apiAdapter;
[customerContext clearCachedCustomer];
}
WEAK(self);
self.loadingPromise = [[[STPPromise<STPPaymentOptionTuple *> new] onSuccess:^(STPPaymentOptionTuple *tuple) {
STRONG(self);
self.paymentOptions = tuple.paymentOptions;
self.selectedPaymentOption = tuple.selectedPaymentOption;
}] onFailure:^(NSError * _Nonnull error) {
STRONG(self);
if (self.hostViewController) {
[self.didAppearPromise onSuccess:^(__unused id value) {
if (self.paymentOptionsViewController) {
[self appropriatelyDismissPaymentOptionsViewController:self.paymentOptionsViewController completion:^{
[self.delegate paymentContext:self didFailToLoadWithError:error];
}];
} else {
[self.delegate paymentContext:self didFailToLoadWithError:error];
}
}];
}
}];
[self.apiAdapter retrieveCustomer:^(STPCustomer * _Nullable customer, NSError * _Nullable error) {
stpDispatchToMainThreadIfNecessary(^{
STRONG(self);
if (!self) {
return;
}
if (error) {
[self.loadingPromise fail:error];
return;
}
if (!self.shippingAddress && customer.shippingAddress) {
self.shippingAddress = customer.shippingAddress;
self.shippingAddressNeedsVerification = YES;
}

STPPaymentOptionTuple *paymentTuple = [customer filteredSourceTupleForUIWithConfiguration:self.configuration];

[self.loadingPromise succeed:paymentTuple];
});
}];
}

Which in turns call STPCustomerEphemeralKeyProvider. As stripe does not save Apple Pay option in dashboard, this method return list of card payment options, together with the default card as selected payment option 😲

Although the new STPCard has a different address, it is the exact same card with the same info, and the isEqual method of STPCard is

1
2
3
4
5
6
7
8
9
10
11
- (BOOL)isEqualToCard:(nullable STPCard *)other {
if (self == other) {
return YES;
}

if (!other || ![other isKindOfClass:self.class]) {
return NO;
}

return [self.stripeID isEqualToString:other.stripeID];
}

I raised an issue How to change selected payment option? hope it gets resolved soon 😢

Comments