Signal-iOS/SignalMessaging/Subscriptions/Stripe.swift
Evan Hahn 4675434c6c Add FiatMoney type
This change should have no user impact.

We commonly pair a currency, like USD, and an amount, like 1.23. This
adds the `FiatMoney` struct. It's a simple struct with two fields.

After adding it, I tried to use it everywhere. (It's possible I missed a
spot.)

I think this is a useful change on its own, but it'll be nice for an
upcoming change, too.

See also: [Android's equivalent class][0].

[0]: cb65347bb3/core-util/src/main/java/org/signal/core/util/money/FiatMoney.java (L1)
2022-10-26 17:04:51 -05:00

517 lines
16 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 payment: PKPayment
) -> Promise<String> {
firstly { () -> Promise<PaymentIntent> in
createBoostPaymentIntent(for: amount, level: level)
}.then { intent in
confirmPaymentIntent(for: payment, clientSecret: intent.clientSecret, paymentIntentId: intent.id).map { intent.id }
}
}
public static func createBoostPaymentIntent(
for amount: FiatMoney,
level: OneTimeBadgeLevel
) -> Promise<PaymentIntent> {
firstly(on: .sharedUserInitiated) { () -> Promise<HTTPResponse> in
guard !isAmountTooSmall(amount) else {
throw OWSAssertionError("Amount too small")
}
guard !isAmountTooLarge(amount) else {
throw OWSAssertionError("Amount too large")
}
guard supportedCurrencyCodes.contains(amount.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(
integerMoneyValue: integralAmount(amount),
inCurrencyCode: amount.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
])
}
}
/// Is an amount of money too large?
///
/// According to [Stripe's docs][0], amounts can be "up to twelve digits for
/// IDR (for example, a value of 999999999999 for a charge of
/// 9,999,999,999.99 IDR), and up to eight digits for all other currencies
/// (for example, a value of 99999999 for a charge of 999,999.99 USD).
///
/// - Parameter amount: The amount of money.
/// - Returns: Whether the amount is too large.
///
/// [0]: https://stripe.com/docs/currencies?presentment-currency=US#minimum-and-maximum-charge-amounts
public static func isAmountTooLarge(_ amount: FiatMoney) -> Bool {
let integerAmount = integralAmount(amount)
let maximum: UInt = amount.currencyCode == "IDR" ? 999999999999 : 99999999
return integerAmount > maximum
}
/// Is an amount of money too small?
///
/// This is a client-side validation, so if we're not sure, we should
/// accept the amount.
///
/// These minimums are pulled from [Stripe's document minimums][0]. Note
/// that Stripe's values are for *settlement* currency (which is always USD
/// for Signal), but we use them as helpful minimums anyway.
///
/// - Parameter amount: The amount of money.
/// - Returns: Whether the amount is too small.
///
/// [0]: https://stripe.com/docs/currencies?presentment-currency=US#minimum-and-maximum-charge-amounts
public static func isAmountTooSmall(_ amount: FiatMoney) -> Bool {
let integerAmount = integralAmount(amount)
let minimum = minimumIntegralChargePerCurrencyCode[amount.currencyCode, default: 50]
return integerAmount < minimum
}
private static func integralAmount(_ amount: FiatMoney) -> UInt {
let scaled: Decimal
if zeroDecimalCurrencyCodes.contains(amount.currencyCode.uppercased()) {
scaled = amount.value
} else {
scaled = amount.value * 100
}
let rounded = scaled.rounded()
guard rounded >= 0 else { return 0 }
guard rounded <= Decimal(UInt.max) else { return UInt.max }
return (rounded as NSDecimalNumber).uintValue
}
}
// 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
}
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 {
/// A list of supported currencies, [according to Stripe][0].
///
/// [0]: https://stripe.com/docs/currencies?presentment-currency=US#presentment-currencies
static let supportedCurrencyCodes: [Currency.Code] = [
"AED",
"AFN",
"ALL",
"AMD",
"ANG",
"AOA",
"ARS",
"AUD",
"AWG",
"AZN",
"BAM",
"BBD",
"BDT",
"BGN",
"BIF",
"BMD",
"BND",
"BOB",
"BRL",
"BSD",
"BWP",
"BYN",
"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",
"SLE",
"SLL",
"SOS",
"SRD",
"STD",
"SZL",
"THB",
"TJS",
"TOP",
"TRY",
"TTD",
"TWD",
"TZS",
"UAH",
"UGX",
"USD",
"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: Set<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"
}()
}