Previously, sending a gift badge was not a durable operation, which meant that crashes/failures could cause users to have their payment methods charged without actually sending the badge. Now, the flow is split up into two steps: non-durable parts before the charge is attempted, and durable parts afterward. The high-level flow is: 1. Prepare the payment, which involves a couple of repeatable network requests. 2. Enqueue a job with the prepared payment, that: 1. Charges the payment method (idempotently) 2. Requests a receipt credential (idempotently) 3. Enqueues a gift message, and optionally a text message 3. When the job completes, open the conversation in the UI.
533 lines
18 KiB
Swift
533 lines
18 KiB
Swift
//
|
|
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
|
|
//
|
|
|
|
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 donate(amount: NSDecimalNumber, in currencyCode: Currency.Code, for payment: PKPayment) -> Promise<Void> {
|
|
firstly { () -> Promise<PaymentIntent> in
|
|
API.createPaymentIntent(for: amount, in: currencyCode)
|
|
}.then { intent in
|
|
confirmPaymentIntent(for: payment, clientSecret: intent.clientSecret, paymentIntentId: intent.id)
|
|
}
|
|
}
|
|
|
|
public static func boost(amount: NSDecimalNumber,
|
|
in currencyCode: Currency.Code,
|
|
level: OneTimeBadgeLevel,
|
|
for payment: PKPayment) -> Promise<String> {
|
|
firstly { () -> Promise<PaymentIntent> in
|
|
createBoostPaymentIntent(for: amount, in: currencyCode, level: level)
|
|
}.then { intent in
|
|
confirmPaymentIntent(for: payment, clientSecret: intent.clientSecret, paymentIntentId: intent.id).map { intent.id }
|
|
}
|
|
}
|
|
|
|
public static func createBoostPaymentIntent(
|
|
for amount: NSDecimalNumber,
|
|
in currencyCode: Currency.Code,
|
|
level: OneTimeBadgeLevel
|
|
) -> 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,
|
|
level: level.rawValue
|
|
)
|
|
|
|
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")
|
|
)
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
return try confirmPaymentIntent(paymentIntentClientSecret: clientSecret,
|
|
paymentIntentId: paymentIntentId,
|
|
paymentMethodId: paymentMethodId)
|
|
}.asVoid()
|
|
}
|
|
|
|
public static func confirmPaymentIntent(paymentIntentClientSecret: String,
|
|
paymentIntentId: String,
|
|
paymentMethodId: String,
|
|
idempotencyKey: String? = nil) throws -> Promise<HTTPResponse> {
|
|
try API.postForm(endpoint: "payment_intents/\(paymentIntentId)/confirm",
|
|
parameters: [
|
|
"payment_method": paymentMethodId,
|
|
"client_secret": paymentIntentClientSecret
|
|
],
|
|
idempotencyKey: idempotencyKey)
|
|
}
|
|
|
|
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 = 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")
|
|
}
|
|
}
|
|
|
|
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")
|
|
)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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],
|
|
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: - 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"
|
|
]
|
|
static let supportedCurrencyInfos: [Currency.Info] = {
|
|
Currency.infos(for: supportedCurrencyCodes, ignoreMissingNames: false, shouldSort: true)
|
|
}()
|
|
|
|
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)
|
|
}()
|
|
|
|
static let zeroDecimalCurrencyCodes: [Currency.Code] = [
|
|
"BIF",
|
|
"CLP",
|
|
"DJF",
|
|
"GNF",
|
|
"JPY",
|
|
"KMF",
|
|
"KRW",
|
|
"MGA",
|
|
"PYG",
|
|
"RWF",
|
|
"UGX",
|
|
"VND",
|
|
"VUV",
|
|
"XAF",
|
|
"XOF",
|
|
"XPF"
|
|
]
|
|
|
|
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
|
|
]
|
|
|
|
static let defaultCurrencyCode: Currency.Code = {
|
|
if let localeCurrencyCode = Locale.current.currencyCode?.uppercased(), supportedCurrencyCodes.contains(localeCurrencyCode) {
|
|
return localeCurrencyCode
|
|
}
|
|
|
|
return "USD"
|
|
}()
|
|
}
|