Path: blob/main/Signal/src/ViewControllers/Donations/DonationViewsUtil+Gifting.swift
1 views
//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import PassKit
import SignalServiceKit
import SignalUI
extension DonationViewsUtil {
/// Utilities for sending gift donations.
///
/// The gifting process is as follows:
///
/// 1. Throw if we're already sending a gift. See ``throwIfAlreadySendingGift``.
/// 2. Prepare to pay. This is different for each payment method. For example, Apple Pay will
/// create a payment intent and a payment method, but not actually do a charge. See
/// ``prepareToPay``.
/// 3. Show the safety number confirmation dialog if necessary, throwing if the user rejects.
/// See ``showSafetyNumberConfirmationIfNecessary``.
/// 4. Start the job and wait for it to finish/fail. See ``startJob``.
///
/// Each payment method behaves slightly differently, but has these four steps.
///
/// Errors should all be displayed the same way.
enum Gifts {
enum SendGiftError: Error {
case recipientIsBlocked
case failedAndUserNotCharged
case failedAndUserMaybeCharged
case userCanceledBeforeChargeCompleted
}
/// Throw if the user is already sending a gift to this person. This should be checked
/// before sending a gift as the first step.
///
/// This unusual situation can happen if:
///
/// 1. The user enqueues a "send gift badge" job for this recipient
/// 2. The app is terminated (e.g., due to a crash)
/// 3. Before the job finishes, the user restarts the app and tries to gift another badge to
/// the same person
///
/// This *could* happen without a Signal developer making a mistake, if the app is
/// terminated at the right time and network conditions are right.
static func throwIfAlreadySendingGift(
threadId: String,
transaction: DBReadTransaction,
) throws {
let isAlreadyGifting = DonationUtilities.sendGiftBadgeJobQueue.alreadyHasJob(
threadId: threadId,
transaction: transaction,
)
if isAlreadyGifting {
Logger.warn("[Gifting] Already sending a gift to this recipient")
throw SendGiftError.failedAndUserNotCharged
}
}
/// Prepare a payment with Apple Pay, using Stripe.
static func prepareToPay(
amount: FiatMoney,
applePayPayment: PKPayment,
) async throws -> PreparedGiftPayment {
return try await prepareToPay(
amount: amount,
withStripePaymentMethod: .applePay(payment: applePayPayment),
)
}
/// Prepare a payment with a credit/debit card, using Stripe.
static func prepareToPay(
amount: FiatMoney,
creditOrDebitCard: Stripe.PaymentMethod.CreditOrDebitCard,
) async throws -> PreparedGiftPayment {
try await prepareToPay(
amount: amount,
withStripePaymentMethod: .creditOrDebitCard(creditOrDebitCard: creditOrDebitCard),
)
}
/// Prepare a payment with Stripe.
private static func prepareToPay(
amount: FiatMoney,
withStripePaymentMethod paymentMethod: Stripe.PaymentMethod,
) async throws -> PreparedGiftPayment {
do {
return try await withCooperativeTimeout(seconds: 30) {
let paymentIntent = try await Stripe.createBoostPaymentIntent(
for: amount,
level: .giftBadge(.signalGift),
paymentMethod: paymentMethod.stripePaymentMethod,
)
let paymentMethodId = try await Stripe.createPaymentMethod(with: paymentMethod)
return .forStripe(paymentIntent: paymentIntent, paymentMethodId: paymentMethodId)
}
} catch is CooperativeTimeoutError {
Logger.warn("[Gifting] Timed out while preparing gift badge payment")
throw SendGiftError.failedAndUserNotCharged
}
}
/// Show a safety number sheet if necessary for the thread.
///
/// Because some screens care, returns the promise and whether the user needs to intervene.
@MainActor
static func showSafetyNumberConfirmationIfNecessary(
for thread: TSContactThread,
didPresent: @MainActor () -> Void = {},
) async -> Bool {
await SafetyNumberConfirmationSheet.presentRepeatedlyAsNecessary(
for: { [thread.contactAddress] },
from: CurrentAppContext().frontmostViewController()!,
confirmationText: SafetyNumberStrings.confirmSendButton,
didPresent: didPresent,
)
}
/// Runs the gifting job.
///
/// Durably enqueues a job to (1) do the charge (2) redeem the receipt
/// credential (3) enqueue a gift badge message (and optionally a text
/// message) to the reipient.
///
/// Before attempting the job, we double-check that the recipient isn't
/// blocked. This isn't required from a correctness perspective -- the job
/// will triple-check that the recipient isn't blocked. However, once we
/// attempt the job, we have to report a "might have been charged" error, so
/// it's good to double-check before issuing a charge.
///
/// The `onChargeSucceeded` block will be called when the charge succeeds
/// but before the message is sent. This may be useful for the UI. However,
/// note that the block isn't invoked until *after* the charge succeeds, so
/// the charge may succeed even if the block hasn't yet been invoked.
static func startJob(
amount: FiatMoney,
preparedPayment: PreparedGiftPayment,
thread: TSContactThread,
messageText: String,
databaseStorage: SDSDatabaseStorage,
blockingManager: BlockingManager,
onChargeSucceeded: @MainActor () -> Void = {},
) async throws {
let jobRecord = SendGiftBadgeJobQueue.createJob(
preparedPayment: preparedPayment,
receiptRequest: ReceiptCredentialManager.generateReceiptRequest(),
amount: amount,
thread: thread,
messageText: messageText,
)
let chargePromise: Promise<Void>
let completionPromise: Promise<Void>
(chargePromise, completionPromise) = try await databaseStorage.awaitableWrite { tx in
if blockingManager.isAddressBlocked(thread.contactAddress, transaction: tx) {
throw SendGiftError.recipientIsBlocked
}
return DonationUtilities.sendGiftBadgeJobQueue.addJob(jobRecord, tx: tx)
}
do {
try await chargePromise.awaitable()
await onChargeSucceeded()
try await completionPromise.awaitable()
} catch {
throw SendGiftError.failedAndUserMaybeCharged
}
}
/// Show an error message for a given error.
static func presentErrorSheetIfApplicable(for error: SendGiftError) {
let title: String
let message: String
switch error {
case .userCanceledBeforeChargeCompleted:
return
case .recipientIsBlocked:
title = OWSLocalizedString(
"DONATION_ON_BEHALF_OF_A_FRIEND_RECIPIENT_IS_BLOCKED_ERROR_TITLE",
comment: "Users can donate on a friend's behalf. This is the title for an error message that appears if the try to do this, but the recipient is blocked.",
)
message = OWSLocalizedString(
"DONATION_ON_BEHALF_OF_A_FRIEND_RECIPIENT_IS_BLOCKED_ERROR_BODY",
comment: "Users can donate on a friend's behalf. This is the error message that appears if the try to do this, but the recipient is blocked.",
)
case .failedAndUserNotCharged:
title = OWSLocalizedString(
"DONATION_ON_BEHALF_OF_A_FRIEND_PAYMENT_FAILED_ERROR_TITLE",
comment: "Users can donate on a friend's behalf. If the payment fails and the user has not been charged, an error dialog will be shown. This is the title of that dialog.",
)
message = OWSLocalizedString(
"DONATION_ON_BEHALF_OF_A_FRIEND_PAYMENT_FAILED_ERROR_BODY",
comment: "Users can donate on a friend's behalf. If the payment fails and the user has not been charged, this error message is shown.",
)
case .failedAndUserMaybeCharged:
title = OWSLocalizedString(
"DONATION_ON_BEHALF_OF_A_FRIEND_PAYMENT_SUCCEEDED_BUT_MESSAGE_FAILED_ERROR_TITLE",
comment: "Users can donate on a friend's behalf. If the payment was processed but the donation failed to send, an error dialog will be shown. This is the title of that dialog.",
)
message = OWSLocalizedString(
"DONATION_ON_BEHALF_OF_A_FRIEND_PAYMENT_SUCCEEDED_BUT_MESSAGE_FAILED_ERROR_BODY",
comment: "Users can donate on a friend's behalf. If the payment was processed but the donation failed to send, this error message will be shown.",
)
}
OWSActionSheets.showActionSheet(title: title, message: message)
}
}
}