TM-SGNL-iOS/SignalServiceKit/Jobs/DonationReceiptCredentialRedemptionJobQueue.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

658 lines
28 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import LibSignalClient
/// Manages "donation receipt credential" redemption.
///
/// Donation payments are handled differently depending on the payment
/// method. Ultimately, however, all payments are "confirmed" this means
/// the user has authorized the payment. Once that happens, we end up with a
/// "payment intent ID" as well as a "receipt credential request/context".
///
/// At this point, we're in a zero-knowledge world neither the payment
/// intent ID nor the receipt credential request are associated with our
/// account.
///
/// We take the payment intent ID and receipt credential request, and send
/// them (unauthenticated) to Signal servers. If the payment in question has
/// been "processed" (per the relevant payment processor, such as Stripe),
/// the server returns us a value that we can combine with our receipt
/// credential request context to create a zero-knowledge "receipt
/// credential".
///
/// Note that if the payment has not processed successfully, we instead
/// receive an error which can tell us the status of the payment and how to
/// proceed. For example, the payment may have failed to process, or may
/// still be pending but not have affirmatively failed we want to respond
/// differently to those scenarios.
///
/// *Finally*, we make an authenticated request to send a presentation for
/// the ZK receipt credential to the service thereby proving that we have
/// made a donation which assigns a badge to our account.
///
/// - Note
/// Some payment types (such as credit cards) usually process immediately,
/// but others (such as SEPA debit transfers) can take days/weeks to
/// process. During that time, receipt credential request redemption will
/// fail.
public class DonationReceiptCredentialRedemptionJobQueue {
private let jobQueueRunner: JobQueueRunner<
JobRecordFinderImpl<DonationReceiptCredentialRedemptionJobRecord>,
DonationReceiptCredentialRedemptionJobRunnerFactory
>
private let jobRunnerFactory: DonationReceiptCredentialRedemptionJobRunnerFactory
private let logger: PrefixedLogger = .donations
public init(db: any DB, reachabilityManager: SSKReachabilityManager) {
self.jobRunnerFactory = DonationReceiptCredentialRedemptionJobRunnerFactory()
self.jobQueueRunner = JobQueueRunner(
canExecuteJobsConcurrently: true,
db: db,
jobFinder: JobRecordFinderImpl(db: db),
jobRunnerFactory: self.jobRunnerFactory
)
self.jobQueueRunner.listenForReachabilityChanges(reachabilityManager: reachabilityManager)
}
func start(appContext: AppContext) {
guard appContext.isMainApp else { return }
jobQueueRunner.start(shouldRestartExistingJobs: true)
}
/// Persists and returns a `JobRecord` for redeeming a boost donation.
///
/// - Important
/// The returned job must be passed to ``runRedemptionJob(jobRecord:)``.
func saveBoostRedemptionJob(
amount: FiatMoney,
paymentProcessor: DonationPaymentProcessor,
paymentMethod: DonationPaymentMethod,
receiptCredentialRequestContext: ReceiptCredentialRequestContext,
receiptCredentialRequest: ReceiptCredentialRequest,
boostPaymentIntentID: String,
tx: DBWriteTransaction
) -> DonationReceiptCredentialRedemptionJobRecord {
logger.info("Adding a boost redemption job.")
let jobRecord = DonationReceiptCredentialRedemptionJobRecord(
paymentProcessor: paymentProcessor.rawValue,
paymentMethod: paymentMethod.rawValue,
receiptCredentialRequestContext: receiptCredentialRequestContext.serialize().asData,
receiptCredentialRequest: receiptCredentialRequest.serialize().asData,
subscriberID: Data(), // Unused
targetSubscriptionLevel: 0, // Unused
priorSubscriptionLevel: 0, // Unused
isNewSubscription: true, // Unused
shouldSuppressPaymentAlreadyRedeemed: false, // Unused
isBoost: true,
amount: amount.value,
currencyCode: amount.currencyCode,
boostPaymentIntentID: boostPaymentIntentID
)
jobRecord.anyInsert(transaction: SDSDB.shimOnlyBridge(tx))
return jobRecord
}
/// Persists and returns a `JobRecord` for redeeming a boost donation.
///
/// - Important
/// The returned job must be passed to ``runRedemptionJob(jobRecord:)``.
///
/// - Parameter paymentMethod
/// The payment method for this subscription. In practice, should not be
/// `nil`! However, we fetch this from the service, which cannot guarantee a
/// recognized value (as it is in turn fetched from an external service,
/// such as Stripe).
///
/// - Parameter isNewSubscription
/// `true` if this job represents a new or updated subscription. `false` if
/// this job is associated with the renewal of an existing subscription.
///
/// - Parameter shouldSuppressPaymentAlreadyRedeemed
/// Whether this job should suppress "payment already redeemed" errors.
func saveSubscriptionRedemptionJob(
paymentProcessor: DonationPaymentProcessor,
paymentMethod: DonationPaymentMethod?,
receiptCredentialRequestContext: ReceiptCredentialRequestContext,
receiptCredentialRequest: ReceiptCredentialRequest,
subscriberID: Data,
targetSubscriptionLevel: UInt,
priorSubscriptionLevel: UInt?,
isNewSubscription: Bool,
shouldSuppressPaymentAlreadyRedeemed: Bool,
tx: DBWriteTransaction
) -> DonationReceiptCredentialRedemptionJobRecord {
logger.info("Adding a subscription redemption job.")
let jobRecord = DonationReceiptCredentialRedemptionJobRecord(
paymentProcessor: paymentProcessor.rawValue,
paymentMethod: paymentMethod?.rawValue,
receiptCredentialRequestContext: receiptCredentialRequestContext.serialize().asData,
receiptCredentialRequest: receiptCredentialRequest.serialize().asData,
subscriberID: subscriberID,
targetSubscriptionLevel: targetSubscriptionLevel,
priorSubscriptionLevel: priorSubscriptionLevel ?? 0,
isNewSubscription: isNewSubscription,
shouldSuppressPaymentAlreadyRedeemed: shouldSuppressPaymentAlreadyRedeemed,
isBoost: false,
amount: nil,
currencyCode: nil,
boostPaymentIntentID: String() // Unused
)
jobRecord.anyInsert(transaction: SDSDB.shimOnlyBridge(tx))
return jobRecord
}
public func runRedemptionJob(
jobRecord: DonationReceiptCredentialRedemptionJobRecord
) async throws {
logger.info("Running redemption job.")
try await withCheckedThrowingContinuation { continuation in
self.jobQueueRunner.addPersistedJob(
jobRecord,
runner: self.jobRunnerFactory.buildRunner(continuation: continuation)
)
}
}
}
private class DonationReceiptCredentialRedemptionJobRunnerFactory: JobRunnerFactory {
func buildRunner() -> DonationReceiptCredentialRedemptionJobRunner { buildRunner(continuation: nil) }
func buildRunner(continuation: CheckedContinuation<Void, Error>?) -> DonationReceiptCredentialRedemptionJobRunner {
return DonationReceiptCredentialRedemptionJobRunner(continuation: continuation)
}
}
private class DonationReceiptCredentialRedemptionJobRunner: JobRunner {
private let logger: PrefixedLogger = .donations
private let continuation: CheckedContinuation<Void, Error>?
init(continuation: CheckedContinuation<Void, Error>?) {
self.continuation = continuation
}
/// Represents the type of payment that resulted in this receipt credential
/// redemption.
enum PaymentType: CustomStringConvertible {
/// A one-time payment, or "boost".
case oneTimeBoost(paymentIntentId: String, amount: FiatMoney)
/// A recurring payment, or (an overloaded term) "subscription".
case recurringSubscription(
subscriberId: Data,
targetSubscriptionLevel: UInt,
priorSubscriptionLevel: UInt,
isNewSubscription: Bool,
shouldSuppressPaymentAlreadyRedeemed: Bool
)
var receiptCredentialResultMode: DonationReceiptCredentialResultStore.Mode {
switch self {
case .oneTimeBoost: return .oneTimeBoost
case .recurringSubscription(_, _, _, isNewSubscription: true, _): return .recurringSubscriptionInitiation
case .recurringSubscription(_, _, _, isNewSubscription: false, _): return .recurringSubscriptionRenewal
}
}
var donationReceiptType: DonationReceipt.DonationReceiptType {
switch self {
case .oneTimeBoost:
return .boost
case let .recurringSubscription(_, targetSubscriptionLevel, _, _, _):
return .subscription(subscriptionLevel: targetSubscriptionLevel)
}
}
var description: String {
switch self {
case .oneTimeBoost: return "one-time"
case .recurringSubscription(_, _, _, isNewSubscription: true, _): return "recurring-initiation"
case .recurringSubscription(_, _, _, isNewSubscription: false, _): return "recurring-renewal"
}
}
}
private var donationReceiptCredentialResultStore: DonationReceiptCredentialResultStore {
DependenciesBridge.shared.donationReceiptCredentialResultStore
}
// MARK: - Retries
private enum Constants {
/// Defines the time between retries for SEPA and recurring iDEAL transactions.
static let sepaRetryInterval = TSConstants.isUsingProductionService ? kDayInterval : kMinuteInterval
}
private enum RetryInterval {
case exponential
case sepa
}
private func stillProcessingRetryParameters(
paymentType: PaymentType,
paymentMethod: DonationPaymentMethod?
) -> (RetryInterval, maxRetries: UInt) {
switch paymentMethod {
case nil, .applePay, .creditOrDebitCard, .paypal:
// We'll retry operations for these payment types fairly aggressively, so
// we need a large retry buffer.
return (.exponential, 110)
case .sepa:
// We'll only retry operations for SEPA 1x/day (including those fronted by
// iDEAL), so we limit the retry buffer. They should always complete within
// 14 business days, so this is generous compared to what we should need,
// but prevents us from having an indefinite job if for whatever reason a
// payment never processes.
return (.sepa, 30)
case .ideal:
switch paymentType {
case .oneTimeBoost:
return (.exponential, 110)
case .recurringSubscription:
return (.sepa, 30)
}
}
}
var transientFailureCount: UInt = 0
private func triggerExponentialRetry(jobRecord: JobRecord) -> TimeInterval {
// If this operation just started, but we've tried in the past and have
// persisted failures on the job record, we'll respect those and start with
// a longer retry. Otherwise, as this operation continues to retry it'll
// get a longer retry from its local error count.
transientFailureCount += 1
return OWSOperation.retryIntervalForExponentialBackoff(
failureCount: max(jobRecord.failureCount, transientFailureCount)
)
}
private func timeIntervalSince(_ timestampMs: UInt64) -> TimeInterval {
return Date().timeIntervalSince(Date(millisecondsSince1970: timestampMs))
}
private func sepaRetryDelay(configuration: Configuration) -> TimeInterval? {
let (retryInterval, _) = stillProcessingRetryParameters(
paymentType: configuration.paymentType,
paymentMethod: configuration.paymentMethod
)
guard retryInterval == .sepa else { return nil }
let priorError = SSKEnvironment.shared.databaseStorageRef.read(block: { tx -> DonationReceiptCredentialRequestError? in
return donationReceiptCredentialResultStore.getRequestError(
errorMode: configuration.paymentType.receiptCredentialResultMode,
tx: tx.asV2Read
)
})
guard let priorError, priorError.errorCode == .paymentStillProcessing else {
return nil
}
let delay = Constants.sepaRetryInterval - timeIntervalSince(priorError.timestampMs)
guard delay > 0 else {
return nil
}
owsAssertDebug(
priorError.paymentMethod == .sepa || priorError.paymentMethod == .ideal,
logger: logger
)
return delay
}
// MARK: - Parsing
private struct Configuration {
var paymentMethod: DonationPaymentMethod?
var paymentProcessor: DonationPaymentProcessor
var paymentType: PaymentType
var receiptCredentialRequest: ReceiptCredentialRequest
var receiptCredentialRequestContext: ReceiptCredentialRequestContext
var receiptCredentialPresentation: ReceiptCredentialPresentation?
}
private func parseJobRecord(_ jobRecord: DonationReceiptCredentialRedemptionJobRecord) throws -> Configuration {
guard let paymentProcessor = DonationPaymentProcessor(rawValue: jobRecord.paymentProcessor) else {
throw OWSGenericError("Unexpected payment processor in job record! \(jobRecord.paymentProcessor)")
}
let paymentMethod: DonationPaymentMethod? = try jobRecord.paymentMethod.map { paymentMethodString in
guard let paymentMethod = DonationPaymentMethod(rawValue: paymentMethodString) else {
throw OWSGenericError("Unexpected payment method in job record! \(paymentMethodString)")
}
return paymentMethod
}
let receiptCredentialRequestContext = try ReceiptCredentialRequestContext(
contents: [UInt8](jobRecord.receiptCredentialRequestContext)
)
let receiptCredentialRequest = try ReceiptCredentialRequest(
contents: [UInt8](jobRecord.receiptCredentialRequest)
)
let paymentType: PaymentType
if jobRecord.isBoost {
guard
let value = jobRecord.amount.map({ $0 as Decimal }),
let currencyCode = jobRecord.currencyCode
else {
throw OWSGenericError("Boost job record missing amount!")
}
paymentType = .oneTimeBoost(
paymentIntentId: jobRecord.boostPaymentIntentID,
amount: FiatMoney(currencyCode: currencyCode, value: value)
)
} else {
paymentType = .recurringSubscription(
subscriberId: jobRecord.subscriberID,
targetSubscriptionLevel: jobRecord.targetSubscriptionLevel,
priorSubscriptionLevel: jobRecord.priorSubscriptionLevel,
isNewSubscription: jobRecord.isNewSubscription,
shouldSuppressPaymentAlreadyRedeemed: jobRecord.shouldSuppressPaymentAlreadyRedeemed
)
}
return Configuration(
paymentMethod: paymentMethod,
paymentProcessor: paymentProcessor,
paymentType: paymentType,
receiptCredentialRequest: receiptCredentialRequest,
receiptCredentialRequestContext: receiptCredentialRequestContext,
receiptCredentialPresentation: try jobRecord.getReceiptCredentialPresentation()
)
}
// MARK: - Running
func runJobAttempt(_ jobRecord: DonationReceiptCredentialRedemptionJobRecord) async -> JobAttemptResult {
do {
return try await _runJobAttempt(jobRecord)
} catch {
if error.isRetryable {
// In practice, the only retryable errors are network failures.
owsAssertDebug(
error.isNetworkFailureOrTimeout,
logger: logger
)
return .retryAfter(triggerExponentialRetry(jobRecord: jobRecord))
}
logger.warn("Job encountered unexpected terminal error")
return await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { tx in
jobRecord.anyRemove(transaction: tx)
return .finished(.failure(error))
}
}
}
func didFinishJob(_ jobRecordId: JobRecord.RowId, result: JobResult) async {
switch result.ranSuccessfullyOrError {
case .success:
logger.info("Redemption job succeeded")
DonationReceiptCredentialRedemptionJob.postNotification(name: DonationReceiptCredentialRedemptionJob.didSucceedNotification)
continuation?.resume()
case .failure(let error):
DonationReceiptCredentialRedemptionJob.postNotification(name: DonationReceiptCredentialRedemptionJob.didFailNotification)
continuation?.resume(throwing: error)
}
}
private func _runJobAttempt(_ jobRecord: DonationReceiptCredentialRedemptionJobRecord) async throws -> JobAttemptResult {
// First, load a bunch of state that *could* fail. If it does, the
// operation can't ever succeed, so we throw it away.
let configuration = try parseJobRecord(jobRecord)
logger.info("Running job for \(configuration.paymentType).")
// When the app relaunches, we'll try to restart all pending redemption
// jobs. If one is for SEPA, and if that job hit a "still processing" error
// in the past 24 hours, don't check again until 24 hours after the error.
if let retryDelay = sepaRetryDelay(configuration: configuration) {
return .retryAfter(retryDelay, canRetryEarly: false)
}
// In order to properly show the "you have a new badge" UI after this job
// succeeds, we need to know what badges we had beforehand.
let badgesSnapshotBeforeJob: ProfileBadgesSnapshot = .current()
let badge: ProfileBadge
if let cachedBadge {
badge = cachedBadge
} else {
badge = try await loadBadge(paymentType: configuration.paymentType)
cachedBadge = badge
}
let amount: FiatMoney
if let cachedAmount {
amount = cachedAmount
} else {
amount = try await loadAmount(paymentType: configuration.paymentType)
cachedAmount = amount
}
let receiptCredentialPresentation: ReceiptCredentialPresentation
if let persistedReceiptCredentialPresentation = configuration.receiptCredentialPresentation {
logger.info("Using persisted receipt credential presentation")
receiptCredentialPresentation = persistedReceiptCredentialPresentation
} else {
logger.info("Creating new receipt credential presentation")
do {
receiptCredentialPresentation = try await fetchReceiptCredentialPresentation(
jobRecord: jobRecord,
configuration: configuration,
badge: badge,
amount: amount
)
} catch let error as DonationSubscriptionManager.KnownReceiptCredentialRequestError {
let errorCode = error.errorCode
let chargeFailureCodeIfPaymentFailed = error.chargeFailureCodeIfPaymentFailed
let paymentMethod = configuration.paymentMethod
let paymentType = configuration.paymentType
return await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { tx in
if
errorCode == .paymentIntentRedeemed,
case .recurringSubscription(_, _, _, _, shouldSuppressPaymentAlreadyRedeemed: true) = paymentType
{
self.logger.warn("Suppressing payment-already-redeemed error.")
jobRecord.anyRemove(transaction: tx)
return .finished(.success(()))
}
self.persistErrorCode(
errorCode: errorCode,
chargeFailureCodeIfPaymentFailed: chargeFailureCodeIfPaymentFailed,
configuration: configuration,
badge: badge,
amount: amount,
tx: tx.asV2Write
)
if errorCode == .paymentStillProcessing {
let (retryInterval, maxRetries) = self.stillProcessingRetryParameters(
paymentType: paymentType,
paymentMethod: paymentMethod
)
if jobRecord.failureCount < maxRetries {
self.logger.warn("Payment still processing; scheduling retry…")
jobRecord.addFailure(tx: tx)
switch retryInterval {
case .exponential:
return .retryAfter(self.triggerExponentialRetry(jobRecord: jobRecord))
case .sepa:
return .retryAfter(Constants.sepaRetryInterval, canRetryEarly: false)
}
}
}
self.logger.warn("Couldn't fetch credential; aborting: \(errorCode)")
jobRecord.anyRemove(transaction: tx)
return .finished(.failure(error))
}
}
}
try await DonationSubscriptionManager.redeemReceiptCredentialPresentation(
receiptCredentialPresentation: receiptCredentialPresentation
).awaitable()
return await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { tx in
self.donationReceiptCredentialResultStore.clearRequestError(
errorMode: configuration.paymentType.receiptCredentialResultMode,
tx: tx.asV2Write
)
self.donationReceiptCredentialResultStore.setRedemptionSuccess(
success: DonationReceiptCredentialRedemptionSuccess(
badgesSnapshotBeforeJob: badgesSnapshotBeforeJob,
badge: badge,
paymentMethod: configuration.paymentMethod
),
successMode: configuration.paymentType.receiptCredentialResultMode,
tx: tx.asV2Write
)
DonationReceipt(
receiptType: configuration.paymentType.donationReceiptType,
timestamp: Date(),
amount: amount
).anyInsert(transaction: tx)
jobRecord.anyRemove(transaction: tx)
return .finished(.success(()))
}
}
var cachedBadge: ProfileBadge?
private func loadBadge(paymentType: PaymentType) async throws -> ProfileBadge {
switch paymentType {
case .oneTimeBoost:
return try await DonationSubscriptionManager.getBoostBadge().awaitable()
case let .recurringSubscription(_, targetSubscriptionLevel, _, _, _):
return try await DonationSubscriptionManager.getSubscriptionBadge(
subscriptionLevel: targetSubscriptionLevel
).awaitable()
}
}
var cachedAmount: FiatMoney?
private func loadAmount(paymentType: PaymentType) async throws -> FiatMoney {
switch paymentType {
case .oneTimeBoost(paymentIntentId: _, amount: let amount):
return amount
case let .recurringSubscription(subscriberId, _, _, _, _):
let subscription = try await DonationSubscriptionManager.getCurrentSubscriptionStatus(for: subscriberId).awaitable()
guard let subscription else {
throw OWSAssertionError("Missing subscription", logger: logger)
}
logger.info("Fetched current subscription. \(subscription.debugDescription)")
return subscription.amount
}
}
private func fetchReceiptCredentialPresentation(
jobRecord: DonationReceiptCredentialRedemptionJobRecord,
configuration: Configuration,
badge: ProfileBadge,
amount: FiatMoney
) async throws -> ReceiptCredentialPresentation {
let receiptCredential: ReceiptCredential
switch configuration.paymentType {
case let .oneTimeBoost(paymentIntentId: paymentIntentId, amount: _):
logger.info("Durable job requesting receipt for boost")
receiptCredential = try await DonationSubscriptionManager.requestReceiptCredential(
boostPaymentIntentId: paymentIntentId,
expectedBadgeLevel: .boostBadge,
paymentProcessor: configuration.paymentProcessor,
context: configuration.receiptCredentialRequestContext,
request: configuration.receiptCredentialRequest,
logger: logger
).awaitable()
case let .recurringSubscription(subscriberId, targetSubscriptionLevel, priorSubscriptionLevel, _, _):
logger.info("Durable job requesting receipt for subscription")
receiptCredential = try await DonationSubscriptionManager.requestReceiptCredential(
subscriberId: subscriberId,
isValidReceiptLevelPredicate: { receiptLevel -> Bool in
// Validate that receipt credential level matches requested
// level, or prior subscription level.
if receiptLevel == targetSubscriptionLevel {
return true
} else if priorSubscriptionLevel != 0 {
return receiptLevel == priorSubscriptionLevel
}
return false
},
context: configuration.receiptCredentialRequestContext,
request: configuration.receiptCredentialRequest,
logger: logger
).awaitable()
}
await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { tx in
jobRecord.setReceiptCredential(receiptCredential, tx: tx)
}
return try DonationSubscriptionManager.generateReceiptCredentialPresentation(
receiptCredential: receiptCredential
)
}
private func persistErrorCode(
errorCode: DonationReceiptCredentialRequestError.ErrorCode,
chargeFailureCodeIfPaymentFailed: String?,
configuration: Configuration,
badge: ProfileBadge,
amount: FiatMoney,
tx: DBWriteTransaction
) {
let receiptCredentialRequestError: DonationReceiptCredentialRequestError = {
if let paymentMethod = configuration.paymentMethod {
return DonationReceiptCredentialRequestError(
errorCode: errorCode,
chargeFailureCodeIfPaymentFailed: chargeFailureCodeIfPaymentFailed,
badge: badge,
amount: amount,
paymentMethod: paymentMethod
)
} else {
logger.warn("Building legacy error, job record missing fields!")
return DonationReceiptCredentialRequestError(legacyErrorCode: errorCode)
}
}()
donationReceiptCredentialResultStore.setRequestError(
error: receiptCredentialRequestError,
errorMode: configuration.paymentType.receiptCredentialResultMode,
tx: tx
)
}
}
// MARK: - Notifications
public enum DonationReceiptCredentialRedemptionJob {
public static let didSucceedNotification = NSNotification.Name("DonationReceiptCredentialRedemptionJob.DidSucceed")
public static let didFailNotification = NSNotification.Name("DonationReceiptCredentialRedemptionJob.DidFail")
fileprivate static func postNotification(name: NSNotification.Name) {
NotificationCenter.default.postNotificationNameAsync(name, object: nil, userInfo: nil)
}
}
// MARK: -
private extension PrefixedLogger {
static let donations = PrefixedLogger(prefix: "[Donations]")
}