Feat: [CP-8865] Add userTransaction UUID to PaymentsV2

Refs: https://gitlab.protontech.ch/apple/shared/protoncore/-/merge_requests/1969
This commit is contained in:
Tiziano Bruni 2024-11-22 17:03:54 +00:00
commit 776a8638ac
12 changed files with 211 additions and 74 deletions

View file

@ -199,7 +199,7 @@
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Something went wrong, please retry. If the issue persists please contact our support team"
"value" : "Something went wrong, please retry. If the issue persists please contact our support team."
}
}
}

View file

@ -25,7 +25,7 @@ public extension JSONDecoder.KeyDecodingStrategy {
static var lowerCamelCase: JSONDecoder.KeyDecodingStrategy {
.custom {
// this has been added convert the ID JSON key to the most common used id
if $0.last!.stringValue == "ID" {
if $0.last!.stringValue == "ID" || $0.last!.stringValue == "UUID" {
return AnyKey(stringValue: $0.last!.stringValue.lowercased())!
}
let currentKey = $0.last!.stringValue

View file

@ -37,6 +37,7 @@ public protocol ProtonTransactionProviding {
var originalID: UInt64 { get }
var productID: String { get }
var price: Decimal? { get }
var userTransactionUUID: UUID? { get }
var currencyIdentifier: String? { get }
}
@ -45,6 +46,7 @@ public struct ProtonTransaction: ProtonTransactionProviding {
public var originalID: UInt64
public var productID: String
public var price: Decimal?
public var userTransactionUUID: UUID?
public var currencyIdentifier: String?
}
@ -58,6 +60,7 @@ extension Transaction {
originalID: originalID,
productID: productID,
price: price,
userTransactionUUID: appAccountToken,
currencyIdentifier: currencyCode )
}
}

View file

@ -0,0 +1,31 @@
//
// UserTransactionUUIDResponse.swift
// PaymentsV2 - Created on 15/10/2024.
//
// Copyright (c) 2024 Proton Technologies AG
//
// This file is part of Proton Technologies AG.
//
// ProtonCore is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// ProtonCore is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
import Foundation
public struct UserTransactionUUIDResponse: Decodable {
let code: Int
let uuid: String
var uuidValue: UUID? {
UUID(uuidString: uuid)
}
}

View file

@ -31,10 +31,31 @@ public struct PaymentsAPIs {
private struct Constants {
static var envPrefix = "https://"
static var moduleNameSpace = "/payments/"
static func moduleNameSpace(requestType: RequestType) -> String {
switch requestType {
case .userTransactionUUID:
return "/auth/"
default:
return "/payments/"
}
}
static func apiVersion(requestType: RequestType) -> APIv {
switch requestType {
case .userTransactionUUID:
return .v4
default:
return .v5
}
}
static func urlString(requestType: RequestType, baseURL: String) -> String {
return Constants.envPrefix + baseURL + Constants.moduleNameSpace(requestType: requestType) + Constants.apiVersion(requestType: requestType).rawValue + requestType.requestEndpoint
}
}
private enum APIv: String {
case v4
case v5
}
@ -43,7 +64,7 @@ public struct PaymentsAPIs {
public func url(for api: RequestType) throws -> APIRequest {
let urlString = Constants.envPrefix + envURL.baseUrl + Constants.moduleNameSpace + version.rawValue + api.requestEndpoint
let urlString = Constants.urlString(requestType: api, baseURL: envURL.baseUrl)
var urlComponents = URLComponents(string: urlString)
if let queryItems = api.queryComponents {
@ -88,6 +109,7 @@ public enum RequestType {
// MARK: Miscellaneous
case icon(name: String)
case userTransactionUUID
}
extension RequestType {
@ -112,6 +134,8 @@ extension RequestType {
return "/plans"
case .icon(let iconName):
return "/resources/icons/\(iconName)"
case .userTransactionUUID:
return "/sessions/uuid"
}
}
@ -139,6 +163,8 @@ extension RequestType {
return nil
case .icon:
return nil
case .userTransactionUUID:
return nil
}
}
@ -171,6 +197,8 @@ extension RequestType {
return generateQueryParameters(parameters: queryParams)
case .icon:
return nil
case .userTransactionUUID:
return nil
}
}

View file

@ -64,7 +64,7 @@ public class RemoteManager: RemoteManagerProviding {
public func updateSession(sessionID: String, authToken: String) {
requestHTTPHeader[.sessionId] = sessionID
requestHTTPHeader[.authorization] = "Bearer \(authToken)"
requestHTTPHeader[.authorization] = "Bearer \(authToken)"
}
// MARK: Private methods

View file

@ -42,6 +42,7 @@ public enum ProtonPlansManagerError: Error {
case unableToCreateRequest
case unableToFetchProductsFromStore
case unableToMatchProtonPlanToStoreProduct
case unableToGetUserTransactionUUID
// Transaction error
case transactionNotFound
@ -138,7 +139,8 @@ public final class ProtonPlansManager: NSObject, ProtonPlansManagerProviding {
self.planName = planName
self.planCycle = planCycle
let result = try await product.purchase()
let userTransactionUUID = try await generateUserTransactionUUID()
let result = try await product.purchase(options: [.appAccountToken(userTransactionUUID)])
switch result {
case .success(let verificationResult):
@ -170,6 +172,23 @@ public final class ProtonPlansManager: NSObject, ProtonPlansManagerProviding {
}
}
private func generateUserTransactionUUID() async throws -> UUID {
guard let request = try? paymentsAPI.url(for: .userTransactionUUID) else {
throw ProtonPlansManagerError.unableToGetUserTransactionUUID
}
do {
let uuidStrign: UserTransactionUUIDResponse = try await remoteManager.getFromURL(request.url)
guard let uuid = UUID(uuidString: uuidStrign.uuid) else {
throw ProtonPlansManagerError.unableToGetUserTransactionUUID
}
return uuid
} catch {
throw ProtonPlansManagerError.unableToGetUserTransactionUUID
}
}
private func findMatchingPlan(productID: String) -> ComposedPlan? {
planComposer.matchPlanToStoreProduct(productID)
}

View file

@ -22,18 +22,22 @@
import Foundation
import StoreKit
enum StoreKitReceiptManagerError: Error {
case unableToExtractReceiptData
}
public protocol StoreKitReceiptManagerProviding {
func fetchPurchaseReceipt() throws -> String
}
final public class StoreKitReceiptManager: StoreKitReceiptManagerProviding {
public final class StoreKitReceiptManager: StoreKitReceiptManagerProviding {
public init() {}
public func fetchPurchaseReceipt() throws -> String {
guard let url = Bundle.main.appStoreReceiptURL, let data = try? Data(contentsOf: url) else {
debugPrint("Unable to get receipt data")
throw ProtonPlansManagerError.unableToExtractReceiptData
throw StoreKitReceiptManagerError.unableToExtractReceiptData
}
return data.base64EncodedString()

View file

@ -27,6 +27,8 @@ public enum TransactionType {
case failed
case renewal
case alreadyProcessed
case transactionUUIDNotFoundOrMismatching
case unableToVerifyAccountsUUIDs
case unknown
}
@ -81,13 +83,22 @@ public final class StoreObserver {
Task(priority: .background) {
for await update in Transaction.updates {
if let transaction = try? update.payloadValue {
guard let plan = planComposer?.matchPlanToStoreProduct(transaction.productID) else {
guard let plan = planComposer?.matchPlanToStoreProduct(transaction.productID), let appAccountToken = transaction.appAccountToken else {
return
}
do {
_ = try await transactionHandler?.processTransaction(transaction.toProtonTransaction(), plan: plan)
await transaction.finish()
transactionStatus = .successful
guard let accountMatching = try await transactionHandler?.verifyTransactionUUIDs(appAccountToken: appAccountToken) else {
transactionStatus = .unableToVerifyAccountsUUIDs
return
}
if accountMatching {
_ = try await transactionHandler?.processTransaction(transaction.toProtonTransaction(), plan: plan)
await transaction.finish()
transactionStatus = .successful
} else {
transactionStatus = .transactionUUIDNotFoundOrMismatching
return
}
} catch {
debugPrint(error)

View file

@ -19,14 +19,20 @@
// You should have received a copy of the GNU General Public License
// along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
import Foundation
import ProtonCoreObservability
import ProtonCoreNetworking
import StoreKit
public enum TransactionHandlerError: Error {
case unableToCreateRequest
case unableToFindPlanName
case unableToFindMatchingPlan
case transactionIdNotEqualToOriginalTransactionId
case userTransactionUUIDNotMatching
case unableToGetBundleIdentifier
case unableToGetTransactionAmountOrCurrency
}
public enum TransactionHandlerState: String {
@ -74,6 +80,7 @@ public final class TransactionHandler {
}
try await resolveTransaction(transaction, plan: plan)
// transaction.appAccountToken
// add API to fetch account UUID from BE --> AccountUUID
// if transaction.appAccountToken == AccountUUID --> Process
@ -83,6 +90,17 @@ public final class TransactionHandler {
public func updateRemoteManager(remoteManager: RemoteManagerProviding) {
self.remoteManager = remoteManager
}
public func verifyTransactionUUIDs(appAccountToken: UUID) async throws -> Bool {
guard let request = try? paymentsAPIs.url(for: .userTransactionUUID) else {
throw TransactionHandlerError.unableToCreateRequest
}
debugPrint("Fetching user transaction UUID")
let userUUID: UserTransactionUUIDResponse = try await remoteManager.getFromURL(request.url)
return appAccountToken == userUUID.uuidValue
}
}
// MARK: Private methods
@ -97,13 +115,13 @@ private extension TransactionHandler {
let transactionIdentifier = transaction.originalID
guard let bundleIdentifier = Bundle.main.bundleIdentifier else {
debugPrint("bundle not obtainable")
throw ProtonPlansManagerError.unableToGetBundleIdentifier
throw TransactionHandlerError.unableToGetBundleIdentifier
}
guard let amount = transaction.price, let currency = transaction.currencyIdentifier else {
debugPrint("Impossible to get amount and currency from transaction")
transactionState = .transactionProcessError
throw ProtonPlansManagerError.unableToGetTransactionAmountOrCurrency
throw TransactionHandlerError.unableToGetTransactionAmountOrCurrency
}
transactionState = .generatingReceipt
@ -125,7 +143,7 @@ private extension TransactionHandler {
guard let request = try? paymentsAPIs.url(for: .createToken(token: transactionToken)) else {
transactionState = .transactionProcessError
throw ProtonPlansManagerError.unableToCreateRequest
throw TransactionHandlerError.unableToCreateRequest
}
debugPrint("Creating payment token..")
do {
@ -163,7 +181,7 @@ private extension TransactionHandler {
guard let request = try? paymentsAPIs.url(for: .createSubscription(newSubscription: newSub)) else {
transactionState = .transactionProcessError
throw ProtonPlansManagerError.unableToCreateRequest
throw TransactionHandlerError.unableToCreateRequest
}
transactionState = .createNewSubscription

View file

@ -244,4 +244,17 @@ final class PaymentsAPIsTokenTests: XCTestCase {
XCTAssertEqual(expectedResult, result?.url)
XCTAssertNil(result?.body)
}
// MARK: Transaction UUID
func test_transactionUUID() throws {
guard let expectedResult = URL(string: "https://proton.black/api/auth/v4/sessions/uuid") else {
return
}
let result = try? sut.url(for: .userTransactionUUID)
XCTAssertEqual(expectedResult, result?.url)
XCTAssertNil(result?.body)
}
}

View file

@ -23,7 +23,7 @@ import XCTest
@testable import ProtonCorePaymentsV2
final class RemoteManagerTests: XCTestCase {
private var paymentsAPI: PaymentsAPIs!
private var urlSessionConfig: URLSessionConfiguration!
private var sut: RemoteManager!
@ -44,7 +44,7 @@ final class RemoteManagerTests: XCTestCase {
mockRemoteManager.destroy()
mockRemoteManager = nil
}
func XCTAssertThrowsErrorAsync<T, R>(
_ expression: @autoclosure () async throws -> T,
_ errorThrown: @autoclosure () -> R,
@ -63,14 +63,14 @@ final class RemoteManagerTests: XCTestCase {
// MARK: Token
extension RemoteManagerTests {
func test_check_token_status_unusable_token() async throws {
let mockResponse: [String: Any] = [
"Code": 1000,
"Status": 0
]
mockRemoteManager.setupURLSessionMock(withMockResponse: mockResponse)
guard let request = try? paymentsAPI.url(for: .checkToken(token: "1234214sdasd")) else {
@ -78,12 +78,12 @@ extension RemoteManagerTests {
return
}
let tokenStatus: TokenStatus = try await sut.getFromURL(request.url)
XCTAssertEqual(tokenStatus.code, 1000)
XCTAssertEqual(tokenStatus.status, 0)
XCTAssertFalse(tokenStatus.tokenUsable)
}
func test_check_token_status_usable_token() async throws {
let mockResponse: [String: Any] = [
@ -105,48 +105,48 @@ extension RemoteManagerTests {
}
func test_create_payment_token() async throws {
let mockResponse: [String: Any] = [
"Code": 1000,
"Status": 1,
"Token": "IM_A_TOKEN",
"Data": NSNull()
]
mockRemoteManager.setupURLSessionMock(withMockResponse: mockResponse)
let token = Token(amount: 200, currency: "USD", payment: nil, paymentMethodID: nil)
guard let request = try? paymentsAPI.url(for: .createToken(token: token)) else {
XCTFail("Unable to generate the expected request")
return
}
let tokenStatus: NewToken = try await sut.postToURL(request: request)
XCTAssertEqual(tokenStatus.code, 1000)
XCTAssertEqual(tokenStatus.status, 1)
XCTAssertEqual(tokenStatus.token, "IM_A_TOKEN")
}
func test_create_payment_token_code_only() async throws {
let mockResponse: [String: Any] = [
"Code": 1000,
]
mockRemoteManager.setupURLSessionMock(withMockResponse: mockResponse)
let token = Token(amount: 200, currency: "USD", payment: nil, paymentMethodID: nil)
guard let request = try? paymentsAPI.url(for: .createToken(token: token)) else {
XCTFail("Unable to generate the expected request")
return
}
let tokenStatus: StatusResponse = try await sut.postToURL(request: request)
XCTAssertEqual(tokenStatus.code, 1000)
}
func test_create_payment_token_no_response() async throws {
mockRemoteManager.setupURLSessionMock()
let token = Token(amount: 200, currency: "USD", payment: nil, paymentMethodID: nil)
@ -154,15 +154,14 @@ extension RemoteManagerTests {
XCTFail("Unable to generate the expected request")
return
}
try await sut.postToURL(request: request)
XCTAssertTrue(true)
}
func test_create_payment_token_no_response_fail() async throws {
let expectedErrorCode = 500
mockRemoteManager.setupURLSessionMock(responseStatusCode: expectedErrorCode)
let token = Token(amount: 200, currency: "USD", payment: nil, paymentMethodID: nil)
@ -170,7 +169,7 @@ extension RemoteManagerTests {
XCTFail("Unable to generate the expected request")
return
}
await XCTAssertThrowsErrorAsync(
try await sut.postToURL(request: request),
RemoteError.responseReturnedError(errorCode: expectedErrorCode)
@ -180,24 +179,23 @@ extension RemoteManagerTests {
// MARK: Subscription
extension RemoteManagerTests {
func test_get_current_Subscription() async throws {
let mockResponse = Bundle.main.loadJsonDataToDic(from: "current_sub_response.json")
mockRemoteManager.setupURLSessionMock(withMockResponse: mockResponse)
guard let request = try? paymentsAPI.url(for: .getCurrentSubscription) else {
XCTFail("Unable to generate the expected request")
return
}
let currentSub: CurrentSubscription = try await sut.getFromURL(request.url)
XCTAssertEqual(currentSub.code, 1000)
XCTAssertEqual(currentSub.upcomingSubscriptions?[0].cycle, 1)
}
func test_create_new_Subscription() async throws {
let mockResponse = Bundle.main.loadJsonDataToDic(from: "new_sub_payload.json")
mockRemoteManager.setupURLSessionMock(withMockResponse: mockResponse)
@ -217,43 +215,43 @@ extension RemoteManagerTests {
codes: ["CODE1", "CODE2"],
couponCode: "BUNDLE2022",
giftCode: "123abc"))
guard let request = try? paymentsAPI.url(for: .createSubscription(newSubscription: payload)) else {
XCTFail("Unable to generate the expected request")
return
}
let newSub: NewSubscriptionResponse = try await sut.postToURL(request: request)
XCTAssertEqual(newSub.code, 1000)
XCTAssertEqual(newSub.subscription.renew, 1)
XCTAssertNil(newSub.upcomingSubscriptions)
}
func test_cancel_current_subscription() async throws {
let mockResponse: [String: Any] = [
"Code": 1000,
]
mockRemoteManager.setupURLSessionMock(withMockResponse: mockResponse)
let payload = CancelSubscription(reason: "TEMPORARY",
score: 7,
context: "vpn",
feedback: "I need a computer.",
reasonDetails: "I do not have a computer.")
guard let request = try? paymentsAPI.url(for: .cancelSubscription(cancelSubscription: payload)) else {
XCTFail("Unable to generate the expected request")
return
}
let responseStatus: StatusResponse = try await sut.deleteToURL(request: request)
XCTAssertEqual(responseStatus.code, 1000)
}
func test_check_subscription() async throws {
let mockResponse = Bundle.main.loadJsonDataToDic(from: "check_sub_payload.json")
mockRemoteManager.setupURLSessionMock(withMockResponse: mockResponse)
@ -265,89 +263,102 @@ extension RemoteManagerTests {
codes: ["CODE1", "CODE2"],
couponCode: "discountCode",
giftCode: "giftCode")
guard let request = try? paymentsAPI.url(for: .checkSubscription(subscription: payload)) else {
XCTFail("Unable to generate the expected request")
return
}
let subValidationResponse: ValidateSubscriptionResponse = try await sut.postToURL(request: request)
XCTAssertEqual(subValidationResponse.code, 1000)
XCTAssertEqual(subValidationResponse.proration, -1448)
XCTAssertEqual(subValidationResponse.coupon.code, "TEST2022")
}
func test_latest_subscription() async throws {
let mockResponse: [String: Any] = [
"Code": 1000,
"LastSubscriptionEnd": 1531519200
]
mockRemoteManager.setupURLSessionMock(withMockResponse: mockResponse)
guard let request = try? paymentsAPI.url(for: .subscriptionLatest) else {
XCTFail("Unable to generate the expected request")
return
}
let latestSub: LastSubscription = try await sut.getFromURL(request.url)
XCTAssertEqual(latestSub.code, 1000)
XCTAssertEqual(latestSub.lastSubscriptionEnd, 1531519200)
}
func test_change_renew_subscription() async throws {
let mockResponse: [String: Any] = [
"Code": 1000
]
mockRemoteManager.setupURLSessionMock(withMockResponse: mockResponse)
let payload = RenewSubscription(renewalState: 1,
cancellationFeedback: "{\n \"Reason\":\"QUALITY_ISSUE\",\n \"Feedback\":\"I need a computer.\",\n \"ReasonDetails\":\"I am Amish\",\n \"Context\":\"mail\"\n }")
guard let request = try? paymentsAPI.url(for: .changeRenewSubscription(renewSubscription: payload)) else {
XCTFail("Unable to generate the expected request")
return
}
let response: StatusResponse = try await sut.putToURL(request: request)
XCTAssertEqual(response.code, 1000)
}
func test_userTransactionUUID() async throws {
let mockResponse: [String: Any] = [
"Code": 1000,
"UUID": "adq2d12dp12od1p2odmp12od"
]
mockRemoteManager.setupURLSessionMock(withMockResponse: mockResponse)
guard let request = try? paymentsAPI.url(for: .userTransactionUUID) else {
XCTFail("Unable to generate the expected request")
return
}
let userTransactionUUID: UserTransactionUUIDResponse = try await sut.getFromURL(request.url)
XCTAssertEqual(userTransactionUUID.code, 1000)
XCTAssertEqual(userTransactionUUID.uuid, "adq2d12dp12od1p2odmp12od")
}
}
// MARK: Payment Status
extension RemoteManagerTests {
func test_payment_status() async throws {
let mockResponse = Bundle.main.loadJsonDataToDic(from: "payment_status_payload.json")
mockRemoteManager.setupURLSessionMock(withMockResponse: mockResponse)
guard let request = try? paymentsAPI.url(for: .paymentStatus(vendor: .Apple)) else {
XCTFail("Unable to generate the expected request")
return
}
let paymentStatus: PaymentsStatus = try await sut.getFromURL(request.url)
XCTAssertEqual(paymentStatus.vendorStates.inApp, 1)
XCTAssertEqual(paymentStatus.vendorStates.bitcoin, 0)
}
}
extension RemoteManagerTests {
func test_APIError_code_handling() async throws {
let mockResponse: [String: Any] = [
"Code": 5003
]
mockRemoteManager.setupURLSessionMock(withMockResponse: mockResponse)
let payload = RenewSubscription(renewalState: 1,
cancellationFeedback: "dasdas")
guard let request = try? paymentsAPI.url(for: .changeRenewSubscription(renewSubscription: payload)) else {
XCTFail("Unable to generate the expected request")
return
}
await XCTAssertThrowsErrorAsync(
try await sut.putToURL(request: request),
APICodeError.badAppVersion
@ -357,11 +368,10 @@ extension RemoteManagerTests {
// MARK: Plans
extension RemoteManagerTests {
func test_get_available_plans() async throws {
let mockResponse = Bundle.main.loadJsonDataToDic(from: "availablePlans.json")
mockRemoteManager.setupURLSessionMock(withMockResponse: mockResponse)
guard let request = try? paymentsAPI.url(for: .availablePlans(currency: "USD", vendor: "Apple", state: 1, timeStamp: 123124)) else {
XCTFail("Unable to generate the expected request")
return