// // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation import PassKit import SignalCoreKit /// Stripe donations /// /// One-time donation ("boost") process: /// 1. Start flow /// - ``Stripe/boost(amount:level:for:)`` /// 2. Intent creation /// - ``Stripe/createBoostPaymentIntent(for:level:paymentMethod:)`` /// 3. Payment source tokenization /// - `Stripe.API.createToken(with:)` /// - Cards need to be tokenized. /// - SEPA transfers are not tokenized. /// 4. PaymentMethod creation /// - ``Stripe/createPaymentMethod(with:)`` /// 5. Intent confirmation /// - Charges the user's payment method /// - ``Stripe/confirmPaymentIntent(paymentIntentClientSecret:paymentIntentId:paymentMethodId:idempotencyKey:)`` public struct Stripe: Dependencies { public struct PaymentIntent { let id: String let clientSecret: String fileprivate init(clientSecret: String) throws { self.id = try API.id(for: clientSecret) self.clientSecret = clientSecret } } /// Step 1: Starts the boost payment flow. public static func boost( amount: FiatMoney, level: OneTimeBadgeLevel, for paymentMethod: PaymentMethod ) -> Promise { firstly { () -> Promise in createBoostPaymentIntent(for: amount, level: level, paymentMethod: paymentMethod.stripePaymentMethod) }.then { intent in confirmPaymentIntent( for: paymentMethod, clientSecret: intent.clientSecret, paymentIntentId: intent.id ) } } /// Step 2: Creates boost payment intent public static func createBoostPaymentIntent( for amount: FiatMoney, level: OneTimeBadgeLevel, paymentMethod: OWSRequestFactory.StripePaymentMethod ) -> Promise { firstly(on: DispatchQueue.sharedUserInitiated) { () -> Promise in // The description is never translated as it's populated into an // english only receipt by Stripe. let request = OWSRequestFactory.boostStripeCreatePaymentIntent( integerMoneyValue: DonationUtilities.integralAmount(for: amount), inCurrencyCode: amount.currencyCode, level: level.rawValue, paymentMethod: paymentMethod ) return networkManager.makePromise(request: request) }.map(on: DispatchQueue.sharedUserInitiated) { response in guard let json = response.responseBodyJson else { throw OWSAssertionError("Missing or invalid JSON") } guard let parser = ParamParser(responseObject: json) else { throw OWSAssertionError("Failed to decode JSON response") } return try PaymentIntent( clientSecret: try parser.required(key: "clientSecret") ) } } /// Steps 3 and 4: Payment source tokenization and creates payment method public static func createPaymentMethod( with paymentMethod: PaymentMethod ) -> Promise { requestPaymentMethod(with: paymentMethod).map(on: DispatchQueue.sharedUserInitiated) { response in guard let json = response.responseBodyJson else { throw OWSAssertionError("Missing responseBodyJson") } guard let parser = ParamParser(responseObject: json) else { throw OWSAssertionError("Failed to decode JSON response") } return try parser.required(key: "id") }.recover(on: DispatchQueue.sharedUserInitiated) { error -> Promise in throw convertToStripeErrorIfPossible(error) } } private static func requestPaymentMethod( with paymentMethod: PaymentMethod ) -> Promise { switch paymentMethod { case let .applePay(payment: payment): return requestPaymentMethod(with: API.parameters(for: payment)) case let .creditOrDebitCard(creditOrDebitCard: card): return requestPaymentMethod(with: API.parameters(for: card)) case let .bankTransferSEPA(mandate: _, account: sepaAccount): return firstly(on: DispatchQueue.sharedUserInitiated) { () -> Promise in // Step 3 not required. // Step 4: Payment method creation let parameters: [String: String] = [ "billing_details[name]": sepaAccount.name, "billing_details[email]": sepaAccount.email, "sepa_debit[iban]": sepaAccount.iban, "type": "sepa_debit", ] return try API.postForm(endpoint: "payment_methods", parameters: parameters) } case let .bankTransferIDEAL(idealAccount): return firstly(on: DispatchQueue.sharedUserInitiated) { () -> Promise in let parameters: [String: String] = { switch idealAccount { case let .oneTime(name: name, IDEALBank: bank): return [ "billing_details[name]": name, "ideal[bank]": bank.rawValue, "type": "ideal" ] case let .recurring(mandate: _, name: name, email: email, IDEALBank: bank): return [ "billing_details[name]": name, "billing_details[email]": email, "ideal[bank]": bank.rawValue, "type": "ideal" ] } }() // Step 4: Payment method creation return try API.postForm(endpoint: "payment_methods", parameters: parameters) } } } private static func requestPaymentMethod( with tokenizationParameters: [String: any StripeQueryParamValue] ) -> Promise { firstly(on: DispatchQueue.sharedUserInitiated) { () -> Promise in // Step 3: Payment source tokenization API.createToken(with: tokenizationParameters) }.then(on: DispatchQueue.sharedUserInitiated) { tokenId -> Promise in // Step 4: Payment method creation let parameters: [String: any StripeQueryParamValue] = ["card": ["token": tokenId], "type": "card"] return try API.postForm(endpoint: "payment_methods", parameters: parameters) } } public struct ConfirmedPaymentIntent { public let paymentIntentId: String public let paymentMethodId: String public let redirectToUrl: URL? } public struct ConfirmedSetupIntent { public let setupIntentId: String public let paymentMethodId: String public let redirectToUrl: URL? } public typealias PaymentMethodID = String /// Steps 3, 4, and 5: Tokenizes payment source, creates payment method, and confirms payment intent. static func confirmPaymentIntent( for paymentMethod: PaymentMethod, clientSecret: String, paymentIntentId: String ) -> Promise { firstly(on: DispatchQueue.sharedUserInitiated) { () -> Promise in // Steps 3 and 4: Payment source tokenization and payment method creation createPaymentMethod(with: paymentMethod) }.then(on: DispatchQueue.sharedUserInitiated) { paymentMethodId -> Promise in // Step 5: Confirm payment intent confirmPaymentIntent( mandate: paymentMethod.mandate, paymentIntentClientSecret: clientSecret, paymentIntentId: paymentIntentId, paymentMethodId: paymentMethodId, callbackURL: paymentMethod.callbackURL ) } } /// Step 5: Confirms payment intent public static func confirmPaymentIntent( mandate: PaymentMethod.Mandate?, paymentIntentClientSecret: String, paymentIntentId: String, paymentMethodId: PaymentMethodID, callbackURL: String? = nil, idempotencyKey: String? = nil ) -> Promise { firstly(on: DispatchQueue.sharedUserInitiated) { () -> Promise in try API.postForm( endpoint: "payment_intents/\(paymentIntentId)/confirm", parameters: [ "payment_method": paymentMethodId, "client_secret": paymentIntentClientSecret, "return_url": callbackURL ?? RETURN_URL_FOR_3DS, ].merging( mandate?.parameters ?? [:], uniquingKeysWith: { _, new in new } ), idempotencyKey: idempotencyKey ) }.map(on: DispatchQueue.sharedUserInitiated) { response -> ConfirmedPaymentIntent in .init( paymentIntentId: paymentIntentId, paymentMethodId: paymentMethodId, redirectToUrl: parseNextActionRedirectUrl(from: response.responseBodyJson) ) }.recover(on: DispatchQueue.sharedUserInitiated) { error -> Promise in throw convertToStripeErrorIfPossible(error) } } public static func confirmSetupIntent( mandate: PaymentMethod.Mandate?, paymentMethodId: String, clientSecret: String, callbackURL: String? ) -> Promise { firstly(on: DispatchQueue.sharedUserInitiated) { () -> Promise in let setupIntentId = try API.id(for: clientSecret) return try API.postForm( endpoint: "setup_intents/\(setupIntentId)/confirm", parameters: [ "payment_method": paymentMethodId, "client_secret": clientSecret, "return_url": callbackURL ?? RETURN_URL_FOR_3DS, ].merging( mandate?.parameters ?? [:], uniquingKeysWith: { _, new in new } ) ) }.map(on: DispatchQueue.sharedUserInitiated) { response -> ConfirmedSetupIntent in guard let json = response.responseBodyJson else { throw OWSAssertionError("Missing responseBodyJson") } guard let parser = ParamParser(responseObject: json) else { throw OWSAssertionError("Failed to decode JSON response") } let setupIntentId: String = try parser.required(key: "id") return .init( setupIntentId: setupIntentId, paymentMethodId: paymentMethodId, redirectToUrl: parseNextActionRedirectUrl(from: response.responseBodyJson) ) }.recover(on: DispatchQueue.sharedUserInitiated) { error -> Promise in throw convertToStripeErrorIfPossible(error) } } } // MARK: - API fileprivate extension Stripe { static let publishableKey: String = TSConstants.isUsingProductionService ? "pk_live_6cmGZopuTsV8novGgJJW9JpC00vLIgtQ1D" : "pk_test_sngOd8FnXNkpce9nPXawKrJD00kIDngZkD" static let authorizationHeader = "Basic \(Data("\(publishableKey):".utf8).base64EncodedString())" static let urlSession = OWSURLSession( baseUrl: URL(string: "https://api.stripe.com/v1/")!, securityPolicy: OWSURLSession.defaultSecurityPolicy, configuration: URLSessionConfiguration.ephemeral ) struct API { static func id(for clientSecret: String) throws -> String { let components = clientSecret.components(separatedBy: "_secret_") if components.count >= 2, !components[0].isEmpty { return components[0] } else { throw OWSAssertionError("Invalid client secret") } } // MARK: Common Stripe integrations static func parameters(for payment: PKPayment) -> [String: any StripeQueryParamValue] { var parameters = [String: any StripeQueryParamValue]() parameters["pk_token"] = String(data: payment.token.paymentData, encoding: .utf8) if let billingContact = payment.billingContact { parameters["card"] = self.parameters(for: billingContact) } parameters["pk_token_instrument_name"] = payment.token.paymentMethod.displayName?.nilIfEmpty parameters["pk_token_payment_network"] = payment.token.paymentMethod.network.map { $0.rawValue } if payment.token.transactionIdentifier == "Simulated Identifier" { owsAssertDebug(!TSConstants.isUsingProductionService, "Simulated ApplePay only works in staging") // Generate a fake transaction identifier parameters["pk_token_transaction_id"] = "ApplePayStubs~4242424242424242~0~USD~\(UUID().uuidString)" } else { parameters["pk_token_transaction_id"] = payment.token.transactionIdentifier.nilIfEmpty } return parameters } static func parameters(for contact: PKContact) -> [String: any StripeQueryParamValue] { var parameters = [String: String]() if let name = contact.name { parameters["name"] = OWSFormat.formatNameComponents(name).nilIfEmpty } if let email = contact.emailAddress { parameters["email"] = email.nilIfEmpty } if let phoneNumber = contact.phoneNumber { parameters["phone"] = phoneNumber.stringValue.nilIfEmpty } if let address = contact.postalAddress { parameters["address_line1"] = address.street.nilIfEmpty parameters["address_city"] = address.city.nilIfEmpty parameters["address_state"] = address.state.nilIfEmpty parameters["address_zip"] = address.postalCode.nilIfEmpty parameters["address_country"] = address.isoCountryCode.uppercased() } return parameters } /// Get the query parameters for a request to make a Stripe card token. /// /// See [Stripe's docs][0]. /// /// [0]: https://stripe.com/docs/api/tokens/create_card static func parameters( for creditOrDebitCard: PaymentMethod.CreditOrDebitCard ) -> [String: String] { func pad(_ n: UInt8) -> String { n < 10 ? "0\(n)" : "\(n)" } return [ "card[number]": creditOrDebitCard.cardNumber, "card[exp_month]": pad(creditOrDebitCard.expirationMonth), "card[exp_year]": pad(creditOrDebitCard.expirationTwoDigitYear), "card[cvc]": String(creditOrDebitCard.cvv) ] } typealias Token = String /// Step 3 of the process. Payment source tokenization static func createToken(with tokenizationParameters: [String: any StripeQueryParamValue]) -> Promise { firstly(on: DispatchQueue.sharedUserInitiated) { () -> Promise in return try postForm(endpoint: "tokens", parameters: tokenizationParameters) }.map(on: DispatchQueue.sharedUserInitiated) { response in guard let json = response.responseBodyJson else { throw OWSAssertionError("Missing responseBodyJson") } guard let parser = ParamParser(responseObject: json) else { throw OWSAssertionError("Failed to decode JSON response") } return try parser.required(key: "id") } } /// Make a `POST` request to the Stripe API. static func postForm(endpoint: String, parameters: [String: any StripeQueryParamValue], idempotencyKey: String? = nil) throws -> Promise { guard let formData = try parameters.encodeStripeQueryParamValueToString().data(using: .utf8) else { throw OWSAssertionError("Failed to generate post body data") } var headers: [String: String] = [ "Content-Type": "application/x-www-form-urlencoded", "Authorization": authorizationHeader ] if let idempotencyKey = idempotencyKey { headers["Idempotency-Key"] = idempotencyKey } return urlSession.dataTaskPromise( endpoint, method: .post, headers: headers, body: formData ) } } } // MARK: - Encoding URL query parameters private func percentEncodeStringForQueryParam(_ string: String) throws -> String { // characters not allowed taken from RFC 3986 Section 2.1 with exceptions for ? and / from RFC 3986 Section 3.4 var charactersAllowed = CharacterSet.urlQueryAllowed charactersAllowed.remove(charactersIn: ":#[]@!$&'()*+,;=") guard let result = string.addingPercentEncoding(withAllowedCharacters: charactersAllowed) else { throw OWSAssertionError("string percent encoding for query param failed") } return result } /// This protocol is only exposed internal to the module for testing. Do not use. protocol StripeQueryParamValue { func encodeStripeQueryParamValueToString(key: String) throws -> String } extension String: StripeQueryParamValue { func encodeStripeQueryParamValueToString(key: String) throws -> String { let keyEncoded = try percentEncodeStringForQueryParam(key) let valueEncoded = try percentEncodeStringForQueryParam(self) return "\(keyEncoded)=\(valueEncoded)" } } extension NSNull: StripeQueryParamValue { func encodeStripeQueryParamValueToString(key: String) throws -> String { return try percentEncodeStringForQueryParam(key) } } extension Dictionary: StripeQueryParamValue { func encodeStripeQueryParamValueToString(key: String = "") throws -> String { var pairs: [String] = [] for subKey in keys.sorted() { let value = self[subKey]! let keyName: String if key.isEmpty { keyName = subKey } else { keyName = "\(key)[\(subKey)]" } pairs.append(try value.encodeStripeQueryParamValueToString(key: keyName)) } return pairs.joined(separator: "&") } } // MARK: - Converting to StripeError extension Stripe { private static func convertToStripeErrorIfPossible(_ error: Error) -> Error { guard let responseJson = error.httpResponseJson as? [String: Any], let errorJson = responseJson["error"] as? [String: Any], let code = errorJson["code"] as? String, !code.isEmpty else { return error } return StripeError(code: code) } } // MARK: - Currency // See https://stripe.com/docs/currencies public extension Stripe { static let preferredCurrencyCodes: [Currency.Code] = [ "USD", "AUD", "BRL", "GBP", "CAD", "CNY", "EUR", "HKD", "INR", "JPY", "KRW", "PLN", "SEK", "CHF" ] static let preferredCurrencyInfos: [Currency.Info] = { Currency.infos(for: preferredCurrencyCodes, ignoreMissingNames: true, shouldSort: false) }() } // MARK: - Callbacks public extension Stripe { static func isStripeIDEALCallback(_ url: URL) -> Bool { url.scheme == "https" && url.host == "signaldonations.org" && url.path == "/ideal" && url.user == nil && url.password == nil && url.port == nil } enum IDEALCallbackType { case oneTime(didSucceed: Bool, paymentIntentId: String) case monthly(didSucceed: Bool, clientSecret: String, setupIntentId: String) } static func parseStripeIDEALCallback(_ url: URL) -> IDEALCallbackType?{ guard isStripeIDEALCallback(url), let components = URLComponents(string: url.absoluteString), let queryItems = components.queryItems else { return nil } /// This is more of an optimization to allow failing fast if the payment was known to be declined. /// However, success is assumed and only the 'failed' state is checked here to guard against the /// possibility of these strings changing and causing a missing 'success' to result in a false failure. /// In the case that the 'failed' string changes, the app will still show the user the failed state, but it /// will happend later on in the donation processing flow vs. happening here. var redirectSuccess = true if let resultItem = queryItems.first(where: { $0.name == "redirect_status" }), let resultString = resultItem.value, resultString == "failed" { redirectSuccess = false } if let intentItem = queryItems.first(where: { $0.name == "payment_intent" }), let paymentIntentId = intentItem.value { return .oneTime( didSucceed: redirectSuccess, paymentIntentId: paymentIntentId ) } if let clientSecretItem = queryItems.first(where: { $0.name == "setup_intent_client_secret" }), let clientSecret = clientSecretItem.value, let intentItem = queryItems.first(where: { $0.name == "setup_intent" }), let setupIntentId = intentItem.value { return .monthly( didSucceed: redirectSuccess, clientSecret: clientSecret, setupIntentId: setupIntentId ) } return nil } }