Path: blob/main/SignalServiceKit/Subscriptions/SubscriptionFetcher.swift
1 views
//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
public struct SubscriptionFetcher {
private let networkManager: NetworkManager
private let retryPolicy: NetworkManager.RetryPolicy
public init(
networkManager: NetworkManager,
retryPolicy: NetworkManager.RetryPolicy = .dont,
) {
self.networkManager = networkManager
self.retryPolicy = retryPolicy
}
public func fetch(subscriberID: Data) async throws -> Subscription? {
let response: HTTPResponse
do {
response = try await networkManager.asyncRequest(
.fetchSubscription(subscriberID: subscriberID),
retryPolicy: retryPolicy,
)
} catch where error.httpStatusCode == 404 {
return nil
}
switch response.responseStatusCode {
case 200:
guard let parser = response.responseBodyParamParser else {
throw OWSAssertionError("Missing or invalid response body!")
}
guard let subscriptionDict: [String: Any] = try parser.optional(key: "subscription") else {
return nil
}
let chargeFailureDict: [String: Any]? = try parser.optional(key: "chargeFailure")
return try Subscription(
subscriptionDict: subscriptionDict,
chargeFailureDict: chargeFailureDict,
)
default:
throw OWSAssertionError("Got bad response code! \(response.responseStatusCode)")
}
}
}
private extension TSRequest {
static func fetchSubscription(subscriberID: Data) -> TSRequest {
var result = TSRequest(
url: URL(string: "v1/subscription/\(subscriberID.asBase64Url)")!,
method: "GET",
parameters: nil,
)
result.auth = .anonymous
result.applyRedactionStrategy(.redactURL())
return result
}
}
// MARK: -
/// Represents a *recurring* subscription, associated with a subscriber ID and
/// fetched from the service using that ID.
public struct Subscription: Equatable {
public struct ChargeFailure: Equatable {
/// The error code reported by the server.
///
/// If nil, we know there was a charge failure but don't know the code. This is unusual,
/// but can happen if the server sends an invalid response.
public let code: String?
init(jsonDictionary: [String: Any]) {
code = try? ParamParser(jsonDictionary).optional(key: "code")
}
}
/// The state of the subscription as understood by the backend
///
/// A subscription will be in the `active` state as long as the current
/// subscription payment has been successfully processed by the payment
/// processor.
///
/// - Note
/// Signal servers get a callback when a subscription is going to renew. If
/// the user hasn't performed a "subscription keep-alive in ~30-45 days, the
/// server will, upon getting that callback, cancel the subscription.
public enum SubscriptionStatus: Equatable {
/// Indicates the subscription has been paid successfully for the
/// current period, and all is well.
case active
/// Indicates the subscription has been unrecoverably canceled. This may
/// be due to terminal failures while renewing (in which case the charge
/// failure should be populated), or due to inactivity (in which case
/// there will be no charge failure, as Signal servers canceled the
/// subscription artificially).
case canceled
/// Indicates the subscription failed to renew, but the payment
/// processor is planning to retry the renewal. If the future renewal
/// succeeds, the subscription will go back to being "active". Continued
/// renewal failures will result in the subscription being canceled.
///
/// - Note
/// Retries are not predictable, but are expected to happen on the scale
/// of days, for up to circa two weeks.
case pastDue
/// An unrecognized status.
case unrecognized(rawValue: String)
init(rawValue: String) {
switch rawValue {
case "active": self = .active
case "canceled": self = .canceled
case "past_due": self = .pastDue
default: self = .unrecognized(rawValue: rawValue)
}
}
}
public let level: UInt
public let amount: FiatMoney
public let endOfCurrentPeriod: Date
public let active: Bool
public let cancelAtEndOfPeriod: Bool
public let status: SubscriptionStatus
/// The payment processor, if a recognized processor for donations.
public let donationPaymentProcessor: DonationPaymentProcessor?
/// The payment method, if a recognized method for donations.
/// - Note
/// This will never be `.applePay`, since the server treats Apple Pay
/// payments like credit card payments.
public let donationPaymentMethod: DonationPaymentMethod?
/// Whether the payment for this subscription is actively processing, and
/// has not yet succeeded nor failed.
public let isPaymentProcessing: Bool
/// Indicates that payment for this subscription failed.
public let chargeFailure: ChargeFailure?
public var debugDescription: String {
[
"Subscription",
"End of current period: \(endOfCurrentPeriod)",
"Cancel at end of period?: \(cancelAtEndOfPeriod)",
"Status: \(status)",
"Charge failure: \(chargeFailure.debugDescription)",
].joined(separator: ". ")
}
public init(subscriptionDict: [String: Any], chargeFailureDict: [String: Any]?) throws {
let params = ParamParser(subscriptionDict)
level = try params.required(key: "level")
let currencyCode: Currency.Code = try {
let raw: String = try params.required(key: "currency")
return raw.uppercased()
}()
amount = FiatMoney(
currencyCode: currencyCode,
value: try {
let integerValue: Int64 = try params.required(key: "amount")
let decimalValue = Decimal(integerValue)
if DonationUtilities.zeroDecimalCurrencyCodes.contains(currencyCode) {
return decimalValue
} else {
return decimalValue / 100
}
}(),
)
let _endOfCurrentPeriod: TimeInterval = try params.required(key: "endOfCurrentPeriod")
endOfCurrentPeriod = Date(timeIntervalSince1970: _endOfCurrentPeriod)
active = try params.required(key: "active")
cancelAtEndOfPeriod = try params.required(key: "cancelAtPeriodEnd")
status = SubscriptionStatus(rawValue: try params.required(key: "status"))
let processorString: String = try params.required(key: "processor")
if let donationPaymentProcessor = DonationPaymentProcessor(rawValue: processorString) {
self.donationPaymentProcessor = donationPaymentProcessor
} else if BackupPaymentProcessor(rawValue: processorString) != nil {
self.donationPaymentProcessor = nil
} else {
owsFailDebug("[Donations] Unrecognized payment processor while parsing subscription: \(processorString)")
self.donationPaymentProcessor = nil
}
let paymentMethodString: String? = try params.optional(key: "paymentMethod")
if let donationPaymentMethod = paymentMethodString.map({ DonationPaymentMethod(serverRawValue: $0) }) {
self.donationPaymentMethod = donationPaymentMethod
} else if paymentMethodString.map({ BackupPaymentMethod(rawValue: $0) }) != nil {
self.donationPaymentMethod = nil
} else {
owsFailDebug("[Donations] Unrecognized payment method while parsing subscription: \(paymentMethodString ?? "nil")")
self.donationPaymentMethod = nil
}
isPaymentProcessing = try params.required(key: "paymentProcessing")
if let chargeFailureDict {
chargeFailure = ChargeFailure(jsonDictionary: chargeFailureDict)
} else {
chargeFailure = nil
}
}
}