TM-SGNL-iOS/SignalServiceKit/Subscriptions/Subscription.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

153 lines
6.4 KiB
Swift

//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
/// 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?
public init() {
code = nil
}
public init(code: String) {
self.code = code
}
public init(jsonDictionary: [String: Any]) {
code = try? ParamParser(dictionary: 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: String {
case unknown
case incomplete = "incomplete"
case unpaid = "unpaid"
/// Indicates the subscription has been paid successfully for the
/// current period, and all is well.
case active = "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 = "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 = "past_due"
}
public let level: UInt
public let amount: FiatMoney
public let endOfCurrentPeriod: TimeInterval
public let billingCycleAnchor: TimeInterval
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)",
"Billing cycle anchor: \(billingCycleAnchor)",
"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(dictionary: 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
}
}()
)
endOfCurrentPeriod = try params.required(key: "endOfCurrentPeriod")
billingCycleAnchor = try params.required(key: "billingCycleAnchor")
active = try params.required(key: "active")
cancelAtEndOfPeriod = try params.required(key: "cancelAtPeriodEnd")
status = SubscriptionStatus(rawValue: try params.required(key: "status")) ?? .unknown
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 = chargeFailureDict {
chargeFailure = ChargeFailure(jsonDictionary: chargeFailureDict)
} else {
chargeFailure = nil
}
}
}