241 lines
10 KiB
Swift
241 lines
10 KiB
Swift
//
|
||
// Copyright 2024 Signal Messenger, LLC
|
||
// SPDX-License-Identifier: AGPL-3.0-only
|
||
//
|
||
|
||
protocol SubscriptionRedemptionNecessityCheckerStore {
|
||
func getLastRedemptionNecessaryCheck(tx: DBReadTransaction) -> Date?
|
||
func setLastRedemptionNecessaryCheck(_ now: Date, tx: DBWriteTransaction)
|
||
}
|
||
|
||
/// Responsible for determining if we need to attempt redemption for a
|
||
/// subscription.
|
||
///
|
||
/// Broadly, once per some fixed period (at the time of writing, 1x/3d) we make
|
||
/// a series of network requests and parse/compare the results to determine if
|
||
/// we believe the subscription has been renewed. If so, we save and kick off a
|
||
/// durable redemption job.
|
||
///
|
||
/// At the time of writing we have two subscription types – donations and
|
||
/// backups – which differ in their details but which also reuse much of the
|
||
/// same "subscriber ID" anonymization infrastructure. Consequently, the logic
|
||
/// we use to decide if they should be redeemed is largely the same for both,
|
||
/// and customized by blocks passed by specific callers.
|
||
struct SubscriptionRedemptionNecessityChecker<RedemptionJobContext> {
|
||
typealias FetchSubscriptionBlock = (
|
||
_ db: DB,
|
||
_ subscriptionFetcher: SubscriptionFetcher,
|
||
) async throws -> (subscriberID: Data, subscription: Subscription)?
|
||
|
||
typealias ParseEntitlementExpirationBlock = (
|
||
_ entitlements: WhoAmIRequestFactory.Responses.WhoAmI.Entitlements,
|
||
_ subscription: Subscription,
|
||
) -> TimeInterval?
|
||
|
||
typealias SaveRedemptionJobBlock = (
|
||
_ subscriberId: Data,
|
||
_ subscription: Subscription,
|
||
_ tx: DBWriteTransaction,
|
||
) throws -> RedemptionJobContext?
|
||
|
||
typealias StartRedemptionJobBlock = (
|
||
_ jobContext: RedemptionJobContext,
|
||
) async throws -> Void
|
||
|
||
private enum Constants {
|
||
static var intervalBetweenChecks: TimeInterval { 3 * .day }
|
||
}
|
||
|
||
private let checkerStore: SubscriptionRedemptionNecessityCheckerStore
|
||
private let dateProvider: DateProvider
|
||
private let db: any DB
|
||
private let logger: PrefixedLogger
|
||
private let networkManager: NetworkManager
|
||
private let tsAccountManager: TSAccountManager
|
||
private let whoAmIManager: WhoAmIManager
|
||
|
||
init(
|
||
checkerStore: SubscriptionRedemptionNecessityCheckerStore,
|
||
dateProvider: @escaping DateProvider,
|
||
db: any DB,
|
||
logger: PrefixedLogger,
|
||
networkManager: NetworkManager,
|
||
tsAccountManager: TSAccountManager,
|
||
) {
|
||
self.checkerStore = checkerStore
|
||
self.dateProvider = dateProvider
|
||
self.db = db
|
||
self.logger = logger
|
||
self.networkManager = networkManager
|
||
self.tsAccountManager = tsAccountManager
|
||
self.whoAmIManager = WhoAmIManagerImpl(networkManager: networkManager)
|
||
}
|
||
|
||
/// Redeems the current subscription period, if necessary.
|
||
///
|
||
/// - Parameter fetchSubscriptionBlock
|
||
/// Fetches the current subscriber ID and associated subscription.
|
||
///
|
||
/// - Parameter parseEntitlementExpirationBlock
|
||
/// Returns the expiration time of the current account entitlement
|
||
/// associated with the given subscription. For example, if the given
|
||
/// subscription is for a donation, returns the expiration time of the
|
||
/// associated badge entitlement.
|
||
///
|
||
/// - Parameter saveRedemptionJobBlock
|
||
/// Saves a durable redemption job. Invoked if redemption is necessary.
|
||
/// May return `nil` if the caller knows a job should not be saved; for
|
||
/// example, if a duplicate job has already been saved.
|
||
///
|
||
/// - Parameter startRedemptionJobBlock
|
||
/// Starts a durable redemption job previously saved by
|
||
/// `saveRedemptionJobBlock`.
|
||
func redeemSubscriptionIfNecessary(
|
||
fetchSubscriptionBlock: FetchSubscriptionBlock,
|
||
parseEntitlementExpirationBlock: ParseEntitlementExpirationBlock,
|
||
saveRedemptionJobBlock: SaveRedemptionJobBlock,
|
||
startRedemptionJobBlock: StartRedemptionJobBlock,
|
||
) async throws {
|
||
let (
|
||
registrationState,
|
||
lastRedemptionNecessaryCheck,
|
||
): (
|
||
TSRegistrationState,
|
||
Date?,
|
||
) = db.read { tx in
|
||
return (
|
||
tsAccountManager.registrationState(tx: tx),
|
||
checkerStore.getLastRedemptionNecessaryCheck(tx: tx),
|
||
)
|
||
}
|
||
|
||
guard registrationState.isRegistered else {
|
||
// Only registered devices should try and redeem.
|
||
return
|
||
}
|
||
|
||
if
|
||
let lastRedemptionNecessaryCheck,
|
||
dateProvider().timeIntervalSince(lastRedemptionNecessaryCheck) < Constants.intervalBetweenChecks
|
||
{
|
||
/// Not necessary to check, we did so recently!
|
||
return
|
||
}
|
||
|
||
guard
|
||
let (subscriberId, subscription) = try await fetchSubscriptionBlock(
|
||
db,
|
||
SubscriptionFetcher(
|
||
networkManager: networkManager,
|
||
retryPolicy: .hopefullyRecoverable,
|
||
),
|
||
)
|
||
else {
|
||
logger.warn("Not redeeming, subscription missing!")
|
||
|
||
/// If there's no subscription there's nothing for us to redeem, so
|
||
/// we can bail out.
|
||
await db.awaitableWrite { tx in
|
||
checkerStore.setLastRedemptionNecessaryCheck(dateProvider(), tx: tx)
|
||
}
|
||
|
||
return
|
||
}
|
||
|
||
logger.info("Checking if subscription should be redeemed. \(subscription.debugDescription)")
|
||
|
||
/// This "heartbeat" is important to do regularly, as the server will
|
||
/// take cleanup steps on subscriber IDs that haven't had a client
|
||
/// perform a "keep-alive" in a long time (such as canceling the
|
||
/// associated subscription, if possible).
|
||
try await performSubscriberIdHeartbeat(subscriberId)
|
||
|
||
let hasSubscriptionRenewedSinceLastRedemption: Bool = try await {
|
||
let currentEntitlements = try await whoAmIManager.makeWhoAmIRequest().entitlements
|
||
|
||
let currentEntitlementExpiration: TimeInterval? = parseEntitlementExpirationBlock(
|
||
currentEntitlements,
|
||
subscription,
|
||
)
|
||
|
||
if let expiration = currentEntitlementExpiration.map({ Date(timeIntervalSince1970: $0) }) {
|
||
/// If the subscription expiration is after the entitlement
|
||
/// expiration, we know it's renewed since we last redeemed.
|
||
/// (The entitlement will last till the subscription expiration
|
||
/// + a grace period, so if the subscription expiration is
|
||
/// larger, it must have renewed since the entitlement was last
|
||
/// set.)
|
||
///
|
||
/// This also covers starting a new subscription after a
|
||
/// previous one expired; we'll have an entitlement from prior
|
||
/// redemptions, but the new subscription will definitely expire
|
||
/// after that entitlement.
|
||
return expiration < subscription.endOfCurrentPeriod
|
||
}
|
||
|
||
/// We have no entitlement, so if we have an active subscription
|
||
/// we know we should redeem. (For example, this is the first
|
||
/// time this user has set up a subscription.)
|
||
///
|
||
/// It's important to check the subscription status, because we
|
||
/// don't want to attempt redemption for a long-canceled
|
||
/// subscription just because the entitlements we had from that
|
||
/// subscription have been removed from our account.
|
||
return subscription.active
|
||
}()
|
||
|
||
if
|
||
case .pastDue = subscription.status
|
||
{
|
||
/// For some payment methods (e.g., cards), the payment processors
|
||
/// will automatically retry a subscription-renewal payment failure.
|
||
/// While that's happening, the subscription will be "past due".
|
||
///
|
||
/// Retries will occur on the scale of days, for a period of weeks.
|
||
/// We don't want to attempt badge redemption during this time since
|
||
/// we don't expect to succeed now, but failure doesn't yet mean
|
||
/// much as we may succeed in the future if the payment recovers.
|
||
logger.warn("Subscription failed to renew, but payment processor is retrying. Not yet attempting receipt credential redemption for this period.")
|
||
|
||
await db.awaitableWrite { tx in
|
||
checkerStore.setLastRedemptionNecessaryCheck(dateProvider(), tx: tx)
|
||
}
|
||
} else if hasSubscriptionRenewedSinceLastRedemption {
|
||
logger.info("Attempting to redeem subscription renewal!")
|
||
|
||
let savedJobContext: RedemptionJobContext? = try await db.awaitableWrite { tx in
|
||
/// Ask the caller to save a redemption job. Importantly, do
|
||
/// this in the same transaction as recording that we performed
|
||
/// a necessity check.
|
||
let jobContext = try saveRedemptionJobBlock(subscriberId, subscription, tx)
|
||
|
||
checkerStore.setLastRedemptionNecessaryCheck(dateProvider(), tx: tx)
|
||
|
||
return jobContext
|
||
}
|
||
|
||
if let savedJobContext {
|
||
/// Now that we've saved the durable job, kick-start it.
|
||
try await startRedemptionJobBlock(savedJobContext)
|
||
}
|
||
} else {
|
||
logger.info("Subscription has not renewed since last redemption; bailing out!")
|
||
|
||
await db.awaitableWrite { tx in
|
||
checkerStore.setLastRedemptionNecessaryCheck(dateProvider(), tx: tx)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Let the server know that a client still cares about this subscriberId.
|
||
private func performSubscriberIdHeartbeat(_ subscriberId: Data) async throws {
|
||
let registerSubscriberIdResponse = try await networkManager.asyncRequest(
|
||
OWSRequestFactory.setSubscriberID(subscriberId),
|
||
)
|
||
|
||
guard registerSubscriberIdResponse.responseStatusCode == 200 else {
|
||
throw registerSubscriberIdResponse.asError()
|
||
}
|
||
}
|
||
}
|