Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
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)
        }
    }
}