Signal-iOS/SignalServiceKit/Jobs/ReceiptCredentialRedemptionJobQueue.swift

615 lines
26 KiB
Swift
Raw 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
import SignalCoreKit
/// 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 ReceiptCredentialRedemptionJobQueue {
private let jobQueueRunner: JobQueueRunner<
JobRecordFinderImpl<ReceiptCredentialRedemptionJobRecord>,
ReceiptCredentialRedemptionJobRunnerFactory
>
private let jobRunnerFactory: ReceiptCredentialRedemptionJobRunnerFactory
public init(db: DB, reachabilityManager: SSKReachabilityManager) {
self.jobRunnerFactory = ReceiptCredentialRedemptionJobRunnerFactory()
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)
}
public func addBoostJob(
amount: FiatMoney,
paymentProcessor: DonationPaymentProcessor,
paymentMethod: DonationPaymentMethod,
receiptCredentialRequestContext: Data,
receiptCredentialRequest: Data,
boostPaymentIntentID: String,
future: Future<Void>,
transaction: SDSAnyWriteTransaction
) {
Logger.info("[Donations] Adding a boost job")
let jobRecord = ReceiptCredentialRedemptionJobRecord(
paymentProcessor: paymentProcessor.rawValue,
paymentMethod: paymentMethod.rawValue,
receiptCredentialRequestContext: receiptCredentialRequestContext,
receiptCredentialRequest: receiptCredentialRequest,
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
)
add(jobRecord: jobRecord, future: future, tx: transaction)
}
/// Add a new redemption job for a recurring payment.
///
/// - 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.
public func addSubscriptionJob(
paymentProcessor: DonationPaymentProcessor,
paymentMethod: DonationPaymentMethod?,
receiptCredentialRequestContext: Data,
receiptCredentialRequest: Data,
subscriberID: Data,
targetSubscriptionLevel: UInt,
priorSubscriptionLevel: UInt?,
isNewSubscription: Bool,
shouldSuppressPaymentAlreadyRedeemed: Bool,
future: Future<Void>,
transaction: SDSAnyWriteTransaction
) {
Logger.info("[Donations] Adding a subscription job")
let jobRecord = ReceiptCredentialRedemptionJobRecord(
paymentProcessor: paymentProcessor.rawValue,
paymentMethod: paymentMethod?.rawValue,
receiptCredentialRequestContext: receiptCredentialRequestContext,
receiptCredentialRequest: receiptCredentialRequest,
subscriberID: subscriberID,
targetSubscriptionLevel: targetSubscriptionLevel,
priorSubscriptionLevel: priorSubscriptionLevel ?? 0,
isNewSubscription: isNewSubscription,
shouldSuppressPaymentAlreadyRedeemed: shouldSuppressPaymentAlreadyRedeemed,
isBoost: false,
amount: nil,
currencyCode: nil,
boostPaymentIntentID: String() // Unused
)
add(jobRecord: jobRecord, future: future, tx: transaction)
}
private func add(jobRecord: ReceiptCredentialRedemptionJobRecord, future: Future<Void>, tx: SDSAnyWriteTransaction) {
jobRecord.anyInsert(transaction: tx)
tx.addSyncCompletion {
self.jobQueueRunner.addPersistedJob(jobRecord, runner: self.jobRunnerFactory.buildRunner(future: future))
}
}
}
private class ReceiptCredentialRedemptionJobRunnerFactory: JobRunnerFactory {
func buildRunner() -> ReceiptCredentialRedemptionJobRunner { buildRunner(future: nil) }
func buildRunner(future: Future<Void>?) -> ReceiptCredentialRedemptionJobRunner {
return ReceiptCredentialRedemptionJobRunner(future: future)
}
}
private class ReceiptCredentialRedemptionJobRunner: JobRunner, Dependencies {
private let future: Future<Void>?
init(future: Future<Void>?) {
self.future = future
}
/// 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: ReceiptCredentialResultStore.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 receiptCredentialResultStore: ReceiptCredentialResultStore {
DependenciesBridge.shared.receiptCredentialResultStore
}
// 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 = databaseStorage.read(block: { tx -> ReceiptCredentialRequestError? in
return receiptCredentialResultStore.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)
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: ReceiptCredentialRedemptionJobRecord) 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("[Donations] 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 receiptCredentialPresentation = try jobRecord.receiptCredentialPresentation.map {
try ReceiptCredentialPresentation(contents: [UInt8]($0))
}
let paymentType: PaymentType
if jobRecord.isBoost {
guard
let value = jobRecord.amount.map({ $0 as Decimal }),
let currencyCode = jobRecord.currencyCode
else {
throw OWSGenericError("[Donations] 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: receiptCredentialPresentation
)
}
// MARK: - Running
func runJobAttempt(_ jobRecord: ReceiptCredentialRedemptionJobRecord) async -> JobAttemptResult {
do {
return try await _runJobAttempt(jobRecord)
} catch {
if error.isRetryable {
// In practice, the only retryable errors are network failures.
owsAssertDebug(error.isNetworkFailureOrTimeout)
return .retryAfter(triggerExponentialRetry(jobRecord: jobRecord))
}
Logger.warn("[Donations] Job encountered unexpected terminal error")
return await databaseStorage.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("[Donations] Redemption job succeeded")
ReceiptCredentialRedemptionJob.postNotification(name: ReceiptCredentialRedemptionJob.didSucceedNotification)
future?.resolve(())
case .failure(let error):
ReceiptCredentialRedemptionJob.postNotification(name: ReceiptCredentialRedemptionJob.didFailNotification)
future?.reject(error)
}
}
private func _runJobAttempt(_ jobRecord: ReceiptCredentialRedemptionJobRecord) 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("[Donations] 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("[Donations] Using persisted receipt credential presentation")
receiptCredentialPresentation = persistedReceiptCredentialPresentation
} else {
Logger.info("[Donations] Creating new receipt credential presentation")
do {
receiptCredentialPresentation = try await fetchReceiptCredentialPresentation(
jobRecord: jobRecord,
configuration: configuration,
badge: badge,
amount: amount
)
} catch let error as SubscriptionManagerImpl.KnownReceiptCredentialRequestError {
let errorCode = error.errorCode
let chargeFailureCodeIfPaymentFailed = error.chargeFailureCodeIfPaymentFailed
let paymentMethod = configuration.paymentMethod
let paymentType = configuration.paymentType
return await databaseStorage.awaitableWrite { tx in
if
errorCode == .paymentIntentRedeemed,
case .recurringSubscription(_, _, _, _, shouldSuppressPaymentAlreadyRedeemed: true) = paymentType
{
Logger.warn("[Donations] 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 {
Logger.warn("[Donations] 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)
}
}
}
Logger.warn("[Donations] Couldn't fetch credential; aborting: \(errorCode)")
jobRecord.anyRemove(transaction: tx)
return .finished(.failure(error))
}
}
}
try await SubscriptionManagerImpl.redeemReceiptCredentialPresentation(
receiptCredentialPresentation: receiptCredentialPresentation
).awaitable()
return await databaseStorage.awaitableWrite { tx in
self.receiptCredentialResultStore.clearRequestError(
errorMode: configuration.paymentType.receiptCredentialResultMode,
tx: tx.asV2Write
)
self.receiptCredentialResultStore.setRedemptionSuccess(
success: ReceiptCredentialRedemptionSuccess(
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 SubscriptionManagerImpl.getBoostBadge().awaitable()
case let .recurringSubscription(_, targetSubscriptionLevel, _, _, _):
return try await SubscriptionManagerImpl.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 SubscriptionManagerImpl.getCurrentSubscriptionStatus(for: subscriberId).awaitable()
guard let subscription else {
throw OWSAssertionError("Missing subscription")
}
Logger.info("[Donations] Fetched current subscription. \(subscription.debugDescription)")
return subscription.amount
}
}
private func fetchReceiptCredentialPresentation(
jobRecord: ReceiptCredentialRedemptionJobRecord,
configuration: Configuration,
badge: ProfileBadge,
amount: FiatMoney
) async throws -> ReceiptCredentialPresentation {
let receiptCredentialPresentation: ReceiptCredentialPresentation
switch configuration.paymentType {
case let .oneTimeBoost(paymentIntentId: paymentIntentId, amount: _):
Logger.info("[Donations] Durable job requesting receipt for boost")
receiptCredentialPresentation = try await SubscriptionManagerImpl.requestReceiptCredentialPresentation(
boostPaymentIntentId: paymentIntentId,
expectedBadgeLevel: .boostBadge,
paymentProcessor: configuration.paymentProcessor,
context: configuration.receiptCredentialRequestContext,
request: configuration.receiptCredentialRequest
).awaitable()
case let .recurringSubscription(subscriberId, targetSubscriptionLevel, priorSubscriptionLevel, _, _):
Logger.info("[Donations] Durable job requesting receipt for subscription")
receiptCredentialPresentation = try await SubscriptionManagerImpl.requestReceiptCredentialPresentation(
subscriberId: subscriberId,
targetSubscriptionLevel: targetSubscriptionLevel,
priorSubscriptionLevel: priorSubscriptionLevel,
context: configuration.receiptCredentialRequestContext,
request: configuration.receiptCredentialRequest
).awaitable()
}
await databaseStorage.awaitableWrite { tx in
jobRecord.setReceiptCredentialPresentation(receiptCredentialPresentation.serialize().asData, tx: tx)
}
return receiptCredentialPresentation
}
private func persistErrorCode(
errorCode: ReceiptCredentialRequestError.ErrorCode,
chargeFailureCodeIfPaymentFailed: String?,
configuration: Configuration,
badge: ProfileBadge,
amount: FiatMoney,
tx: DBWriteTransaction
) {
let receiptCredentialRequestError: ReceiptCredentialRequestError = {
if let paymentMethod = configuration.paymentMethod {
return ReceiptCredentialRequestError(
errorCode: errorCode,
chargeFailureCodeIfPaymentFailed: chargeFailureCodeIfPaymentFailed,
badge: badge,
amount: amount,
paymentMethod: paymentMethod
)
} else {
Logger.warn("[Donations] Building legacy error, job record missing fields!")
return ReceiptCredentialRequestError(legacyErrorCode: errorCode)
}
}()
receiptCredentialResultStore.setRequestError(
error: receiptCredentialRequestError,
errorMode: configuration.paymentType.receiptCredentialResultMode,
tx: tx
)
}
}
// MARK: - Notifications
public enum ReceiptCredentialRedemptionJob {
public static let didSucceedNotification = NSNotification.Name("ReceiptCredentialRedemptionJob.DidSucceed")
public static let didFailNotification = NSNotification.Name("ReceiptCredentialRedemptionJob.DidFail")
fileprivate static func postNotification(name: NSNotification.Name) {
NotificationCenter.default.postNotificationNameAsync(name, object: nil, userInfo: nil)
}
}