509 lines
17 KiB
Swift
509 lines
17 KiB
Swift
//
|
|
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import PassKit
|
|
import SignalServiceKit
|
|
|
|
public struct Stripe: Dependencies {
|
|
public static func donate(amount: NSDecimalNumber, in currencyCode: Currency.Code, for payment: PKPayment) -> Promise<Void> {
|
|
firstly { () -> Promise<API.PaymentIntent> in
|
|
API.createPaymentIntent(for: amount, in: currencyCode)
|
|
}.then { intent in
|
|
API.confirmPaymentIntent(for: payment, clientSecret: intent.clientSecret, paymentIntentId: intent.id)
|
|
}
|
|
}
|
|
|
|
public static func boost(amount: NSDecimalNumber, in currencyCode: Currency.Code, for payment: PKPayment) -> Promise<String> {
|
|
firstly { () -> Promise<API.PaymentIntent> in
|
|
API.createBoostPaymentIntent(for: amount, in: currencyCode)
|
|
}.then { intent in
|
|
API.confirmPaymentIntent(for: payment, clientSecret: intent.clientSecret, paymentIntentId: intent.id).map { intent.id }
|
|
}
|
|
}
|
|
|
|
public static func createPaymentMethod(with payment: PKPayment) -> Promise<String> {
|
|
firstly(on: .sharedUserInitiated) { () -> Promise<String> in
|
|
API.createToken(with: payment)
|
|
}.then(on: .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: .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")
|
|
}
|
|
}
|
|
|
|
public static func confirmSetupIntent(for paymentIntentID: String, clientSecret: String, payment: PKPayment) throws -> Promise<HTTPResponse> {
|
|
firstly(on: .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
|
|
])
|
|
}
|
|
}
|
|
|
|
public static func isAmountTooLarge(_ amount: NSDecimalNumber, in currencyCode: Currency.Code) -> Bool {
|
|
// Stripe supports a maximum of 8 integral digits.
|
|
integralAmount(amount, in: currencyCode) > 99_999_999
|
|
}
|
|
|
|
public static func isAmountTooSmall(_ amount: NSDecimalNumber, in currencyCode: Currency.Code) -> Bool {
|
|
// Stripe requires different minimums per currency, but they often depend
|
|
// on conversion rates which we don't have access to. It's okay to do a best
|
|
// effort here because stripe will reject the payment anyway, this just allows
|
|
// us to fail sooner / provide a nicer error to the user.
|
|
let minimumIntegralAmount = minimumIntegralChargePerCurrencyCode[currencyCode] ?? 50
|
|
return integralAmount(amount, in: currencyCode) < minimumIntegralAmount
|
|
}
|
|
|
|
public static func integralAmount(_ amount: NSDecimalNumber, in currencyCode: Currency.Code) -> UInt {
|
|
let roundedAndScaledAmount: Double
|
|
if zeroDecimalCurrencyCodes.contains(currencyCode.uppercased()) {
|
|
roundedAndScaledAmount = amount.doubleValue.rounded(.toNearestOrEven)
|
|
} else {
|
|
roundedAndScaledAmount = (amount.doubleValue * 100).rounded(.toNearestOrEven)
|
|
}
|
|
|
|
guard roundedAndScaledAmount <= Double(UInt.max) else { return UInt.max }
|
|
guard roundedAndScaledAmount >= 0 else { return 0 }
|
|
return UInt(roundedAndScaledAmount)
|
|
}
|
|
}
|
|
|
|
// MARK: - API
|
|
fileprivate extension Stripe {
|
|
|
|
static let publishableKey: String = FeatureFlags.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 {
|
|
struct PaymentIntent {
|
|
let id: String
|
|
let clientSecret: String
|
|
|
|
init(clientSecret: String) throws {
|
|
self.id = try API.id(for: clientSecret)
|
|
self.clientSecret = clientSecret
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
static func createPaymentIntent(
|
|
for amount: NSDecimalNumber,
|
|
in currencyCode: Currency.Code
|
|
) -> Promise<(PaymentIntent)> {
|
|
firstly(on: .sharedUserInitiated) { () -> Promise<HTTPResponse> in
|
|
guard !isAmountTooSmall(amount, in: currencyCode) else {
|
|
throw OWSAssertionError("Amount too small")
|
|
}
|
|
|
|
guard !isAmountTooLarge(amount, in: currencyCode) else {
|
|
throw OWSAssertionError("Amount too large")
|
|
}
|
|
|
|
guard supportedCurrencyCodes.contains(currencyCode.uppercased()) else {
|
|
throw OWSAssertionError("Unexpected currency code")
|
|
}
|
|
|
|
// The description is never translated as it's populated into an
|
|
// english only receipt by Stripe.
|
|
let request = OWSRequestFactory.createPaymentIntent(
|
|
withAmount: integralAmount(amount, in: currencyCode),
|
|
inCurrencyCode: currencyCode,
|
|
withDescription: LocalizationNotNeeded("Thank you for your donation. Your contribution helps fuel the mission of developing open source privacy technology that protects free expression and enables secure global communication for millions around the world. Signal Technology Foundation is a tax-exempt nonprofit organization in the United States under section 501c3 of the Internal Revenue Code. Our Federal Tax ID is 82-4506840. No goods or services were provided in exchange for this donation. Please retain this receipt for your tax records.")
|
|
)
|
|
|
|
return networkManager.makePromise(request: request)
|
|
}.map(on: .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: "client_secret")
|
|
)
|
|
}
|
|
}
|
|
|
|
static func createBoostPaymentIntent(
|
|
for amount: NSDecimalNumber,
|
|
in currencyCode: Currency.Code
|
|
) -> Promise<(PaymentIntent)> {
|
|
firstly(on: .sharedUserInitiated) { () -> Promise<HTTPResponse> in
|
|
guard !isAmountTooSmall(amount, in: currencyCode) else {
|
|
throw OWSAssertionError("Amount too small")
|
|
}
|
|
|
|
guard !isAmountTooLarge(amount, in: currencyCode) else {
|
|
throw OWSAssertionError("Amount too large")
|
|
}
|
|
|
|
guard supportedCurrencyCodes.contains(currencyCode.uppercased()) else {
|
|
throw OWSAssertionError("Unexpected currency code")
|
|
}
|
|
|
|
// The description is never translated as it's populated into an
|
|
// english only receipt by Stripe.
|
|
let request = OWSRequestFactory.boostCreatePaymentIntent(
|
|
withAmount: integralAmount(amount, in: currencyCode),
|
|
inCurrencyCode: currencyCode
|
|
)
|
|
|
|
return networkManager.makePromise(request: request)
|
|
}.map(on: .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")
|
|
)
|
|
}
|
|
}
|
|
|
|
static func confirmPaymentIntent(for payment: PKPayment, clientSecret: String, paymentIntentId: String) -> Promise<Void> {
|
|
firstly(on: .sharedUserInitiated) { () -> Promise<String> in
|
|
createPaymentMethod(with: payment)
|
|
}.then(on: .sharedUserInitiated) { paymentMethodId -> Promise<HTTPResponse> in
|
|
|
|
guard !SubscriptionManager.terminateTransactionIfPossible else {
|
|
throw OWSGenericError("Boost transaction chain cancelled")
|
|
}
|
|
|
|
let parameters = [
|
|
"payment_method": paymentMethodId,
|
|
"client_secret": clientSecret
|
|
]
|
|
return try postForm(endpoint: "payment_intents/\(paymentIntentId)/confirm", parameters: parameters)
|
|
}.asVoid()
|
|
}
|
|
|
|
// 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(!FeatureFlags.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
|
|
}
|
|
|
|
static func createToken(with payment: PKPayment) -> Promise<String> {
|
|
firstly(on: .sharedUserInitiated) { () -> Promise<HTTPResponse> in
|
|
return try postForm(endpoint: "tokens", parameters: parameters(for: payment))
|
|
}.map(on: .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]) throws -> Promise<HTTPResponse> {
|
|
guard let formData = AFQueryStringFromParameters(parameters).data(using: .utf8) else {
|
|
throw OWSAssertionError("Failed to generate post body data")
|
|
}
|
|
|
|
return urlSession.dataTaskPromise(
|
|
endpoint,
|
|
method: .post,
|
|
headers: ["Content-Type": "application/x-www-form-urlencoded", "Authorization": authorizationHeader],
|
|
body: formData
|
|
)
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
// MARK: - Currency
|
|
// See https://stripe.com/docs/currencies
|
|
|
|
public extension Stripe {
|
|
static let supportedCurrencyCodes: [Currency.Code] = [
|
|
"USD",
|
|
"AED",
|
|
"AFN",
|
|
"ALL",
|
|
"AMD",
|
|
"ANG",
|
|
"AOA",
|
|
"ARS",
|
|
"AUD",
|
|
"AWG",
|
|
"AZN",
|
|
"BAM",
|
|
"BBD",
|
|
"BDT",
|
|
"BGN",
|
|
"BIF",
|
|
"BMD",
|
|
"BND",
|
|
"BOB",
|
|
"BRL",
|
|
"BSD",
|
|
"BWP",
|
|
"BZD",
|
|
"CAD",
|
|
"CDF",
|
|
"CHF",
|
|
"CLP",
|
|
"CNY",
|
|
"COP",
|
|
"CRC",
|
|
"CVE",
|
|
"CZK",
|
|
"DJF",
|
|
"DKK",
|
|
"DOP",
|
|
"DZD",
|
|
"EGP",
|
|
"ETB",
|
|
"EUR",
|
|
"FJD",
|
|
"FKP",
|
|
"GBP",
|
|
"GEL",
|
|
"GIP",
|
|
"GMD",
|
|
"GNF",
|
|
"GTQ",
|
|
"GYD",
|
|
"HKD",
|
|
"HNL",
|
|
"HRK",
|
|
"HTG",
|
|
"HUF",
|
|
"IDR",
|
|
"ILS",
|
|
"INR",
|
|
"ISK",
|
|
"JMD",
|
|
"JPY",
|
|
"KES",
|
|
"KGS",
|
|
"KHR",
|
|
"KMF",
|
|
"KRW",
|
|
"KYD",
|
|
"KZT",
|
|
"LAK",
|
|
"LBP",
|
|
"LKR",
|
|
"LRD",
|
|
"LSL",
|
|
"MAD",
|
|
"MDL",
|
|
"MGA",
|
|
"MKD",
|
|
"MMK",
|
|
"MNT",
|
|
"MOP",
|
|
"MRO",
|
|
"MUR",
|
|
"MVR",
|
|
"MWK",
|
|
"MXN",
|
|
"MYR",
|
|
"MZN",
|
|
"NAD",
|
|
"NGN",
|
|
"NIO",
|
|
"NOK",
|
|
"NPR",
|
|
"NZD",
|
|
"PAB",
|
|
"PEN",
|
|
"PGK",
|
|
"PHP",
|
|
"PKR",
|
|
"PLN",
|
|
"PYG",
|
|
"QAR",
|
|
"RON",
|
|
"RSD",
|
|
"RUB",
|
|
"RWF",
|
|
"SAR",
|
|
"SBD",
|
|
"SCR",
|
|
"SEK",
|
|
"SGD",
|
|
"SHP",
|
|
"SLL",
|
|
"SOS",
|
|
"SRD",
|
|
"STD",
|
|
"SZL",
|
|
"THB",
|
|
"TJS",
|
|
"TOP",
|
|
"TRY",
|
|
"TTD",
|
|
"TWD",
|
|
"TZS",
|
|
"UAH",
|
|
"UGX",
|
|
"UYU",
|
|
"UZS",
|
|
"VND",
|
|
"VUV",
|
|
"WST",
|
|
"XAF",
|
|
"XCD",
|
|
"XOF",
|
|
"XPF",
|
|
"YER",
|
|
"ZAR",
|
|
"ZMW"
|
|
]
|
|
public static let supportedCurrencyInfos: [Currency.Info] = {
|
|
Currency.infos(for: supportedCurrencyCodes, ignoreMissingNames: false, shouldSort: true)
|
|
}()
|
|
|
|
public static let preferredCurrencyCodes: [Currency.Code] = [
|
|
"USD",
|
|
"AUD",
|
|
"BRL",
|
|
"GBP",
|
|
"CAD",
|
|
"CNY",
|
|
"EUR",
|
|
"HKD",
|
|
"INR",
|
|
"JPY",
|
|
"KRW",
|
|
"PLN",
|
|
"SEK",
|
|
"CHF"
|
|
]
|
|
public static let preferredCurrencyInfos: [Currency.Info] = {
|
|
Currency.infos(for: preferredCurrencyCodes, ignoreMissingNames: true, shouldSort: false)
|
|
}()
|
|
|
|
public static let zeroDecimalCurrencyCodes: [Currency.Code] = [
|
|
"BIF",
|
|
"CLP",
|
|
"DJF",
|
|
"GNF",
|
|
"JPY",
|
|
"KMF",
|
|
"KRW",
|
|
"MGA",
|
|
"PYG",
|
|
"RWF",
|
|
"UGX",
|
|
"VND",
|
|
"VUV",
|
|
"XAF",
|
|
"XOF",
|
|
"XPF"
|
|
]
|
|
|
|
public static let minimumIntegralChargePerCurrencyCode: [Currency.Code: UInt] = [
|
|
"USD": 50,
|
|
"AED": 200,
|
|
"AUD": 50,
|
|
"BGN": 100,
|
|
"BRL": 50,
|
|
"CAD": 50,
|
|
"CHF": 50,
|
|
"CZK": 1500,
|
|
"DKK": 250,
|
|
"EUR": 50,
|
|
"GBP": 30,
|
|
"HKD": 400,
|
|
"HUF": 17500,
|
|
"INR": 50,
|
|
"JPY": 50,
|
|
"MXN": 10,
|
|
"MYR": 2,
|
|
"NOK": 300,
|
|
"NZD": 50,
|
|
"PLN": 200,
|
|
"RON": 200,
|
|
"SEK": 300,
|
|
"SGD": 50
|
|
]
|
|
|
|
public static let defaultCurrencyCode: Currency.Code = {
|
|
if let localeCurrencyCode = Locale.current.currencyCode?.uppercased(), supportedCurrencyCodes.contains(localeCurrencyCode) {
|
|
return localeCurrencyCode
|
|
}
|
|
|
|
return "USD"
|
|
}()
|
|
}
|