// // Copyright (c) 2021 Open Whisper Systems. All rights reserved. // import Foundation import SignalServiceKit import PassKit import ZKGroup public class SubscriptionLevel: Comparable { public let level: UInt public let name: String public let badge: ProfileBadge public let currency: [String: NSDecimalNumber] public init(level: UInt, jsonDictionary: [String: Any]) throws { self.level = level let params = ParamParser(dictionary: jsonDictionary) name = try params.required(key: "name") let badgeDict: [String: Any] = try params.required(key: "badge") badge = try ProfileBadge(jsonDictionary: badgeDict) let currencyDict: [String: Any] = try params.required(key: "currencies") currency = currencyDict.compactMapValues { guard let int64Currency = $0 as? Int64 else { owsFailDebug("Failed to convert currency value") return nil } return NSDecimalNumber(value: int64Currency) } } // MARK: Comparable public static func < (lhs: SubscriptionLevel, rhs: SubscriptionLevel) -> Bool { return lhs.level < rhs.level } public static func == (lhs: SubscriptionLevel, rhs: SubscriptionLevel) -> Bool { return lhs.level == rhs.level } } public struct Subscription { public let level: UInt public let currency: String public let amount: NSDecimalNumber public let endOfCurrentPeriod: TimeInterval public let billingCycleAnchor: TimeInterval public let active: Bool public let cancelAtEndOfPeriod: Bool public init(jsonDictionary: [String: Any]) throws { let params = ParamParser(dictionary: jsonDictionary) level = try params.required(key: "level") currency = try params.required(key: "currency") let amountValue: Int64 = try params.required(key: "amount") amount = NSDecimalNumber(value: amountValue) endOfCurrentPeriod = try params.required(key: "endOfCurrentPeriod") billingCycleAnchor = try params.required(key: "billingCycleAnchor") active = try params.required(key: "active") cancelAtEndOfPeriod = try params.required(key: "cancelAtPeriodEnd") } } public class SubscriptionManager: NSObject { private static let subscriptionKVS = SDSKeyValueStore(collection: "SubscriptionKeyValueStore") private static let subscriberIDKey = "subscriberID" private static let subscriberCurrencyCodeKey = "subscriberCurrencyCode" // MARK: Subscription levels public class func getSubscriptions() -> Promise<[SubscriptionLevel]> { let request = OWSRequestFactory.subscriptionLevelsRequest() return firstly { networkManager.makePromise(request: request) }.map(on: .global()) { response in guard let json = response.responseBodyJson as? [String: Any] else { throw OWSAssertionError("Missing or invalid JSON.") } guard let parser = ParamParser(responseObject: json) else { throw OWSAssertionError("Missing or invalid response.") } do { let subscriptionDicts: [String: Any] = try parser.required(key: "levels") let subscriptions: [SubscriptionLevel] = try subscriptionDicts.compactMap { (subscriptionKey: String, value: Any) in guard let subscriptionDict = value as? [String: Any] else { return nil } guard let level = UInt(subscriptionKey) else { throw OWSAssertionError("Unable to determine subscription level") } return try SubscriptionLevel(level: level, jsonDictionary: subscriptionDict) } return subscriptions.sorted() } catch { owsFailDebug("Unable to parse subscription levels, \(error)") } return [] } } public class func getCurrentSubscriptionStatus(for subscriberID: Data) -> Promise { let subscriberIDString = subscriberID.asBase64Url let request = OWSRequestFactory.subscriptionGetCurrentSubscriptionLevelRequest(subscriberIDString) return firstly { networkManager.makePromise(request: request) }.map(on: .global()) { response in let statusCode = response.responseStatusCode if statusCode != 200 { throw OWSAssertionError("Got bad response code \(statusCode).") } if let json = response.responseBodyJson as? [String: Any] { guard let parser = ParamParser(responseObject: json) else { throw OWSAssertionError("Missing or invalid response.") } guard let subscriptionDict: [String: Any] = try parser.optional(key: "subscription") else { return nil } return try Subscription(jsonDictionary: subscriptionDict) } else { return nil } } } // MARK: Subscription management public static func getSubscriberID(transaction: SDSAnyReadTransaction) -> Data? { guard let subscriberID = subscriptionKVS.getObject( forKey: subscriberIDKey, transaction: transaction ) as? Data else { return nil } return subscriberID } public static func setSubscriberID(_ subscriberID: Data?, transaction: SDSAnyWriteTransaction) { subscriptionKVS.setObject(subscriberID, key: subscriberIDKey, transaction: transaction) } public static func getSubscriberCurrencyCode(transaction: SDSAnyReadTransaction) -> String? { guard let subscriberCurrencyCode = subscriptionKVS.getObject( forKey: subscriberCurrencyCodeKey, transaction: transaction ) as? String else { return nil } return subscriberCurrencyCode } public static func setSubscriberCurrencyCode(_ currencyCode: String?, transaction: SDSAnyWriteTransaction) { subscriptionKVS.setObject(currencyCode, key: subscriberCurrencyCodeKey, transaction: transaction) } private class func setupNewSubscriberID() throws -> Promise { let newSubscriberID = generateSubscriberID() let request = OWSRequestFactory.setSubscriptionIDRequest(newSubscriberID.asBase64Url) return firstly { networkManager.makePromise(request: request) }.map(on: .global()) { response in let statusCode = response.responseStatusCode if statusCode != 200 { throw OWSAssertionError("Got bad response code \(statusCode).") } return newSubscriberID } } private class func generateSubscriberID() -> Data { return Cryptography.generateRandomBytes(UInt(32)) } public class func setupNewSubscription(subscription: SubscriptionLevel, payment: PKPayment, currencyCode: String) throws -> Promise { var generatedSubscriberID = Data() var generatedClientSecret = "" var generatedPaymentID = "" return firstly { return try setupNewSubscriberID() // Create Stripe SetupIntent against new subscriberID }.then(on: .sharedUserInitiated) { subscriberID -> Promise in generatedSubscriberID = subscriberID SDSDatabaseStorage.shared.write { transaction in self.setSubscriberID(subscriberID, transaction: transaction) self.setSubscriberCurrencyCode(currencyCode, transaction: transaction) self.storageServiceManager.recordPendingLocalAccountUpdates() } return try createPaymentMethod(for: subscriberID) // Create new payment method }.then(on: .sharedUserInitiated) { clientSecret -> Promise in generatedClientSecret = clientSecret return Stripe.createPaymentMethod(with: payment) // Bind payment method to SetupIntent, confirm SetupIntent }.then(on: .sharedUserInitiated) { paymentID -> Promise in generatedPaymentID = paymentID return try Stripe.confirmSetupIntent(for: generatedPaymentID, clientSecret: generatedClientSecret) // Update payment on server }.then(on: .sharedUserInitiated) { _ -> Promise in return try setDefaultPaymentMethod(for: generatedSubscriberID, paymentID: generatedPaymentID) // Select subscription level }.then(on: .sharedUserInitiated) { _ -> Promise in return setSubscription(for: generatedSubscriberID, subscription: subscription, currency: currencyCode) // Report success and dismiss sheet } } public class func updateSubscriptionLevel(for subscriberID: Data, from priorSubscription: SubscriptionLevel, to subscription: SubscriptionLevel, payment: PKPayment, currencyCode: String) throws -> Promise { var generatedClientSecret = "" var generatedPaymentID = "" return firstly { try createPaymentMethod(for: subscriberID) // Create new payment method }.then(on: .sharedUserInitiated) { clientSecret -> Promise in generatedClientSecret = clientSecret return Stripe.createPaymentMethod(with: payment) // Bind payment method to SetupIntent, confirm SetupIntent }.then(on: .sharedUserInitiated) { paymentID -> Promise in generatedPaymentID = paymentID return try Stripe.confirmSetupIntent(for: generatedPaymentID, clientSecret: generatedClientSecret) // Update payment on server }.then(on: .sharedUserInitiated) { _ -> Promise in return try setDefaultPaymentMethod(for: subscriberID, paymentID: generatedPaymentID) // Select subscription level }.then(on: .sharedUserInitiated) { _ -> Promise in return setSubscription(for: subscriberID, subscription: subscription, currency: currencyCode) // Report success and dismiss sheet } } public class func cancelSubscription(for subscriberID: Data) throws -> Promise { // TODO EB Should we delete regardless of success of delete call? SDSDatabaseStorage.shared.write { transaction in self.setSubscriberID(nil, transaction: transaction) self.setSubscriberCurrencyCode(nil, transaction: transaction) self.storageServiceManager.recordPendingLocalAccountUpdates() } let request = OWSRequestFactory.deleteSubscriptionIDRequest(subscriberID.asBase64Url) return firstly { networkManager.makePromise(request: request) }.map(on: .global()) { response in let statusCode = response.responseStatusCode if statusCode != 200 { throw OWSAssertionError("Got bad response code \(statusCode).") } } } private class func createPaymentMethod(for subscriberID: Data) throws -> Promise { let request = OWSRequestFactory.subscriptionCreatePaymentMethodRequest(subscriberID.asBase64Url) return firstly { networkManager.makePromise(request: request) }.map(on: .global()) { response in let statusCode = response.responseStatusCode if statusCode != 200 { throw OWSAssertionError("Got bad response code \(statusCode).") } guard let json = response.responseBodyJson as? [String: Any] else { throw OWSAssertionError("Unable to parse response body.") } guard let parser = ParamParser(responseObject: json) else { throw OWSAssertionError("Missing or invalid response.") } do { let clientSecret: String = try parser.required(key: "clientSecret") return clientSecret } catch { throw OWSAssertionError("Missing clientID key") } } } private class func setDefaultPaymentMethod(for subscriberID: Data, paymentID: String) throws -> Promise { let request = OWSRequestFactory.subscriptionSetDefaultPaymentMethodRequest(subscriberID.asBase64Url, paymentID: paymentID) return firstly { networkManager.makePromise(request: request) }.map(on: .global()) { response in let statusCode = response.responseStatusCode if statusCode != 200 { throw OWSAssertionError("Got bad response code \(statusCode).") } } } private class func setSubscription(for subscriberID: Data, subscription: SubscriptionLevel, currency: String) -> Promise { let subscriberID = subscriberID.asBase64Url let key = Cryptography.generateRandomBytes(UInt(32)).asBase64Url let level = String(subscription.level) let request = OWSRequestFactory.subscriptionSetSubscriptionLevelRequest(subscriberID, level: level, currency: currency, idempotencyKey: key) return firstly { networkManager.makePromise(request: request) }.map(on: .global()) { response in let statusCode = response.responseStatusCode if statusCode != 200 { throw OWSAssertionError("Got bad response code \(statusCode).") } } } public class func requestAndRedeemRecieptsIfNecessary(for subscriberID: Data, subscription: SubscriptionLevel, priorSubscription: SubscriptionLevel? = nil) throws -> Promise { return firstly { return try requestReceiptCredentialPresentation(for: subscriberID, subscription: subscription) }.then(on: .sharedUserInitiated) { presentation -> Promise in try redeemReceiptCredentialPresentation(receiptCredentialPresentation: presentation) } } private class func requestReceiptCredentialPresentation(for subscriberID: Data, subscription: SubscriptionLevel, priorSubscription: SubscriptionLevel? = nil) throws -> Promise { let clientOperations = try clientZKReceiptOperations() let receiptSerial = try generateReceiptSerial() let receiptCredentialRequestContext = try clientOperations.createReceiptCredentialRequestContext(receiptSerial: receiptSerial) let receiptCredentialRequest = try receiptCredentialRequestContext.getRequest().serialize().asData.base64EncodedString() let request = OWSRequestFactory.subscriptionRecieptCredentialsRequest(subscriberID.asBase64Url, request: receiptCredentialRequest) return firstly { networkManager.makePromise(request: request) }.map(on: .global()) { response in let statusCode = response.responseStatusCode if statusCode == 200 { Logger.debug("Got valid receipt response") } else if statusCode == 204 { Logger.debug("User has no active subscriptions") } else { throw OWSAssertionError("Got bad response code \(statusCode).") } guard let json = response.responseBodyJson as? [String: Any] else { throw OWSAssertionError("Unable to parse response body.") } guard let parser = ParamParser(responseObject: json) else { throw OWSAssertionError("Missing or invalid response.") } do { let receiptCredentialResponseString: String = try parser.required(key: "receiptCredentialResponse") guard let receiptCredentialResponseData = Data(base64Encoded: receiptCredentialResponseString) else { throw OWSAssertionError("Unable to parse receiptCredentialResponse into data.") } let receiptCredentialResponse = try ReceiptCredentialResponse(contents: [UInt8](receiptCredentialResponseData)) let receiptCredential = try clientOperations.receiveReceiptCredential(receiptCredentialRequestContext: receiptCredentialRequestContext, receiptCredentialResponse: receiptCredentialResponse) // Validate that receipt credential level matches requested level, or prior subscription level let level = try receiptCredential.getReceiptLevel() var receiptCredentialHasValidLevel = (level == subscription.level) if !receiptCredentialHasValidLevel, let priorSubscription = priorSubscription { receiptCredentialHasValidLevel = (level == priorSubscription.level) } guard receiptCredentialHasValidLevel else { throw OWSAssertionError("Unexpected receipt credential level") } // Validate receipt credential expiration % 86400 == 0, per server spec let expiration = try receiptCredential.getReceiptExpirationTime() guard expiration % 86400 == 0 else { throw OWSAssertionError("Invalid receipt credential expiration, expiration mod != 0") } // Validate expiration is less than 60 days from now let maximumValidExpirationDate = Date().timeIntervalSince1970 + (60 * 24 * 60 * 60) guard TimeInterval(expiration) < maximumValidExpirationDate else { throw OWSAssertionError("Invalid receipt credential expiration, expiration is more than 60 days from now") } let receiptCredentialPresentation = try clientOperations.createReceiptCredentialPresentation(receiptCredential: receiptCredential) return receiptCredentialPresentation } catch { throw OWSAssertionError("Missing clientID key") } } } private class func redeemReceiptCredentialPresentation(receiptCredentialPresentation: ReceiptCredentialPresentation) throws -> Promise { let receiptCredentialPresentationString = receiptCredentialPresentation.serialize().asData.base64EncodedString() let request = OWSRequestFactory.subscriptionRedeemRecieptCredential(receiptCredentialPresentationString, makePrimary: true) return firstly { networkManager.makePromise(request: request) }.map(on: .global()) { response in let statusCode = response.responseStatusCode if statusCode != 200 { throw OWSAssertionError("Got bad response code \(statusCode).") } } } private class func generateReceiptSerial() throws -> ReceiptSerial { let count = ReceiptSerial.SIZE let bytes = Cryptography.generateRandomBytes(UInt(count)) return try ReceiptSerial(contents: [UInt8](bytes)) } private class func clientZKReceiptOperations() throws -> ClientZkReceiptOperations { let params = try GroupsV2Protos.serverPublicParams() return ClientZkReceiptOperations(serverPublicParams: params) } }