Signal-iOS/SignalServiceKit/Jobs/DonationReceiptCredentialRedemptionJobQueue.swift
2026-06-01 12:49:00 -05:00

800 lines
32 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 GRDB
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 with a "still processing" error.
public class DonationReceiptCredentialRedemptionJobQueue {
private let jobQueueRunner: JobQueueRunner<
JobRecordFinderImpl<DonationReceiptCredentialRedemptionJobRecord>,
DonationReceiptCredentialRedemptionJobRunnerFactory,
>
private let jobRunnerFactory: DonationReceiptCredentialRedemptionJobRunnerFactory
private let logger: PrefixedLogger
public init(
dateProvider: @escaping DateProvider,
db: any DB,
donationReceiptCredentialResultStore: DonationReceiptCredentialResultStore,
donationSubscriptionManager: DonationSubscriptionManager,
networkManager: NetworkManager,
profileManager: ProfileManager,
reachabilityManager: SSKReachabilityManager,
tsAccountManager: TSAccountManager,
) {
self.jobRunnerFactory = DonationReceiptCredentialRedemptionJobRunnerFactory(
dateProvider: dateProvider,
db: db,
donationReceiptCredentialResultStore: donationReceiptCredentialResultStore,
donationSubscriptionManager: donationSubscriptionManager,
logger: .donations,
networkManager: networkManager,
profileManager: profileManager,
tsAccountManager: tsAccountManager,
)
self.jobQueueRunner = JobQueueRunner(
canExecuteJobsConcurrently: true,
db: db,
jobFinder: JobRecordFinderImpl(db: db),
jobRunnerFactory: self.jobRunnerFactory,
)
self.logger = .donations
self.jobQueueRunner.listenForReachabilityChanges(reachabilityManager: reachabilityManager)
}
func start(appContext: AppContext) {
guard appContext.isMainApp else { return }
jobQueueRunner.start(shouldRestartExistingJobs: true)
}
// MARK: -
/// 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(),
receiptCredentialRequest: receiptCredentialRequest.serialize(),
subscriberID: Data(), // Unused
targetSubscriptionLevel: 0, // Unused
priorSubscriptionLevel: 0, // Unused
isNewSubscription: true, // Unused
isBoost: true,
amount: amount.value,
currencyCode: amount.currencyCode,
boostPaymentIntentID: boostPaymentIntentID,
)
jobRecord.anyInsert(transaction: 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.
func saveSubscriptionRedemptionJob(
paymentProcessor: DonationPaymentProcessor,
paymentMethod: DonationPaymentMethod?,
receiptCredentialRequestContext: ReceiptCredentialRequestContext,
receiptCredentialRequest: ReceiptCredentialRequest,
subscriberID: Data,
targetSubscriptionLevel: UInt,
priorSubscriptionLevel: UInt?,
isNewSubscription: Bool,
tx: DBWriteTransaction,
) -> DonationReceiptCredentialRedemptionJobRecord {
logger.info("Adding a subscription redemption job.")
let jobRecord = DonationReceiptCredentialRedemptionJobRecord(
paymentProcessor: paymentProcessor.rawValue,
paymentMethod: paymentMethod?.rawValue,
receiptCredentialRequestContext: receiptCredentialRequestContext.serialize(),
receiptCredentialRequest: receiptCredentialRequest.serialize(),
subscriberID: subscriberID,
targetSubscriptionLevel: targetSubscriptionLevel,
priorSubscriptionLevel: priorSubscriptionLevel ?? 0,
isNewSubscription: isNewSubscription,
isBoost: false,
amount: nil,
currencyCode: nil,
boostPaymentIntentID: String(), // Unused
)
jobRecord.anyInsert(transaction: 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),
)
}
}
}
// MARK: -
extension DonationReceiptCredentialRedemptionJobQueue {
func subscriptionJobExists(
subscriberID: Data,
tx: DBReadTransaction,
) -> Bool {
return DonationReceiptCredentialRedemptionJobFinder()
.subscriptionJobExists(subscriberID: subscriberID, tx: tx)
}
}
struct DonationReceiptCredentialRedemptionJobFinder {
init() {}
func subscriptionJobExists(
subscriberID: Data,
tx: DBReadTransaction,
) -> Bool {
let sql = """
SELECT EXISTS (
SELECT 1 FROM \(DonationReceiptCredentialRedemptionJobRecord.databaseTableName)
WHERE \(DonationReceiptCredentialRedemptionJobRecord.columnName(.recordType)) IS ?
AND \(DonationReceiptCredentialRedemptionJobRecord.columnName(.subscriberID)) IS ?
)
"""
let arguments: StatementArguments = [
JobRecord.JobRecordType.donationReceiptCredentialRedemption.rawValue,
subscriberID,
]
return failIfThrows {
return try Bool.fetchOne(tx.database, sql: sql, arguments: arguments) ?? false
}
}
}
// MARK: -
private class DonationReceiptCredentialRedemptionJobRunnerFactory: JobRunnerFactory {
private let dateProvider: DateProvider
private let db: DB
private let donationReceiptCredentialResultStore: DonationReceiptCredentialResultStore
private let donationSubscriptionManager: DonationSubscriptionManager
private let logger: PrefixedLogger
private let networkManager: NetworkManager
private let profileManager: ProfileManager
private let tsAccountManager: TSAccountManager
init(
dateProvider: @escaping DateProvider,
db: DB,
donationReceiptCredentialResultStore: DonationReceiptCredentialResultStore,
donationSubscriptionManager: DonationSubscriptionManager,
logger: PrefixedLogger,
networkManager: NetworkManager,
profileManager: ProfileManager,
tsAccountManager: TSAccountManager,
) {
self.dateProvider = dateProvider
self.db = db
self.donationReceiptCredentialResultStore = donationReceiptCredentialResultStore
self.donationSubscriptionManager = donationSubscriptionManager
self.logger = logger
self.networkManager = networkManager
self.profileManager = profileManager
self.tsAccountManager = tsAccountManager
}
func buildRunner() -> DonationReceiptCredentialRedemptionJobRunner { buildRunner(continuation: nil) }
func buildRunner(continuation: CheckedContinuation<Void, Error>?) -> DonationReceiptCredentialRedemptionJobRunner {
return DonationReceiptCredentialRedemptionJobRunner(
continuation: continuation,
dateProvider: dateProvider,
db: db,
donationReceiptCredentialResultStore: donationReceiptCredentialResultStore,
donationSubscriptionManager: donationSubscriptionManager,
networkManager: networkManager,
profileManager: profileManager,
tsAccountManager: tsAccountManager,
)
}
}
// MARK: -
private class DonationReceiptCredentialRedemptionJobRunner: JobRunner {
private let continuation: CheckedContinuation<Void, Error>?
private let dateProvider: DateProvider
private let db: DB
private let donationReceiptCredentialResultStore: DonationReceiptCredentialResultStore
private let donationSubscriptionManager: DonationSubscriptionManager
private let networkManager: NetworkManager
private let profileManager: ProfileManager
private let receiptCredentialManager: ReceiptCredentialManager
private let tsAccountManager: TSAccountManager
private var logger: PrefixedLogger = .donations
private var transientFailureCount: UInt = 0
init(
continuation: CheckedContinuation<Void, Error>?,
dateProvider: @escaping DateProvider,
db: DB,
donationReceiptCredentialResultStore: DonationReceiptCredentialResultStore,
donationSubscriptionManager: DonationSubscriptionManager,
networkManager: NetworkManager,
profileManager: ProfileManager,
tsAccountManager: TSAccountManager,
) {
self.continuation = continuation
self.dateProvider = dateProvider
self.db = db
self.donationReceiptCredentialResultStore = donationReceiptCredentialResultStore
self.donationSubscriptionManager = donationSubscriptionManager
self.networkManager = networkManager
self.profileManager = profileManager
self.receiptCredentialManager = ReceiptCredentialManager(
dateProvider: dateProvider,
logger: logger,
networkManager: networkManager,
)
self.tsAccountManager = tsAccountManager
}
/// 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,
)
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"
}
}
}
// MARK: - Retries
private enum Constants {
/// Defines the time between retries for SEPA and recurring iDEAL transactions.
static let sepaRetryInterval: TimeInterval = TSConstants.isUsingProductionService ? .day : .minute
}
private enum RetryMode {
case exponential
case sepa
}
private func retryModeIfStillProcessing(
paymentType: PaymentType,
paymentMethod: DonationPaymentMethod?,
) -> RetryMode {
switch paymentMethod {
case nil, .applePay, .creditOrDebitCard, .paypal:
return .exponential
case .sepa:
return .sepa
case .ideal:
switch paymentType {
case .oneTimeBoost:
return .exponential
case .recurringSubscription:
return .sepa
}
}
}
/// Returns an exponential-backoff retry delay that increases with each
/// subsequent call to this method.
private func incrementExponentialRetryDelay() -> TimeInterval {
transientFailureCount += 1
return OWSOperation.retryIntervalForExponentialBackoff(
failureCount: transientFailureCount,
maxAverageBackoff: .day,
)
}
private func sepaRetryDelay(configuration: Configuration) -> TimeInterval? {
switch retryModeIfStillProcessing(
paymentType: configuration.paymentType,
paymentMethod: configuration.paymentMethod,
) {
case .exponential:
return nil
case .sepa:
break
}
let priorError = db.read(block: { tx -> DonationReceiptCredentialRequestError? in
return donationReceiptCredentialResultStore.getRequestError(
errorMode: configuration.paymentType.receiptCredentialResultMode,
tx: tx,
)
})
guard let priorError, priorError.errorCode == .paymentStillProcessing else {
return nil
}
let nextAttemptDate = priorError.creationDate.addingTimeInterval(Constants.sepaRetryInterval)
let delay = nextAttemptDate.timeIntervalSince(dateProvider())
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: jobRecord.receiptCredentialRequestContext,
)
let receiptCredentialRequest = try ReceiptCredentialRequest(
contents: 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,
)
}
return Configuration(
paymentMethod: paymentMethod,
paymentProcessor: paymentProcessor,
paymentType: paymentType,
receiptCredentialRequest: receiptCredentialRequest,
receiptCredentialRequestContext: receiptCredentialRequestContext,
receiptCredentialPresentation: try jobRecord.getReceiptCredentialPresentation(),
)
}
// MARK: - Running
func runJobAttempt(_ jobRecord: DonationReceiptCredentialRedemptionJobRecord) async -> JobAttemptResult<Void> {
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(incrementExponentialRetryDelay())
}
logger.warn("Job encountered unexpected terminal error")
return await db.awaitableWrite { tx in
jobRecord.anyRemove(transaction: tx)
return .finished(.failure(error))
}
}
}
func didFinishJob(_ jobRecordId: JobRecord.RowId, result: JobResult<Void>) 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<Void> {
// 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)
// Now that we know what type of job we are, suffix the logger.
logger = logger.suffixed(with: "[\(configuration.paymentType)]")
_ = try tsAccountManager.registeredStateWithMaybeSneakyTransaction()
let badgesSnapshotBeforeJob = db.read { tx in
// 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.
return ProfileBadgesSnapshot.forLocalProfile(profileManager: profileManager, tx: tx)
}
logger.info("Running job.")
// 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) {
logger.info("Skipping SEPA job: in SEPA retry-delay period!")
return .retryAfter(retryDelay, canRetryEarly: false)
}
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 ReceiptCredentialRequestError {
let errorCode = error.errorCode
let chargeFailureCodeIfPaymentFailed = error.chargeFailureCodeIfPaymentFailed
let paymentMethod = configuration.paymentMethod
let paymentType = configuration.paymentType
return await db.awaitableWrite { tx in
if errorCode == .paymentIntentRedeemed {
/// This error indicates that the user has gotten their
/// badge via another redemption from another job. No
/// harm done, so we'll treat these like a success.
logger.warn("Suppressing payment-already-redeemed error.")
jobRecord.anyRemove(transaction: tx)
return .finished(.success(()))
}
persistErrorCode(
errorCode: errorCode,
chargeFailureCodeIfPaymentFailed: chargeFailureCodeIfPaymentFailed,
configuration: configuration,
badge: badge,
amount: amount,
tx: tx,
)
switch errorCode {
case .paymentStillProcessing:
logger.warn("Payment still processing; scheduling retry…")
switch retryModeIfStillProcessing(
paymentType: paymentType,
paymentMethod: paymentMethod,
) {
case .exponential:
return .retryAfter(incrementExponentialRetryDelay())
case .sepa:
return .retryAfter(Constants.sepaRetryInterval, canRetryEarly: false)
}
case .paymentFailed,
.localValidationFailed,
.serverValidationFailed,
.paymentNotFound,
.paymentIntentRedeemed:
logger.warn("Couldn't fetch credential; aborting: \(errorCode)")
jobRecord.anyRemove(transaction: tx)
return .finished(.failure(error))
}
}
}
}
try await donationSubscriptionManager.redeemReceiptCredentialPresentation(
receiptCredentialPresentation: receiptCredentialPresentation,
)
return await db.awaitableWrite { tx in
switch configuration.paymentType.receiptCredentialResultMode {
case .oneTimeBoost:
donationReceiptCredentialResultStore
.clearRequestError(errorMode: .oneTimeBoost, tx: tx)
case .recurringSubscriptionInitiation, .recurringSubscriptionRenewal:
// For a time, we might have enqueued both the "initiation" job
// and one or more "renewal" jobs for the same subscription
// period; for example, if a SEPA initiation job was processing
// for several days, we might have later enqueued a redundant
// renewal job. If the initiation job persisted an error (such
// as "still processing", and the renewal job later succeeded,
// then the initiation job may never have gotten to clear its
// error.
//
// This shouldn't happen anymore, but we can clear all errors
// (including any orphaned errors) now that we've succeeded.
donationReceiptCredentialResultStore
.clearRequestErrorForAnyRecurringSubscription(tx: tx)
}
self.donationReceiptCredentialResultStore.clearRequestError(
errorMode: configuration.paymentType.receiptCredentialResultMode,
tx: tx,
)
self.donationReceiptCredentialResultStore.setRedemptionSuccess(
success: DonationReceiptCredentialRedemptionSuccess(
badgesSnapshotBeforeJob: badgesSnapshotBeforeJob,
badge: badge,
paymentMethod: configuration.paymentMethod,
),
successMode: configuration.paymentType.receiptCredentialResultMode,
tx: tx,
)
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()
case let .recurringSubscription(_, targetSubscriptionLevel, _, _):
return try await donationSubscriptionManager.getSubscriptionBadge(subscriptionLevel: targetSubscriptionLevel)
}
}
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 SubscriptionFetcher(networkManager: networkManager)
.fetch(subscriberID: subscriberId)
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 receiptCredentialManager.requestReceiptCredential(
via: OWSRequestFactory.boostReceiptCredentials(
paymentIntentID: paymentIntentId,
paymentProcessor: configuration.paymentProcessor,
receiptCredentialRequest: configuration.receiptCredentialRequest,
),
isValidReceiptLevelPredicate: { receiptLevel in
return receiptLevel == OneTimeBadgeLevel.boostBadge.rawValue
},
context: configuration.receiptCredentialRequestContext,
)
case let .recurringSubscription(subscriberId, targetSubscriptionLevel, priorSubscriptionLevel, _):
logger.info("Durable job requesting receipt for subscription")
receiptCredential = try await receiptCredentialManager.requestReceiptCredential(
via: OWSRequestFactory.subscriptionReceiptCredentialsRequest(
subscriberID: subscriberId,
receiptCredentialRequest: configuration.receiptCredentialRequest,
),
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,
)
}
await db.awaitableWrite { tx in
jobRecord.setReceiptCredential(receiptCredential, tx: tx)
}
return try ReceiptCredentialManager.generateReceiptCredentialPresentation(
receiptCredential: receiptCredential,
)
}
private func persistErrorCode(
errorCode: ReceiptCredentialRequestError.ErrorCode,
chargeFailureCodeIfPaymentFailed: String?,
configuration: Configuration,
badge: ProfileBadge,
amount: FiatMoney,
tx: DBWriteTransaction,
) {
let receiptCredentialRequestError = DonationReceiptCredentialRequestError(
errorCode: errorCode,
chargeFailureCodeIfPaymentFailed: chargeFailureCodeIfPaymentFailed,
badge: badge,
amount: amount,
paymentMethod: configuration.paymentMethod,
now: dateProvider(),
)
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.postOnMainThread(name: name, object: nil, userInfo: nil)
}
}
// MARK: -
private extension PrefixedLogger {
static let donations = PrefixedLogger(prefix: "[Donations]")
}