Signal-iOS/SignalMessaging/Subscriptions/Stripe.swift
Harry 003cc19e62
Update SignalCoreKit. Use explicit DispatchQueue instead of relying on the implicit type on promise 'on' params
* Use explicit DispatchQueue instead of relying on the implicit type on promise 'on' params

* Update SignalCoreKit
2023-02-07 16:14:39 -08:00

339 lines
13 KiB
Swift

//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import PassKit
import SignalServiceKit
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
}
}
public static func boost(
amount: FiatMoney,
level: OneTimeBadgeLevel,
for paymentMethod: PaymentMethod
) -> Promise<ConfirmedIntent> {
firstly { () -> Promise<PaymentIntent> in
createBoostPaymentIntent(for: amount, level: level)
}.then { intent in
confirmPaymentIntent(
for: paymentMethod,
clientSecret: intent.clientSecret,
paymentIntentId: intent.id
)
}
}
public static func createBoostPaymentIntent(
for amount: FiatMoney,
level: OneTimeBadgeLevel
) -> Promise<PaymentIntent> {
firstly(on: DispatchQueue.sharedUserInitiated) { () -> Promise<HTTPResponse> 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
)
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")
)
}
}
public static func createPaymentMethod(
with paymentMethod: PaymentMethod
) -> Promise<String> {
firstly(on: DispatchQueue.sharedUserInitiated) { () -> Promise<String> in
API.createToken(with: paymentMethod)
}.then(on: DispatchQueue.sharedUserInitiated) { tokenId -> Promise<HTTPResponse> in
let parameters: [String: Any] = ["card": ["token": tokenId], "type": "card"]
return try API.postForm(endpoint: "payment_methods", parameters: parameters)
}.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<String> in
throw convertToStripeErrorIfPossible(error)
}
}
public struct ConfirmedIntent {
public let intentId: String
public let redirectToUrl: URL?
}
static func confirmPaymentIntent(
for paymentMethod: PaymentMethod,
clientSecret: String,
paymentIntentId: String
) -> Promise<ConfirmedIntent> {
firstly(on: DispatchQueue.sharedUserInitiated) { () -> Promise<String> in
createPaymentMethod(with: paymentMethod)
}.then(on: DispatchQueue.sharedUserInitiated) { paymentMethodId -> Promise<ConfirmedIntent> in
confirmPaymentIntent(
paymentIntentClientSecret: clientSecret,
paymentIntentId: paymentIntentId,
paymentMethodId: paymentMethodId
)
}
}
public static func confirmPaymentIntent(
paymentIntentClientSecret: String,
paymentIntentId: String,
paymentMethodId: String,
idempotencyKey: String? = nil
) -> Promise<ConfirmedIntent> {
firstly(on: DispatchQueue.sharedUserInitiated) { () -> Promise<HTTPResponse> in
try API.postForm(endpoint: "payment_intents/\(paymentIntentId)/confirm",
parameters: [
"payment_method": paymentMethodId,
"client_secret": paymentIntentClientSecret,
"return_url": RETURN_URL_FOR_3DS
],
idempotencyKey: idempotencyKey)
}.map(on: DispatchQueue.sharedUserInitiated) { response -> ConfirmedIntent in
.init(
intentId: paymentIntentId,
redirectToUrl: parseNextActionRedirectUrl(from: response.responseBodyJson)
)
}.recover(on: DispatchQueue.sharedUserInitiated) { error -> Promise<ConfirmedIntent> in
throw convertToStripeErrorIfPossible(error)
}
}
public static func confirmSetupIntent(
for paymentIntentID: String,
clientSecret: String
) -> Promise<ConfirmedIntent> {
firstly(on: DispatchQueue.sharedUserInitiated) { () -> Promise<HTTPResponse> in
let setupIntentId = try API.id(for: clientSecret)
return try API.postForm(endpoint: "setup_intents/\(setupIntentId)/confirm", parameters: [
"payment_method": paymentIntentID,
"client_secret": clientSecret,
"return_url": RETURN_URL_FOR_3DS
])
}.map(on: DispatchQueue.sharedUserInitiated) { response -> ConfirmedIntent in
.init(
intentId: paymentIntentID,
redirectToUrl: parseNextActionRedirectUrl(from: response.responseBodyJson)
)
}.recover(on: DispatchQueue.sharedUserInitiated) { error -> Promise<ConfirmedIntent> 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] {
var parameters = [String: Any]()
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: String] {
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)
]
}
/// Get the query parameters for a request to make a Stripe token.
///
/// See [Stripe's docs][0].
///
/// [0]: https://stripe.com/docs/api/tokens/create_card
static func parameters(for paymentMethod: PaymentMethod) -> [String: Any] {
switch paymentMethod {
case let .applePay(payment):
return parameters(for: payment)
case let .creditOrDebitCard(creditOrDebitCard):
return parameters(for: creditOrDebitCard)
}
}
static func createToken(with paymentMethod: PaymentMethod) -> Promise<String> {
firstly(on: DispatchQueue.sharedUserInitiated) { () -> Promise<HTTPResponse> in
return try postForm(endpoint: "tokens", parameters: parameters(for: 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")
}
}
static func postForm(endpoint: String,
parameters: [String: Any],
idempotencyKey: String? = nil) throws -> Promise<HTTPResponse> {
guard let formData = AFQueryStringFromParameters(parameters).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: - 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)
}()
}