The original post is published on the Just Eat tech blog at this URL.
Introduction
Apple Pay has recently been released in UK and at JUST EAT we worked on the integration in the iOS app to better support all of our customers and to ease the experience to both existing and new users. Until version 10 of our iOS UK app, the checkout for completing an order was wrapped into a webview and the flow was as follows:
Since Apple pushes developers to implement Apple Pay in a way that the checkout doesn't force the user to log in, the checkout flow had to be reworked, and we took the opportunity to make the majority of the checkout flow native. This enabled us to support both checkout flows:
- standard checkout (now with a more native flavour)
- Apple Pay checkout
The latter is clearly a fantastic solution for completing the checkout in very few steps with a great and simple UX. Thanks to the information provided by Apple Pay (inserted by the user when registering a debit/credit card) the user details native screen is no longer necessary and more importantly for the user, there is no need to log in to the platform.
A further detail on the checkout is that we support two different so-called "service types" for the orders: delivery and collection. Defined as so:
typedef NS_ENUM(NSUInteger, JEServiceType)
{
JEServiceTypeUnknown = 0,
JEServiceTypeDelivery,
JEServiceTypeCollection
};
On a side note, these changes soon became a challenge during the development as JUST EAT need to treat Apple Pay users (guest users) in a similar manner to users that have registered previously to our service.
How we designed around Apple Pay
At the time of writing there are already a few very good articles about a basic integration with Apple Pay. Probably the best reference worth mentioning is the NSHipster post.
Clearly also the Apple Documentation is a great start and the "Apple Pay Within Apps" video from WWDC 2015 explains really clearly all the relevant steps to have your app ready for Apple Pay.
Rather than discussing the basic concepts (creating the merchant ID, configuring the PKPaymentRequest
object, handling the presentation of the PKPaymentAuthorizationViewController, sending the token to the Payment Service Provider, etc.), we think it'd be more useful to walk you through the architectural aspects we considered when designing the solution on iOS using Objective-C.
In the architecture we are proposing, the relevant components for handling an Apple Pay payment are the following:
- ApplePayService
- ApplePayPaymentHandler
- ApplePayPaymentRequestFactory
Some additional components are also present in the big picture:
- CheckoutService
- ABRecordRefConverter
- PaymentFlowController
N.B. We haven't used the iOS SDK provided by our PSP (Payment Service Provider) to communicate directly to it from the iOS app, but rather we rely on a payment API to complete this communication.
At JUST EAT we like dependency injection and composition when possible. Developing this new feature with these concepts in mind helped to develop components that are isolated, easily pluggable, easy to test and (sometimes) reusable.
The above components have well-defined responsibilities. We'll provide simplified code for the interfaces. Let's go through them in a constructive order:
N.B. Don't be alarmed if you see the usage of the JEFuture
or the JEProgress
symbols. Lots of parts in our codebase rely on JustPromises (the library about Future and Promises we open sourced on GitHub). You'll also see the usage of some DTOs and the JE
prefix.
- CheckoutService: responsible for handling the basic flow for the checkout. This is very much platform dependant. It could include the logic to perform the necessary actions in the backend to prepare the order to be completed with Apple Pay. In our case we need to store the user delivery notes (things like "The door bell doesn't work please call me when you arrive.") and the preferred time for the delivery.
@interface JECheckoutService : NSObject
/**
* Composition of operations that are needed to prepare the order to be payed.
*/
- (JEFuture *)checkoutWithProgress:(JEProgress *)progress
basketID:(NSString *)basketID
orderContactDetails:(JEOrderContactDetailsDTO *)orderContactDetails
deliveryDate:(NSDate *)deliveryDate
deliveryNotes:(NSString *)deliveryNotes;
- (void)cancelCheckoutForBasketWithID:(NSString *)basketID;
@end
- ApplePayPaymentRequestFactory: responsible for creating the
PKPaymentRequest
objects representing a transaction. In our case objects of this kind are initialised with a delivery method and a card fee. The input parameter for the method returning aPKPaymentRequest
is a representation of the basket.
@interface JEApplePayPaymentRequestFactory : NSObject
- (instancetype)initWithServiceType:(JEServiceType)serviceType
cardFee:(NSDecimalNumber *)cardFee NS_DESIGNATED_INITIALIZER;
- (PKPaymentRequest *)paymentRequestForBasket:(JEBasketDTO *)basketDTO;
- (NSArray *)summaryItemsForBasket:(JEBasketDTO *)basketDTO;
@end
You might wonder why summaryItemsForBasket:
is public rather than keep it private. The reason is related to the fact that the block parameter ofpaymentAuthorizationViewController:didSelectShippingAddress:completion:
has the following signature:
(void (^)(
PKPaymentAuthorizationStatus status,
NSArray *shippingMethods,
NSArray *summaryItems))
It might very well be that some items are not available to be shipped to a specific address, and therefore we need a way to provide the updated list of summary items for the new shipping address the user selected.
- ApplePayPaymentHandler: objects of this class are responsible for handling the payment from beginning to end. It's initialised with a CheckoutService that covers the initial part of the flow (i.e. the steps that happen with the standard checkout). A
PKPaymentRequest
and a basket representation are provided (along with some other minor details) to objects of this class when asked to actually process a payment.
@interface JEApplePayPaymentHandler : NSObject
- (instancetype)initWithServiceType:(JEServiceType)serviceType
checkoutService:(JECheckoutService *)checkoutService NS_DESIGNATED_INITIALIZER;
/**
* Handle an authorised PKPayment payment.
*
* @param payment The payment object provided by Apple Pay via the PassKit framework after the user authorised it.
* @param basket The basket representation to use for creating the order.
* @param paymentProvider The payment provider.
* @param deliveryDate The delivery time of the order.
* @param deliveryNotes THe delivery notes of the order.
*
* @return A future for the payment handling. Future can be successful, failed or canceled if 'cancelLastPaymentAttempt' is called during the initial state of the payment and it can therefore be aborted.
*/
- (JEFuture *)handlePayment:(PKPayment *)payment
forOrderWithBasket:(JEBasketDTO *)basket
paymentProvider:(NSString *)paymentProvider
deliveryDate:(NSDate *)deliveryDate
deliveryNotes:(NSString *)deliveryNotes;
/**
* Attempt to stop the payment process for a given payment.
*/
- (JEFuture *)attemptPaymentCancellation:(PKPayment *)payment;
@end
- ApplePayService: this service is responsible for implementing the
PKPaymentAuthorizationViewControllerDelegate
protocol, for checking if Apple Pay is enabled on the device and for cancelling the payment (if it's not too late). It's initialised with a view controller (used to display thePKPaymentAuthorizationViewController
), aJEApplePayPaymentRequestFactory
and aJEApplePayPaymentHandler
. The logic for handling the selection of shipping address or the shipping method is here.
@protocol JEApplePayServiceDelegate <NSObject>
@optional
- (void)applePayServiceDidPresentApplePaySheet:(JEApplePayService *)service;
- (void)applePayService:(JEApplePayService *)service didCompletePaymentProcessWithSuccessForOrderWithID:(NSString *)orderID;
- (void)applePayService:(JEApplePayService *)service didCompletePaymentProcessWithError:(NSError *)error;
- (void)applePayService:(JEApplePayService *)service didFinishWithPaymentStatus:(JEApplePayServicePaymentStatus)paymentStatus;
@end
@interface JEApplePayService : NSObject
@property (nonatomic, weak) id<JEApplePayServiceDelegate> delegate;
- (instancetype)initWithPresentingViewController:(UIViewController *)presentingViewController
paymentRequestFactory:(JEApplePayPaymentRequestFactory *)paymentRequestFactory
paymentHandler:(JEApplePayPaymentHandler *)paymentHandler NS_DESIGNATED_INITIALIZER;
+ (BOOL)isApplePayAvailableOnDevice;
/**
* Request the receiver to handle an Apple Pay payment.
*
* @param basket The basket representation for the order to be paid.
* @param paymentProvider The payment provider from the retrieved payment option.
* @param deliveryDate The delivery date of the order.
* @param deliveryNotes The delivery notes of the order.
*/
- (void)handlePaymentForOrderWithBasket:(JEBasketDTO *)basket
paymentProvider:(NSString *)paymentProvider
deliveryDate:(NSDate *)deliveryDate
deliveryNotes:(NSString *)deliveryNotes;
/**
* Attempt to stop the payment process for a given basket.
*/
- (void)attemptPaymentCancellationForBasket:(JEBasketDTO *)basket;
@end
- ABRecordRefConverter: just a bunch of class methods for isolating the logic for transforming the
ABRecordRef
to easy-to-use DTOs. Until iOS 8.4, the delegate methodpaymentAuthorizationViewController:didSelectShippingAddress:completion:
ofPKPaymentAuthorizationViewControllerDelegate
provides anABRecordRef
. Starting with iOS 9.0, this method is deprecated and a similar one using a wrapper object (PKContact
) on top of the Contacts framework is used.ABRecordRefConverter
basically does the work that Apple did for us in iOS 9.
@interface JEABRecordRefConverter : NSObject
+ (JEOrderContactDetailsDTO *)orderContactDetailsForABRecordRef:(ABRecordRef)recordRef
requireShippingDetails:(BOOL)requireShippingDetails
error:(NSError **)error;
+ (JEAddressDTO *)addressForABRecordRef:(ABRecordRef)recordRef error:(NSError **)error;
@end
Similar to what happened in theApplePayPaymentRequestFactory
, you might wonder why the addressForABRecordRef:
method is necessary. The reason is that the second parameter ofpaymentAuthorizationViewController:didSelectShippingAddress:completion:
is an ABRecordRef
populated with only the address information for privacy reasons.
- PaymentFlowController: it is responsible for creating the necessary stack and interactions between of all the above components. It acts as the delegate of the
ApplePayService
to handle the navigation flow and it should be intended to be the starting point and glue around our standard checkout flow and the Apple Pay one.
/* simplified code from JEPaymentFlowController */
JEApplePayPaymentRequestFactory *paymentRequestFactory =
[[JEApplePayPaymentRequestFactory alloc] initWithServiceType:self.serviceType
cardFee:self.applePayPaymentOption.fee];
self.checkoutService = [JECheckoutService new];
JEApplePayPaymentHandler *paymentHandler =
[[JEApplePayPaymentHandler alloc] initWithServiceType:self.serviceType
checkoutService:self.checkoutService];
self.applePayService =
[[JEApplePayService alloc] initWithPresentingViewController:self.navigationController
paymentRequestFactory:paymentRequestFactory
paymentHandler:paymentHandler];
self.applePayService.delegate = self;
[self.applePayService
handlePaymentForOrderWithBasketID:self.basketID
paymentProvider:self.applePayPaymentOption.paymentProvider
deliveryDate:deliveryDate
deliveryNotes:deliveryNotes];
We strongly believe in unit testing (and automation and integration testing as well) and we strive to cover our code with the necessary tests for every new feature we develop. Payments are clearly a hot topic and a crucial part of our business, therefore structuring this feature in small and separated components allowed us to easily keep the code coverage for the above components close to 100%.
PKPaymentRequest's pitfalls
The way the payment request is populated dictates what the Apple Pay sheet will display. How to populate the shipping and billing address properties is not completely straightforward. An excerpt of code we have in production is as follows:
/* basic PKPaymentRequest creation */
PKPaymentRequest *paymentRequest = [[PKPaymentRequest alloc] init];
paymentRequest.supportedNetworks = @[PKPaymentNetworkMasterCard, PKPaymentNetworkVisa];
paymentRequest.merchantCapabilities = PKMerchantCapability3DS;
paymentRequest.countryCode = @"GB"; // ISO 3166-1 alpha-2 country code
paymentRequest.currencyCode = @"GBP"; // ISO 4217 currency code
paymentRequest.requiredBillingAddressFields = PKAddressFieldAll;
if (__ORDER_IS_FOR_DELIVERY__)
{
paymentRequest.requiredShippingAddressFields = PKAddressFieldAll;
paymentRequest.shippingType = PKShippingTypeDelivery;
}
else if (__ORDER_IS_FOR_COLLECTION__)
{
paymentRequest.requiredShippingAddressFields = PKAddressFieldPhone | PKAddressFieldEmail;
}
return paymentRequest;
Using the above configuration the Apple Pay sheets for delivery and collection orders appear like so:
Note that, in the case of collection orders, even if we set the requiredShippingAddressFields
property to something meaningful (which isn't PKAddressFieldNone
), the associated cell in the sheet is not displayed.
At JUST EAT we need to know upfront if the order is for delivery or collection in order to let the user fill the basket accordingly (e.g. some items might not be available for delivery) and for this reason we couldn't leverage the built-in capabilities of Apple Pay to handle different shipping methods. Moreover, since Apple defines the shipping type like so:
typedef NS_ENUM(NSUInteger, PKShippingType) {
PKShippingTypeShipping,
PKShippingTypeDelivery,
PKShippingTypeStorePickup,
PKShippingTypeServicePickup
} NS_ENUM_AVAILABLE(NA, 8_3);
for collection orders the correct value to use would be PKShippingTypeStorePickup
but the address of the store must be present in the list of addresses the user has entered on the device. This isn't practical.
Going back to how the payment request is configured, it's critical for JUST EAT to have the phone number and the email of the customer in order for customer services to contact them in case something goes wrong with the order. This applies to delivery orders as well as collection orders. At first we thought that, since for collection orders the shipping address was not necessary, the property requiredShippingAddressFields
of the PKPaymentRequest
could be set to PKAddressFieldNone
and we could grab the phone number and email from the billingAddress
property.
Unfortunately, even setting the requiredBillingAddressFields
to PKAddressFieldAll
when fetching the info from the billingAddress
property of the PKPayment
the phone number and email values are not there. Crucially, to grab the necessary order details info we had to merge the information provided by the two properties (billingAddress
and shippingAddress
) as so:
@interface JEAddressDTO : NSObject
@property (nonatomic, copy, readonly) NSString *line1;
@property (nonatomic, copy, readonly) NSString *line2;
@property (nonatomic, copy, readonly) NSString *line3;
@property (nonatomic, copy, readonly) NSString *city;
@property (nonatomic, copy, readonly) NSString *postCode;
+ (instancetype)addressWithLine1:(NSString *)line1
line2:(NSString *)line2
line3:(NSString *)line3
city:(NSString *)city
postCode:(NSString *)postCode;
@end
@interface JEOrderContactDetailsDTO : NSObject
@property (nonatomic, copy, readonly) NSString *fullName;
@property (nonatomic, copy, readonly) NSString *phoneNumber;
@property (nonatomic, copy, readonly) NSString *email;
@property (nonatomic, strong, readonly) JEAddressDTO *address;
+ (instancetype)orderContactDetailsWithFullName:(NSString *)fullName
phoneNumber:(NSString *)phoneNumber
email:(NSString *)email
address:(JEAddressDTO *)address;
@end
/*
* simplified code for composing the necessary order details info from
* billing and shipping addresses within the `JEApplePayPaymentHandler`
*/
- (JEOrderContactDetailsDTO *)orderContactDetailsForPayment:(PKPayment *)payment
serviceType:(BOOL)serviceType
error:(NSError **)error
{
BOOL isDeliveryOrder = (serviceType == JEServiceTypeDelivery);
NSError *shippingAddressError = nil;
JEOrderContactDetailsDTO *contactDetailsFromShippingAddress =
[JEABRecordRefConverter orderContactDetailsForABRecordRef:payment.shippingAddress
requireShippingDetails:isDeliveryOrder
error:&shippingAddressError];
// at this point `contactDetailsFromShippingAddress` contains
if (shippingAddressError && error != NULL)
{
*error = shippingAddressError;
return nil;
}
JEOrderContactDetailsDTO *orderContactDetails = contactDetailsFromShippingAddress;
if (!isDeliveryOrder) // collection orders
{
NSError *billingAddressError = nil;
JEOrderContactDetailsDTO *contactDetailsFromBillingAddress =
[JEABRecordRefConverter orderContactDetailsForABRecordRef:payment.billingAddress
requireShippingDetails:NO
error:&billingAddressError];
if (billingAddressError && error != NULL)
{
*error = billingAddressError;
return nil;
}
// compose the order contact details
orderContactDetails =
[JEOrderContactDetailsDTO orderContactDetailsWithFullName:contactDetailsFromBillingAddress.fullName
phoneNumber:contactDetailsFromShippingAddress.phoneNumber
email:contactDetailsFromShippingAddress.email
address:contactDetailsFromBillingAddress.address];
}
return orderContactDetails;
}
Integration considerations
From our journey into the Apple Pay world we learned that Apple pushes a lot for the Apple Pay button to be prominent in your app's UI. Unless one starts an iOS application from scratch and provides only Apple Pay as a payment method, other methods are usually already available (cards, PayPal, etc.): showing the Apple Pay button as big as the other buttons leading to different payments is almost a mandatory requirement from Apple. Showing the button as a first option is equally important.
What is not so mandatory, but still very nice to have, is to provide the user with the ability to complete the checkout without logging in. This is a good thing for a few reasons:
- remove the friction and enable happy paths to help your customers pay quicker
- no need to collect data from the user any more, leveraging what's already provided by Apple Pay
- Apple and customer happiness
As in our case, this is far from being trivial and logic in the backend usually needs to be tweaked accordingly to support what we call "guest" or "anonymous" users.
On a side note, it wasn't completely clear from the beginning that the ABRecordRef
object provided in the paymentAuthorizationViewController:didSelectShippingAddress:completion:
delegate method does not contain the full user data but just the address, but that is sufficient information to calculate the shipping cost and the availability of the items to a specific address. The entire user information is provided in the PKPayment
object via the shippingAddress
and billingAddress
properties once the user authorised the payment.
In iOS 9 the API slightly changed mainly due to introduction of the Contacts framework and the deprecation of the AddressBook one. PassKit provides a new class PKContact
that is a wrapper around objects from the Contacts framework. This means that the following properties of PKPaymentRequest
objects:
@property (nonatomic, assign, nullable) ABRecordRef billingAddress NS_DEPRECATED_IOS(8_0, 9_0, "Use billingContact instead");
@property (nonatomic, assign, nullable) ABRecordRef shippingAddress NS_DEPRECATED_IOS(8_0, 9_0, "Use shippingContact instead");
are deprecated in favour of the following new ones:
@property (nonatomic, retain, nullable) PKContact *billingContact NS_AVAILABLE_IOS(9_0);
@property (nonatomic, retain, nullable) PKContact *shippingContact NS_AVAILABLE_IOS(9_0);
This will help a lot since the old ABRecordRef
is defined as a CFTypeRef
and the related APIs are not easy to consume in an object-oriented world.