import Foundation import PassKit /// Presents Apple Pay and sends the resulting (encrypted) token to TruePay's /// /api/v1/transactions/wallet-pay. TruePay decrypts it server-side with the /// merchant's Payment Processing certificate. final class PaymentHandler: NSObject, ObservableObject, PKPaymentAuthorizationControllerDelegate { @Published var status: String? @Published var succeeded = false private var controller: PKPaymentAuthorizationController? private var amount: Double = 0 private var reference = "" func startPayment(amount: Double) { self.amount = amount self.reference = "ios-demo-\(Int(Date().timeIntervalSince1970))" self.status = nil self.succeeded = false let request = PKPaymentRequest() request.merchantIdentifier = Config.merchantIdentifier request.merchantCapabilities = .threeDSecure request.supportedNetworks = Self.networks(Config.supportedNetworks) request.countryCode = Config.countryCode request.currencyCode = Config.currencyCode request.paymentSummaryItems = [ PKPaymentSummaryItem(label: Config.merchantDisplayName, amount: NSDecimalNumber(value: amount), type: .final) ] let controller = PKPaymentAuthorizationController(paymentRequest: request) controller.delegate = self self.controller = controller controller.present { presented in if !presented { // `presented: false` is Apple Pay refusing the REQUEST — it never reaches the // network, so nothing configured in TruePay can cause or fix it. Say which of the // few possible reasons it is, because the bare message sends people to the server. let reason = Self.cannotPresentReason(request) DispatchQueue.main.async { self.status = "Could not present Apple Pay — \(reason)" } } } } /// Why Apple Pay declined to open. /// /// `presented: false` is Apple Pay refusing the REQUEST. Nothing has gone to the network, so /// no certificate, merchant record or setting in TruePay can cause it or fix it — which is /// worth saying, because the bare message sends people to the server every time. /// /// iOS gives no API to read the app's own entitlements (SecTask… is macOS only), so the two /// device-side causes are tested directly and the entitlement is named as what is left. private static func cannotPresentReason(_ request: PKPaymentRequest) -> String { if !PKPaymentAuthorizationController.canMakePayments() { return "this device cannot make Apple Pay payments at all." } if !PKPaymentAuthorizationController.canMakePayments(usingNetworks: request.supportedNetworks) { let nets = request.supportedNetworks.map { $0.rawValue }.joined(separator: ", ") return "there is no card in Wallet for \(nets) on this device. Add one and try again." } if request.paymentSummaryItems.isEmpty || request.paymentSummaryItems[request.paymentSummaryItems.count - 1].amount.doubleValue <= 0 { return "the total must be greater than zero." } // The device is capable and has a usable card, so the request itself was rejected. In // practice that is the merchant identifier not being entitled to this build. return "the device is ready, so the request was rejected. Almost always this means " + "\(request.merchantIdentifier) is missing from this build's Apple Pay entitlement or " + "from its provisioning profile — add it under Signing & Capabilities -> Apple Pay, then " + "re-download the profile. Otherwise check countryCode (\(request.countryCode)) and " + "currencyCode (\(request.currencyCode)). Nothing in TruePay affects this." } // MARK: PKPaymentAuthorizationControllerDelegate func paymentAuthorizationController( _ controller: PKPaymentAuthorizationController, didAuthorizePayment payment: PKPayment, handler completion: @escaping (PKPaymentAuthorizationResult) -> Void ) { chargeWithTruePay(payment) { ok, message in DispatchQueue.main.async { self.succeeded = ok self.status = ok ? "Payment successful" : "Payment failed: \(message ?? "declined")" } completion(PKPaymentAuthorizationResult(status: ok ? .success : .failure, errors: nil)) } } func paymentAuthorizationControllerDidFinish(_ controller: PKPaymentAuthorizationController) { controller.dismiss() } // MARK: TruePay call /// POST the encrypted token to /api/v1/transactions/wallet-pay. private func chargeWithTruePay(_ payment: PKPayment, completion: @escaping (Bool, String?) -> Void) { guard let paymentDataObject = try? JSONSerialization.jsonObject(with: payment.token.paymentData) else { completion(false, "Malformed Apple Pay token"); return } // The PKPaymentToken as TruePay expects it under wallet.payment_token. let token: [String: Any] = [ "paymentData": paymentDataObject, "paymentMethod": [ "displayName": payment.token.paymentMethod.displayName ?? "", "network": payment.token.paymentMethod.network?.rawValue ?? "", "type": "\(payment.token.paymentMethod.type.rawValue)" ], "transactionIdentifier": payment.token.transactionIdentifier ] let body: [String: Any] = [ "amount": amount, "currency": Config.currencyCode, "reference_number": reference, "wallet": ["type": "APPLE_PAY", "payment_token": token] ] guard let url = URL(string: "\(Config.baseURL)/api/v1/transactions/wallet-pay"), let payload = try? JSONSerialization.data(withJSONObject: body) else { completion(false, "Bad request"); return } var req = URLRequest(url: url) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.setValue(Config.apiKey, forHTTPHeaderField: "OMPAY-API-Key") req.setValue(Config.apiSecret, forHTTPHeaderField: "OMPAY-API-Secret") req.httpBody = payload URLSession.shared.dataTask(with: req) { data, _, error in if let error = error { completion(false, error.localizedDescription); return } guard let data = data, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { completion(false, "No response from TruePay"); return } let ok = (json["success"] as? Bool) == true let dataObj = json["data"] as? [String: Any] let message = (dataObj?["error_message"] as? String) ?? (json["message"] as? String) completion(ok, message) }.resume() } private static func networks(_ names: [String]) -> [PKPaymentNetwork] { names.compactMap { name in switch name.lowercased() { case "visa": return .visa case "mastercard": return .masterCard case "amex": return .amex case "discover": return .discover case "mada": if #available(iOS 12.1.1, *) { return .mada } else { return nil } default: return nil } } } }