Signal-iOS/SignalMessaging/Subscriptions/SubscriptionReceiptCredentialRedemptionJob.swift
Evan Hahn 64462ac1b0 Save a receipt when you send someone a badge
This commit:

1. Adds a new donation receipt type: gifts.
2. Saves receipts when sending someone a gift.

The first part was the main challenge. Previously, donation receipts had
logic like this (pseudocode):

     def getReceiptType(self):
       if self.subscriptionLevel:
         return "subscription"
       else:
         return "one-time"

Now, we explicitly encode the type and have logic to handle "legacy"
receipts that don't have a type encoded.
2022-07-08 13:17:30 -05:00

280 lines
12 KiB
Swift

//
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
//
import Foundation
import LibSignalClient
import SignalServiceKit
@objc(OWSSubscriptionReceiptCredentialJobQueue)
public class SubscriptionReceiptCredentialJobQueue: NSObject, JobQueue {
// Add optional paymentIntentID / isBoost
public func addBoostJob(
amount: Decimal,
currencyCode: Currency.Code,
receiptCredentialRequestContext: Data,
receiptCredentailRequest: Data,
boostPaymentIntentID: String,
transaction: SDSAnyWriteTransaction
) {
let jobRecord = OWSReceiptCredentialRedemptionJobRecord(
receiptCredentialRequestContext: receiptCredentialRequestContext,
receiptCredentailRequest: receiptCredentailRequest,
subscriberID: Data(),
targetSubscriptionLevel: 0,
priorSubscriptionLevel: 0,
isBoost: true,
amount: NSDecimalNumber(decimal: amount),
currencyCode: currencyCode,
boostPaymentIntentID: boostPaymentIntentID,
label: self.jobRecordLabel
)
self.add(jobRecord: jobRecord, transaction: transaction)
}
public func addSubscriptionJob(
receiptCredentialRequestContext: Data,
receiptCredentailRequest: Data,
subscriberID: Data,
targetSubscriptionLevel: UInt,
priorSubscriptionLevel: UInt,
boostPaymentIntentID: String,
transaction: SDSAnyWriteTransaction
) {
let jobRecord = OWSReceiptCredentialRedemptionJobRecord(
receiptCredentialRequestContext: receiptCredentialRequestContext,
receiptCredentailRequest: receiptCredentailRequest,
subscriberID: subscriberID,
targetSubscriptionLevel: targetSubscriptionLevel,
priorSubscriptionLevel: priorSubscriptionLevel,
isBoost: false,
amount: nil,
currencyCode: nil,
boostPaymentIntentID: boostPaymentIntentID,
label: self.jobRecordLabel
)
self.add(jobRecord: jobRecord, transaction: transaction)
}
public typealias DurableOperationType = SubscriptionReceiptCredentailRedemptionOperation
@objc
public static let jobRecordLabel: String = "SubscriptionReceiptCredentailRedemption"
public var jobRecordLabel: String {
return type(of: self).jobRecordLabel
}
// per OWSOperation.retryIntervalForExponentialBackoff(failureCount:),
// 110 retries will yield ~24 hours of retry.
public static let maxRetries: UInt = 110
public let requiresInternet: Bool = true
public var isEnabled: Bool { CurrentAppContext().isMainApp }
public var runningOperations = AtomicArray<SubscriptionReceiptCredentailRedemptionOperation>()
@objc
public override init() {
super.init()
AppReadiness.runNowOrWhenAppDidBecomeReadySync {
self.setup()
}
}
@objc
public func setup() {
defaultSetup()
}
public var isSetup = AtomicBool(false)
public func didMarkAsReady(oldJobRecord: JobRecordType, transaction: SDSAnyWriteTransaction) {
// no special handling
}
let operationQueue: OperationQueue = {
let operationQueue = OperationQueue()
operationQueue.name = "SubscriptionReceiptCredential.OperationQueue"
return operationQueue
}()
public func operationQueue(jobRecord: OWSReceiptCredentialRedemptionJobRecord) -> OperationQueue {
return self.operationQueue
}
public func buildOperation(jobRecord: OWSReceiptCredentialRedemptionJobRecord, transaction: SDSAnyReadTransaction) throws -> SubscriptionReceiptCredentailRedemptionOperation {
return try SubscriptionReceiptCredentailRedemptionOperation(jobRecord)
}
}
public class SubscriptionReceiptCredentailRedemptionOperation: OWSOperation, DurableOperation {
// MARK: DurableOperation
public let jobRecord: OWSReceiptCredentialRedemptionJobRecord
weak public var durableOperationDelegate: SubscriptionReceiptCredentialJobQueue?
public var operation: OWSOperation {
return self
}
let isBoost: Bool
let subscriberID: Data
var receiptCredentialRequestContext: ReceiptCredentialRequestContext
var receiptCredentialRequest: ReceiptCredentialRequest
var receiptCredentialPresentation: ReceiptCredentialPresentation?
let targetSubscriptionLevel: UInt
let priorSubscriptionLevel: UInt
let boostPaymentIntentID: String
// For boosts, these should always be present.
// For subscriptions, these will be absent until the job runs, which should populate them.
var amount: Decimal?
var currencyCode: Currency.Code?
@objc
public required init(_ jobRecord: OWSReceiptCredentialRedemptionJobRecord) throws {
self.jobRecord = jobRecord
self.isBoost = jobRecord.isBoost
self.amount = jobRecord.amount.map { $0 as Decimal }
self.currencyCode = jobRecord.currencyCode
self.subscriberID = jobRecord.subscriberID
self.targetSubscriptionLevel = jobRecord.targetSubscriptionLevel
self.priorSubscriptionLevel = jobRecord.priorSubscriptionLevel
self.boostPaymentIntentID = jobRecord.boostPaymentIntentID
self.receiptCredentialRequestContext = try ReceiptCredentialRequestContext(
contents: [UInt8](jobRecord.receiptCredentailRequestContext))
self.receiptCredentialRequest = try ReceiptCredentialRequest(
contents: [UInt8](jobRecord.receiptCredentailRequest))
if let receiptCredentialPresentation = jobRecord.receiptCredentialPresentation {
self.receiptCredentialPresentation = try ReceiptCredentialPresentation(
contents: [UInt8](receiptCredentialPresentation))
}
}
override public func run() {
assert(self.durableOperationDelegate != nil)
let getMoneyPromise: Promise<Void>
if isBoost {
getMoneyPromise = Promise.value(())
} else {
getMoneyPromise = SubscriptionManager.getCurrentSubscriptionStatus(for: subscriberID).done { subscription in
guard let subscription = subscription else {
throw OWSAssertionError("Missing subscription")
}
// Subscriptions represent $12.34 as `1234`, unlike most other code.
var amount = subscription.amount as Decimal
if !Stripe.zeroDecimalCurrencyCodes.contains(subscription.currency) {
amount /= 100
}
self.amount = amount
self.currencyCode = subscription.currency
}
}
let getReceiptCredentialPresentationPromise: Promise<ReceiptCredentialPresentation> = firstly(on: .global()) { () -> Promise<ReceiptCredentialPresentation> in
// We already have a receiptCredentialPresentation, lets use it
if let receiptCredentialPresentation = self.receiptCredentialPresentation {
Logger.info("[Subscriptions] Using persisted receipt credential presentation")
return Promise.value(receiptCredentialPresentation)
}
Logger.info("[Subscriptions] Creating new receipt credential presentation")
// Create a new receipt credential presentation
return firstly(on: .global()) { () -> Promise<ReceiptCredentialPresentation> in
if self.isBoost {
Logger.info("[Subscriptions] Durable job requesting receipt for boost")
return try SubscriptionManager.requestBoostReceiptCredentialPresentation(
for: self.boostPaymentIntentID,
context: self.receiptCredentialRequestContext,
request: self.receiptCredentialRequest,
expectedBadgeLevel: .boostBadge
)
} else {
Logger.info("[Subscriptions] Durable job requesting receipt for subscription")
return try SubscriptionManager.requestReceiptCredentialPresentation(
for: self.subscriberID,
context: self.receiptCredentialRequestContext,
request: self.receiptCredentialRequest,
targetSubscriptionLevel: self.targetSubscriptionLevel,
priorSubscriptionLevel: self.priorSubscriptionLevel
)
}
}.then(on: .global()) { newReceiptCredentialPresentation in
// Store the receipt credential presentation, in case the job fails.
self.databaseStorage.writePromise { transaction in
self.jobRecord.update(
withReceiptCredentialPresentation: newReceiptCredentialPresentation.serialize().asData,
transaction: transaction
)
}.map { _ in newReceiptCredentialPresentation }
}
}
getMoneyPromise.then(on: .global()) {
getReceiptCredentialPresentationPromise.then(on: .global()) {
try SubscriptionManager.redeemReceiptCredentialPresentation(receiptCredentialPresentation: $0)
}
}.done(on: .global()) {
Logger.info("[Subscriptions] Successfully redeemed receipt credential presentation")
self.didSucceed()
}.catch(on: .global()) { error in
self.reportError(error)
}
}
override public func didSucceed() {
self.databaseStorage.write { transaction in
if !self.isBoost {
SubscriptionManager.setLastReceiptRedemptionFailed(failureReason: .none, transaction: transaction)
}
if let amount = amount, let currencyCode = currencyCode {
DonationReceipt(
receiptType: self.isBoost ? .boost : .subscription(subscriptionLevel: targetSubscriptionLevel),
timestamp: Date(),
amount: amount,
currencyCode: currencyCode
).anyInsert(transaction: transaction)
} else {
Logger.warn("amount and/or currencyCode was missing. Is this an old job?")
}
self.durableOperationDelegate?.durableOperationDidSucceed(self, transaction: transaction)
NotificationCenter.default.postNotificationNameAsync(
SubscriptionManager.SubscriptionJobQueueDidFinishJobNotification,
object: nil
)
}
}
override public func didReportError(_ error: Error) {
Logger.debug("remainingRetries: \(self.remainingRetries)")
self.databaseStorage.write { transaction in
self.durableOperationDelegate?.durableOperation(self, didReportError: error, transaction: transaction)
}
}
override public func retryInterval() -> TimeInterval {
let timeInterval = OWSOperation.retryIntervalForExponentialBackoff(failureCount: jobRecord.failureCount)
return timeInterval
}
override public func didFail(error: Error) {
Logger.error("failed to redeem receipt credential with error: \(error.userErrorDescription)")
self.databaseStorage.write { transaction in
NotificationCenter.default.postNotificationNameAsync(
SubscriptionManager.SubscriptionJobQueueDidFailJobNotification,
object: nil
)
self.durableOperationDelegate?.durableOperation(self, didFailWithError: error, transaction: transaction)
}
}
}