TM-SGNL-iOS/SignalServiceKit/Subscriptions/Donations/Stripe.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

573 lines
22 KiB
Swift

//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import PassKit
/// 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 {
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<ConfirmedPaymentIntent> {
firstly { () -> Promise<PaymentIntent> 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<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,
paymentMethod: paymentMethod
)
return SSKEnvironment.shared.networkManagerRef.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<PaymentMethodID> {
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<PaymentMethodID> in
throw convertToStripeErrorIfPossible(error)
}
}
private static func requestPaymentMethod(
with paymentMethod: PaymentMethod
) -> Promise<HTTPResponse> {
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<HTTPResponse> 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<HTTPResponse> 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<HTTPResponse> {
firstly(on: DispatchQueue.sharedUserInitiated) { () -> Promise<API.Token> in
// Step 3: Payment source tokenization
API.createToken(with: tokenizationParameters)
}.then(on: DispatchQueue.sharedUserInitiated) { tokenId -> Promise<HTTPResponse> 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<ConfirmedPaymentIntent> {
firstly(on: DispatchQueue.sharedUserInitiated) { () -> Promise<PaymentMethodID> in
// Steps 3 and 4: Payment source tokenization and payment method creation
createPaymentMethod(with: paymentMethod)
}.then(on: DispatchQueue.sharedUserInitiated) { paymentMethodId -> Promise<ConfirmedPaymentIntent> 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<ConfirmedPaymentIntent> {
firstly(on: DispatchQueue.sharedUserInitiated) { () -> Promise<HTTPResponse> 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<ConfirmedPaymentIntent> in
throw convertToStripeErrorIfPossible(error)
}
}
public static func confirmSetupIntent(
mandate: PaymentMethod.Mandate?,
paymentMethodId: String,
clientSecret: String,
callbackURL: String?
) -> Promise<ConfirmedSetupIntent> {
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": 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<ConfirmedSetupIntent> 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<Token> {
firstly(on: DispatchQueue.sharedUserInitiated) { () -> Promise<HTTPResponse> 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<HTTPResponse> {
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 Promise.wrapAsync {
return try await urlSession.performRequest(
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<String, any StripeQueryParamValue>: 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 {
private static func isStripeIDEALCallback(_ url: URL) -> Bool {
if
url.scheme == "https" &&
url.host == "signaldonations.org" &&
url.path == "/ideal" &&
url.user == nil &&
url.password == nil &&
url.port == nil
{
return true
}
if
url.scheme == "sgnl" &&
url.host == "ideal" &&
url.path.isEmpty &&
url.user == nil &&
url.password == nil &&
url.port == nil
{
return true
}
return false
}
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
}
}