From c4b55cc82f6409ce2c0f3526a3b8ce89287192f7 Mon Sep 17 00:00:00 2001 From: Bianca Danforth Date: Mon, 14 Mar 2022 14:00:09 -0400 Subject: [PATCH] feat(auth): set up App Store modules Because: * We want SubPlat to know about Apple IAP subscriptions for our RPs. This commit: * Sets up the needed Apple IAP modules in lib/payments/iap/apple-app-store including classes, their methods and types, largely modeled after the Google Play IAP modules. * Adds App Store config (under config.subscriptions.appStore) including a sandbox flag and App Store credentials by app bundleId. * Moves lib/payments/google-play to lib/payments/iap/google-play. * Moves IAPConfig class to the new shared lib/payments/iap dir. * Adds two new libraries to the auth server: app-store-server-api (App Store Server API client) and type-fest (for helpful TS types, such as RequireAtLeastOne used here). Closes #10313, closes #12579 --- packages/fxa-auth-server/bin/key_server.js | 2 +- packages/fxa-auth-server/config/index.ts | 22 + .../lib/payments/capability.ts | 6 +- .../iap/apple-app-store/app-store-helper.ts | 78 +++ .../payments/iap/apple-app-store/apple-iap.ts | 29 + .../payments/iap/apple-app-store/errors.ts | 17 + .../lib/payments/iap/apple-app-store/index.ts | 5 + .../iap/apple-app-store/purchase-manager.ts | 296 ++++++++++ .../apple-app-store/subscription-purchase.ts | 208 +++++++ .../iap/apple-app-store/types/errors.ts | 20 + .../iap/apple-app-store/types/index.ts | 4 + .../payments/{ => iap}/google-play/index.ts | 0 .../{ => iap}/google-play/play-billing.ts | 39 +- .../{ => iap}/google-play/purchase-manager.ts | 2 +- .../google-play/subscription-purchase.ts | 0 .../{ => iap}/google-play/subscriptions.ts | 6 +- .../{ => iap}/google-play/types/errors.ts | 0 .../{ => iap}/google-play/types/index.ts | 1 - .../google-play/types/notifications.ts | 0 .../{ => iap}/google-play/types/purchases.ts | 0 .../{ => iap}/google-play/user-manager.ts | 2 +- .../lib/payments/iap/iap-config.ts | 65 +++ .../{google-play => iap}/types/firestore.ts | 15 +- .../lib/payments/iap/types/index.ts | 5 + .../fxa-auth-server/lib/payments/stripe.ts | 2 +- .../fxa-auth-server/lib/routes/account.ts | 2 +- .../lib/routes/subscriptions/google.ts | 13 +- .../lib/routes/subscriptions/mozilla.ts | 2 +- .../lib/routes/subscriptions/play-pubsub.ts | 4 +- .../lib/routes/support-panel.ts | 2 +- packages/fxa-auth-server/package.json | 2 + .../test/local/payments/capability.js | 6 +- .../iap/apple-app-store/app-store-helper.js | 144 +++++ .../payments/iap/apple-app-store/apple-iap.js | 69 +++ .../iap/apple-app-store/purchase-manager.js | 519 ++++++++++++++++++ .../apple-app-store/subscription-purchase.js | 198 +++++++ .../payments/iap/google-play/play-billing.js | 65 +++ .../{ => iap}/google-play/purchase-manager.js | 8 +- .../google-play/subscription-purchase.js | 6 +- .../{ => iap}/google-play/subscriptions.js | 8 +- .../{ => iap}/google-play/user-manager.js | 10 +- .../play-billing.js => iap/iap-config.js} | 86 ++- .../test/local/payments/stripe.js | 2 +- .../test/local/routes/account.js | 2 +- .../test/local/routes/subscriptions/google.js | 36 +- .../test/local/routes/subscriptions/paypal.js | 2 +- .../local/routes/subscriptions/play-pubsub.js | 2 +- .../test/local/routes/subscriptions/stripe.js | 2 +- packages/fxa-auth-server/test/mocks.js | 2 +- .../test/remote/subscription_tests.js | 2 +- yarn.lock | 29 +- 51 files changed, 1917 insertions(+), 130 deletions(-) create mode 100644 packages/fxa-auth-server/lib/payments/iap/apple-app-store/app-store-helper.ts create mode 100644 packages/fxa-auth-server/lib/payments/iap/apple-app-store/apple-iap.ts create mode 100644 packages/fxa-auth-server/lib/payments/iap/apple-app-store/errors.ts create mode 100644 packages/fxa-auth-server/lib/payments/iap/apple-app-store/index.ts create mode 100644 packages/fxa-auth-server/lib/payments/iap/apple-app-store/purchase-manager.ts create mode 100644 packages/fxa-auth-server/lib/payments/iap/apple-app-store/subscription-purchase.ts create mode 100644 packages/fxa-auth-server/lib/payments/iap/apple-app-store/types/errors.ts create mode 100644 packages/fxa-auth-server/lib/payments/iap/apple-app-store/types/index.ts rename packages/fxa-auth-server/lib/payments/{ => iap}/google-play/index.ts (100%) rename packages/fxa-auth-server/lib/payments/{ => iap}/google-play/play-billing.ts (59%) rename packages/fxa-auth-server/lib/payments/{ => iap}/google-play/purchase-manager.ts (99%) rename packages/fxa-auth-server/lib/payments/{ => iap}/google-play/subscription-purchase.ts (100%) rename packages/fxa-auth-server/lib/payments/{ => iap}/google-play/subscriptions.ts (94%) rename packages/fxa-auth-server/lib/payments/{ => iap}/google-play/types/errors.ts (100%) rename packages/fxa-auth-server/lib/payments/{ => iap}/google-play/types/index.ts (91%) rename packages/fxa-auth-server/lib/payments/{ => iap}/google-play/types/notifications.ts (100%) rename packages/fxa-auth-server/lib/payments/{ => iap}/google-play/types/purchases.ts (100%) rename packages/fxa-auth-server/lib/payments/{ => iap}/google-play/user-manager.ts (98%) create mode 100644 packages/fxa-auth-server/lib/payments/iap/iap-config.ts rename packages/fxa-auth-server/lib/payments/{google-play => iap}/types/firestore.ts (69%) create mode 100644 packages/fxa-auth-server/lib/payments/iap/types/index.ts create mode 100644 packages/fxa-auth-server/test/local/payments/iap/apple-app-store/app-store-helper.js create mode 100644 packages/fxa-auth-server/test/local/payments/iap/apple-app-store/apple-iap.js create mode 100644 packages/fxa-auth-server/test/local/payments/iap/apple-app-store/purchase-manager.js create mode 100644 packages/fxa-auth-server/test/local/payments/iap/apple-app-store/subscription-purchase.js create mode 100644 packages/fxa-auth-server/test/local/payments/iap/google-play/play-billing.js rename packages/fxa-auth-server/test/local/payments/{ => iap}/google-play/purchase-manager.js (98%) rename packages/fxa-auth-server/test/local/payments/{ => iap}/google-play/subscription-purchase.js (97%) rename packages/fxa-auth-server/test/local/payments/{ => iap}/google-play/subscriptions.js (95%) rename packages/fxa-auth-server/test/local/payments/{ => iap}/google-play/user-manager.js (91%) rename packages/fxa-auth-server/test/local/payments/{google-play/play-billing.js => iap/iap-config.js} (59%) diff --git a/packages/fxa-auth-server/bin/key_server.js b/packages/fxa-auth-server/bin/key_server.js index 6c696248b57..9568b745f2e 100755 --- a/packages/fxa-auth-server/bin/key_server.js +++ b/packages/fxa-auth-server/bin/key_server.js @@ -9,7 +9,7 @@ const jwtool = require('fxa-jwtool'); const { StatsD } = require('hot-shots'); const { Container } = require('typedi'); const { StripeHelper } = require('../lib/payments/stripe'); -const { PlayBilling } = require('../lib/payments/google-play'); +const { PlayBilling } = require('../lib/payments/iap/google-play'); const { CurrencyHelper } = require('../lib/payments/currencies'); const { AuthLogger, diff --git a/packages/fxa-auth-server/config/index.ts b/packages/fxa-auth-server/config/index.ts index 6584cdd35e0..9e2fe6f8a2e 100644 --- a/packages/fxa-auth-server/config/index.ts +++ b/packages/fxa-auth-server/config/index.ts @@ -780,6 +780,28 @@ const conf = convict({ env: 'PAYPAL_NVP_SIGNATURE', }, }, + appStore: { + credentials: { + doc: 'Map of AppStore Connect credentials by app bundle ID', + format: Object, + default: { + // Cannot use an actual bundleId (e.g. 'org.mozilla.ios.FirefoxVPN') as the key + // due to https://github.com/mozilla/node-convict/issues/250 + org_mozilla_ios_FirefoxVPN: { + issuerId: 'issuer_id', + serverApiKey: 'key', + serverApiKeyId: 'key_id', + }, + }, + env: 'APP_STORE_CREDENTIALS', + }, + sandbox: { + doc: 'Apple App Store Sandbox mode', + format: Boolean, + env: 'APP_STORE_SANDBOX', + default: true, + }, + }, playApiServiceAccount: { credentials: { client_email: { diff --git a/packages/fxa-auth-server/lib/payments/capability.ts b/packages/fxa-auth-server/lib/payments/capability.ts index 20189670295..1e7e01f57d7 100644 --- a/packages/fxa-auth-server/lib/payments/capability.ts +++ b/packages/fxa-auth-server/lib/payments/capability.ts @@ -11,9 +11,9 @@ import { commaSeparatedListToArray } from './utils'; import error from '../error'; import { authEvents } from '../events'; import { AuthLogger, AuthRequest, ProfileClient } from '../types'; -import { PlayBilling } from './google-play/play-billing'; -import { SubscriptionPurchase } from './google-play/subscription-purchase'; -import { PurchaseQueryError } from './google-play/types'; +import { PlayBilling } from './iap/google-play/play-billing'; +import { SubscriptionPurchase } from './iap/google-play/subscription-purchase'; +import { PurchaseQueryError } from './iap/google-play/types'; import { StripeHelper } from './stripe'; function hex(blob: Buffer | string): string { diff --git a/packages/fxa-auth-server/lib/payments/iap/apple-app-store/app-store-helper.ts b/packages/fxa-auth-server/lib/payments/iap/apple-app-store/app-store-helper.ts new file mode 100644 index 00000000000..ed5789f7498 --- /dev/null +++ b/packages/fxa-auth-server/lib/payments/iap/apple-app-store/app-store-helper.ts @@ -0,0 +1,78 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import { + AppStoreServerAPI, + Environment, + StatusResponse, +} from 'app-store-server-api'; +import { Container } from 'typedi'; + +import { AppConfig, AuthLogger } from '../../../types'; +import { AppStoreHelperError } from './types/errors'; + +export class AppStoreHelper { + private log: AuthLogger; + private appStoreServerApiClients: { + [key: string]: AppStoreServerAPI; + }; + private credentialsByBundleId: any; + private environment: Environment; + + constructor() { + this.log = Container.get(AuthLogger); + const { + subscriptions: { appStore }, + } = Container.get(AppConfig); + this.credentialsByBundleId = {}; + // Initialize App Store Server API client per bundle ID + this.environment = appStore.sandbox + ? Environment.Sandbox + : Environment.Production; + this.appStoreServerApiClients = {}; + for (const [bundleIdWithUnderscores, credentials] of Object.entries( + appStore.credentials + )) { + // Cannot use an actual bundleId (e.g. 'org.mozilla.ios.FirefoxVPN') as the key + // due to https://github.com/mozilla/node-convict/issues/250 + const bundleId = bundleIdWithUnderscores.replace('_', '.'); + this.credentialsByBundleId[bundleId] = credentials; + this.clientByBundleId(bundleId); + } + } + + /** + * Returns an App Store Server API client by bundleId, initializing it first + * if needed. + */ + clientByBundleId(bundleId: string): AppStoreServerAPI { + if (this.appStoreServerApiClients.hasOwnProperty(bundleId)) { + return this.appStoreServerApiClients[bundleId]; + } + if (!this.credentialsByBundleId.hasOwnProperty(bundleId)) { + const libraryError = new Error( + `No App Store credentials found for app with bundleId: ${bundleId}.` + ); + libraryError.name = AppStoreHelperError.CREDENTIALS_NOT_FOUND; + throw libraryError; + } + const { serverApiKey, serverApiKeyId, issuerId } = + this.credentialsByBundleId[bundleId]; + this.appStoreServerApiClients[bundleId] = new AppStoreServerAPI( + serverApiKey, + serverApiKeyId, + issuerId, + bundleId, + this.environment + ); + return this.appStoreServerApiClients[bundleId]; + } + + async getSubscriptionStatuses( + bundleId: string, + originalTransactionId: string + ): Promise { + const apiClient = this.clientByBundleId(bundleId); + return apiClient.getSubscriptionStatuses(originalTransactionId); + } +} diff --git a/packages/fxa-auth-server/lib/payments/iap/apple-app-store/apple-iap.ts b/packages/fxa-auth-server/lib/payments/iap/apple-app-store/apple-iap.ts new file mode 100644 index 00000000000..8666e2ed560 --- /dev/null +++ b/packages/fxa-auth-server/lib/payments/iap/apple-app-store/apple-iap.ts @@ -0,0 +1,29 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import { Firestore } from '@google-cloud/firestore'; +import { Container } from 'typedi'; + +import { AppConfig, AuthFirestore, AuthLogger } from '../../../types'; +import { AppStoreHelper } from './app-store-helper'; +import { PurchaseManager } from './purchase-manager'; + +export class AppleIAP { + private firestore: Firestore; + private log: AuthLogger; + private prefix: string; + + public purchaseManager: PurchaseManager; + constructor() { + this.log = Container.get(AuthLogger); + const appStoreHelper = new AppStoreHelper(); + + this.firestore = Container.get(AuthFirestore); + const { authFirestore } = Container.get(AppConfig); + this.prefix = `${authFirestore.prefix}iap-`; + const purchasesDbRef = this.firestore.collection( + `${this.prefix}app-store-purchases` + ); + this.purchaseManager = new PurchaseManager(purchasesDbRef, appStoreHelper); + } +} diff --git a/packages/fxa-auth-server/lib/payments/iap/apple-app-store/errors.ts b/packages/fxa-auth-server/lib/payments/iap/apple-app-store/errors.ts new file mode 100644 index 00000000000..b83e2095ac5 --- /dev/null +++ b/packages/fxa-auth-server/lib/payments/iap/apple-app-store/errors.ts @@ -0,0 +1,17 @@ +/** + * Error Codes representing an error that is temporary to Apple + * and should be retried again without changes. + * https://developer.apple.com/documentation/appstoreserverapi/error_codes + */ +export const APP_STORE_RETRY_ERRORS = [4040002, 4040004, 5000001, 4040006]; + +export class AppStoreRetryableError extends Error { + public errorCode: number; + public errorMessage: string; + + constructor(errorCode: number, errorMessage: string) { + super(errorMessage); + this.name = 'AppStoreRetryableError'; + this.errorCode = errorCode; + } +} diff --git a/packages/fxa-auth-server/lib/payments/iap/apple-app-store/index.ts b/packages/fxa-auth-server/lib/payments/iap/apple-app-store/index.ts new file mode 100644 index 00000000000..3fcf74b26fa --- /dev/null +++ b/packages/fxa-auth-server/lib/payments/iap/apple-app-store/index.ts @@ -0,0 +1,5 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +export { AppleIAP } from './apple-iap'; diff --git a/packages/fxa-auth-server/lib/payments/iap/apple-app-store/purchase-manager.ts b/packages/fxa-auth-server/lib/payments/iap/apple-app-store/purchase-manager.ts new file mode 100644 index 00000000000..a6576a1336b --- /dev/null +++ b/packages/fxa-auth-server/lib/payments/iap/apple-app-store/purchase-manager.ts @@ -0,0 +1,296 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import { CollectionReference } from '@google-cloud/firestore'; +import { + decodeRenewalInfo, + decodeTransaction, + TransactionType, +} from 'app-store-server-api'; +import Container from 'typedi'; + +import { AuthLogger } from '../../../types'; +import { AppStoreHelper } from './app-store-helper'; +import { + APPLE_APP_STORE_FORM_OF_PAYMENT, + mergePurchaseWithFirestorePurchaseRecord, + SubscriptionPurchase, +} from './subscription-purchase'; +import { PurchaseQueryError, PurchaseUpdateError } from './types'; + +/** + * This class wraps Firestore API calls for Apple IAP purchases. It + * will handle related CRUD operations. + * This is using the V2 App Store Server API implementation: + * https://developer.apple.com/documentation/appstoreserverapi + */ +export class PurchaseManager { + private appStoreHelper: AppStoreHelper; + private log: AuthLogger; + private purchasesDbRef: CollectionReference; + + /* + * This class is intended to be initialized by the library. + * Library consumer should not initialize this class themselves. + */ + constructor( + purchasesDbRef: CollectionReference, + appStoreHelper: AppStoreHelper + ) { + this.appStoreHelper = appStoreHelper; + this.log = Container.get(AuthLogger); + this.purchasesDbRef = purchasesDbRef; + } + + /* + * Query a subscription purchase by its bundle ID and original transaction ID. + * The method queries the V2 App Store Server API to get the latest status of the purchase, + * then merge it with purchase ownership info stored in the library's managed Firestore database, + * then returns the merged information as a SubscriptionPurchase to its caller. + * A brand new subscription will not yet have a defined userId; + * that's handled by registerToUserAccount. + */ + public async querySubscriptionPurchase( + bundleId: string, + originalTransactionId: string + ) { + // STEP 1. Query App Store Server API to get the subscription status + let apiResponse; + let subscriptionStatus; + let transactionInfo; + let renewalInfo; + try { + apiResponse = await this.appStoreHelper.getSubscriptionStatuses( + bundleId, + originalTransactionId + ); + // Find the latest transaction for the subscription + const item = apiResponse.data[0].lastTransactions.find( + (item) => item.originalTransactionId === originalTransactionId + ); + subscriptionStatus = item.status; + // FIXME: improve performance; see https://mozilla-hub.atlassian.net/browse/FXA-4949 + transactionInfo = await decodeTransaction(item.signedTransactionInfo); + renewalInfo = await decodeRenewalInfo(item.signedRenewalInfo); + } catch (err) { + throw this.convertAppStoreAPIErrorToLibraryError(err); + } + + try { + // STEP 2. Look up purchase records from Firestore that matches this original transaction ID + const purchaseRecordDoc = await this.purchasesDbRef + .doc(originalTransactionId) + .get(); + + // Generate SubscriptionPurchase object from API response + const now = Date.now(); + const subscriptionPurchase = SubscriptionPurchase.fromApiResponse( + apiResponse, + subscriptionStatus, + transactionInfo, + renewalInfo, + originalTransactionId, + now + ); + + // Convert subscriptionPurchase object to a format that to be stored in Firestore + const firestoreObject = subscriptionPurchase.toFirestoreObject(); + + if (purchaseRecordDoc.exists) { + // STEP 3a. We have this purchase cached in Firestore. Update our cache with the newly received response from the App Store Server API + await purchaseRecordDoc.ref.update(firestoreObject); + + // STEP 4a. Merge other fields of our purchase record in Firestore (such as userId) with our SubscriptionPurchase object and return to caller. + mergePurchaseWithFirestorePurchaseRecord( + subscriptionPurchase, + purchaseRecordDoc.data() + ); + return subscriptionPurchase; + } else { + // STEP 3b. This is a brand new subscription purchase. Just save the purchase record to Firestore + await purchaseRecordDoc.ref.set(firestoreObject); + + // STEP 4. This is a brand new subscription purchase. Just save the purchase record to Firestore and return an SubscriptionPurchase object with userId undefined. + return subscriptionPurchase; + } + } catch (err) { + // Some unexpected error has occurred while interacting with Firestore. + const libraryError = new Error(err.message); + libraryError.name = PurchaseQueryError.OTHER_ERROR; + throw libraryError; + } + } + + /* + * Force register a purchase to an user. + * This method is not intended to be called from outside of the library. + */ + private async forceRegisterToUserAccount( + originalTransactionId: string, + userId: string + ): Promise { + try { + await this.purchasesDbRef + .doc(originalTransactionId) + .update({ userId: userId }); + } catch (err) { + const libraryError = new Error(err.message); + libraryError.name = PurchaseUpdateError.OTHER_ERROR; + throw libraryError; + } + } + + /** + * Get purchase record from Firestore. + */ + public async getSubscriptionPurchase(originalTransactionId: string) { + const purchaseRecordDoc = await this.purchasesDbRef + .doc(originalTransactionId) + .get(); + if (purchaseRecordDoc.exists) { + return SubscriptionPurchase.fromFirestoreObject(purchaseRecordDoc.data()); + } + return; + } + + /** + * Register a purchase (autorenewing subscription) to a user. + * It's intended to be exposed to the iOS app to verify purchases made in the app. + */ + async registerToUserAccount( + bundleId: string, + originalTransactionId: string, + userId: string + ) { + // STEP 1. Check if the purchase record is already in Firestore + let purchase = await this.getSubscriptionPurchase(originalTransactionId); + if (!purchase) { + // STEP 1b. Query App Store Server API to verify the purchase + try { + purchase = await this.querySubscriptionPurchase( + bundleId, + originalTransactionId + ); + } catch (err) { + // Error when attempt to query purchase. Return not found error to caller. + const libraryError = new Error(err.message); + libraryError.name = PurchaseUpdateError.INVALID_ORIGINAL_TRANSACTION_ID; + throw libraryError; + } + } + // Unlike Google Play subscriptions, We don't need to check if the purchase is + // registerable (i.e. not terminal), since the original transaction ID is + // the primary key in Firestore, and it can be "revived at any time if the customer + // returns to any SKU in that subscription group". + // See https://developer.apple.com/forums/thread/704167. + + // STEP 2. Check if the purchase has been registered to a user. If it is, then return conflict error to our caller. + if (purchase.userId === userId) { + // Purchase record already registered to the target user. We'll do nothing. + return purchase; + } else if (purchase.userId) { + this.log.info('Purchase already registered', { purchase }); + // Purchase record already registered to different user. Return 'conflict' to caller + const libraryError = new Error( + 'Purchase has been registered to another user' + ); + libraryError.name = PurchaseUpdateError.CONFLICT; + throw libraryError; + } + + // STEP 3: Register purchase to the user + await this.forceRegisterToUserAccount(originalTransactionId, userId); + + return purchase; + } + + /* + * Query subscriptions registered to a particular user that are active. + * Note: Other subscriptions which don't meet the above criteria still exists in Firestore purchase records, but are not accessible from outside of the library. + */ + async queryCurrentSubscriptionPurchases( + userId: string, + bundleId?: string, + productId?: string + ): Promise> { + const purchaseList = new Array(); + + try { + // Create query to fetch possibly active subscriptions from Firestore + let query = this.purchasesDbRef + .where('formOfPayment', '==', APPLE_APP_STORE_FORM_OF_PAYMENT) + .where('type', '==', TransactionType.AutoRenewableSubscription) + .where('userId', '==', userId); + + if (productId) { + query = query.where('productId', '==', productId); + } + + if (bundleId) { + query = query.where('bundleId', '==', bundleId); + } + + // Do fetch possibly active subscription from Firestore + const queryResult = await query.get(); + + // Loop through these subscriptions and filter those that are indeed active + for (const purchaseRecordSnapshot of queryResult.docs) { + let purchase: SubscriptionPurchase = + SubscriptionPurchase.fromFirestoreObject( + purchaseRecordSnapshot.data() + ); + + if (!purchase.isEntitlementActive()) { + // Unlike inactive Google Play subscriptions, which get replaced and + // deregistered, App Store subscriptions can be revived at any time + // (see https://developer.apple.com/forums/thread/704167). + // This means we could be hitting Apple here for any past, inactive + // subscriptions the user has, which could generate a lot of requests + // to Apple via querySubscriptionPurchase. + // However, since anything they buy in the same subscription group will + // get the same original transaction ID, the number of requests is + // limited to the number of different iOS products (N = 1 currently), + // and we already minimize calls to this method by caching the results + // of the capability service in the profile server, so this extra request + // per subscription is not currently a performance concern. + this.log.info('queryCurrentSubscriptionPurchases.cache.update', { + originalTransactionId: purchase.originalTransactionId, + }); + purchase = await this.querySubscriptionPurchase( + purchase.bundleId, + purchase.originalTransactionId + ); + } + + // Add the updated purchase to list to returned to clients + if (purchase.isEntitlementActive()) { + purchaseList.push(purchase); + } + } + + return purchaseList; + } catch (err) { + this.log.error('queryCurrentSubscriptions.firestoreFetch', { err }); + const libraryError = new Error(err.message); + libraryError.name = PurchaseQueryError.OTHER_ERROR; + throw libraryError; + } + } + + // See Response Codes section at https://developer.apple.com/documentation/appstoreserverapi/get_all_subscription_statuses and + // https://developer.apple.com/documentation/appstoreserverapi/error_codes + convertAppStoreAPIErrorToLibraryError(appStoreError: any): Error { + const libraryError = new Error(appStoreError.message); + if (appStoreError.code === 404) { + // The account, app or original transaction ID was not found. + libraryError.name = PurchaseQueryError.NOT_FOUND; + } else { + // Unexpected error occurred. + libraryError.name = PurchaseQueryError.OTHER_ERROR; + this.log.error('convertAppStoreAPIErrorToLibraryError', { + message: 'Unexpected error when querying the App Store Server API.', + }); + } + return libraryError; + } +} diff --git a/packages/fxa-auth-server/lib/payments/iap/apple-app-store/subscription-purchase.ts b/packages/fxa-auth-server/lib/payments/iap/apple-app-store/subscription-purchase.ts new file mode 100644 index 00000000000..0520af10085 --- /dev/null +++ b/packages/fxa-auth-server/lib/payments/iap/apple-app-store/subscription-purchase.ts @@ -0,0 +1,208 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import { + AutoRenewStatus, + Environment, + JWSRenewalInfoDecodedPayload, + JWSTransactionDecodedPayload, + LastTransactionsItem, + OfferType, + OwnershipType, + StatusResponse, + SubscriptionStatus, + TransactionType, +} from 'app-store-server-api'; + +const FIRESTORE_OBJECT_INTERNAL_KEYS = ['formOfPayment']; +export const APPLE_APP_STORE_FORM_OF_PAYMENT = 'APPLE_APP_STORE'; + +export const SUBSCRIPTION_PURCHASE_REQUIRED_PROPERTIES = [ + 'autoRenewStatus', + 'autoRenewProductId', + 'bundleId', + 'environment', + 'inAppOwnershipType', + 'originalPurchaseDate', + 'originalTransactionId', + 'productId', + 'status', + 'type', + 'verifiedAt', +]; + +/** + * This file contains internal implementation of classes and utilities that + * is only used inside of the library. + * + * This module uses the V2 App Store Server API implementation: + * https://developer.apple.com/documentation/appstoreserverapi + */ + +/* Convert a purchase object into a format that will be store in Firestore + * Adds some shopkeeping metadata to the purchase object. + */ +function purchaseToFirestoreObject(purchase: SubscriptionPurchase): any { + const fObj: any = {}; + Object.assign(fObj, purchase); + fObj.formOfPayment = APPLE_APP_STORE_FORM_OF_PAYMENT; + return fObj; +} + +/* Merge a purchase object, which is created from App Store Server API response, + * with a purchase record of the same object stored in Firestore. + * The purchase object generated from the API response doesn't contain info of + * purchase ownership (which user owns the product), while the record from + * Firestore can be outdated, so we want to merge the objects to create an + * updated representation of a purchase. We only skip our internal shopkeeping + * metadata that the library consumer doesn't have to worry about. + */ +export function mergePurchaseWithFirestorePurchaseRecord( + purchase: any, + firestoreObject: any +) { + // Copy all keys that exist in Firestore but not in Purchase object to the Purchase object. + Object.keys(firestoreObject).map((key) => { + // Skip the internal key-value pairs assigned by purchaseToFirestoreObject() + if ( + purchase[key] === undefined && + FIRESTORE_OBJECT_INTERNAL_KEYS.indexOf(key) === -1 + ) { + purchase[key] = firestoreObject[key]; + } + }); +} + +/* Library's internal implementation of a SubscriptionPurchase object + * It's used inside of the library, not to be exposed to library's consumers. + */ +export class SubscriptionPurchase { + // Response from App Store API server Subscription Status endpoint + // https://developer.apple.com/documentation/appstoreserverapi/get_all_subscription_statuses + // IMPORTANT: If adding a new required property, also add it to SUBSCRIPTION_PURCHASE_REQUIRED_PROPERTIES + private autoRenewStatus: AutoRenewStatus; + private autoRenewProductId: string; + bundleId: string; // unique identifier for the iOS app; analogous to a Stripe product id + private environment: Environment; + private inAppOwnershipType: OwnershipType; + private originalPurchaseDate: number; + originalTransactionId: string; // unique identifier for the subscription; analogous to a Stripe subscription id + private productId: string; // unique identifier for the plan; analogous to the Stripe plan id + private status: SubscriptionStatus; + private type: TransactionType; + private expirationIntent?: number; + private expiresDate?: number; + private gracePeriodExpiresDate?: number; + private isInBillingRetry?: boolean; + private isUpgraded?: boolean; + private offerType?: OfferType; + private offerIdentifier?: string; + private revocationDate?: number; + private revocationReason?: number; + + // Library-managed properties + userId?: string; // hex string for FxA user id + private verifiedAt: number; // timestamp of last purchase verification by App Store Server API + + // Convert raw API response from App Store Server API to a SubscriptionPurchase object + static fromApiResponse( + apiResponse: StatusResponse, + subscriptionStatus: LastTransactionsItem['status'], + transactionInfo: JWSTransactionDecodedPayload, + renewalInfo: JWSRenewalInfoDecodedPayload, + originalTransactionId: string, + verifiedAt: number + ): SubscriptionPurchase { + const purchase = new SubscriptionPurchase(); + purchase.autoRenewStatus = renewalInfo.autoRenewStatus; + purchase.autoRenewProductId = renewalInfo.autoRenewProductId; + purchase.bundleId = apiResponse.bundleId; + purchase.environment = apiResponse.environment; + purchase.inAppOwnershipType = transactionInfo.inAppOwnershipType; + purchase.originalPurchaseDate = transactionInfo.originalPurchaseDate; + purchase.originalTransactionId = originalTransactionId; + purchase.productId = transactionInfo.productId; + purchase.status = subscriptionStatus; + purchase.type = transactionInfo.type; + purchase.verifiedAt = verifiedAt; + + if (renewalInfo.expirationIntent) { + purchase.expirationIntent = renewalInfo.expirationIntent; + } + if (transactionInfo.expiresDate) { + purchase.expiresDate = transactionInfo.expiresDate; + } + if (renewalInfo.gracePeriodExpiresDate) { + purchase.gracePeriodExpiresDate = renewalInfo.gracePeriodExpiresDate; + } + if (renewalInfo.hasOwnProperty('isInBillingRetryPeriod')) { + // We don't check this.status === SubscriptionStatus.InBillingRetry, since + // it's not mutually exclusive with other subscription states (i.e. + // SubscriptionStatus.InBillingGracePeriod). + purchase.isInBillingRetry = renewalInfo.isInBillingRetryPeriod; + } + if (transactionInfo.hasOwnProperty('isUpgraded')) { + purchase.isUpgraded = transactionInfo.isUpgraded; + } + if (renewalInfo.offerIdentifier) { + purchase.offerIdentifier = renewalInfo.offerIdentifier; + } + if (renewalInfo.offerType) { + purchase.offerType = renewalInfo.offerType; + } + if (transactionInfo.revocationDate) { + purchase.revocationDate = transactionInfo.revocationDate; + } + if (transactionInfo.hasOwnProperty('revocationReason')) { + purchase.revocationReason = transactionInfo.revocationReason; + } + + return purchase; + } + + /** + * Converts purchase data from a Firestore document to a SubscriptionPurchase. + * In particular, there are some shopkeeping properties that are only used in + * Firestore; see FIRESTORE_OBJECT_INTERNAL_KEYS. + */ + static fromFirestoreObject(firestoreObject: any) { + const purchase = new SubscriptionPurchase(); + purchase.mergeWithFirestorePurchaseRecord(firestoreObject); + return purchase; + } + + toFirestoreObject(): any { + return purchaseToFirestoreObject(this); + } + + mergeWithFirestorePurchaseRecord(firestoreObject: any) { + mergePurchaseWithFirestorePurchaseRecord(this, firestoreObject); + } + + isEntitlementActive() { + return [ + SubscriptionStatus.Active, + SubscriptionStatus.InBillingGracePeriod, + ].includes(this.status); + } + + willRenew() { + return this.autoRenewStatus === AutoRenewStatus.On; + } + + isInBillingRetryPeriod() { + return this.isInBillingRetry; + } + + isInGracePeriod() { + return this.status === SubscriptionStatus.InBillingGracePeriod; + } + + isTestPurchase(): boolean { + return this.environment === Environment.Sandbox; + } + + isFreeTrial(): boolean { + return this.offerType === OfferType.Introductory; + } +} diff --git a/packages/fxa-auth-server/lib/payments/iap/apple-app-store/types/errors.ts b/packages/fxa-auth-server/lib/payments/iap/apple-app-store/types/errors.ts new file mode 100644 index 00000000000..74f905964d3 --- /dev/null +++ b/packages/fxa-auth-server/lib/payments/iap/apple-app-store/types/errors.ts @@ -0,0 +1,20 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Possile errors when attempt to query a purchase using the App Store Server API and purchase records stored in Firestore +export enum PurchaseQueryError { + NOT_FOUND = 'NotFound', + OTHER_ERROR = 'OtherError', +} + +// Possile errors when attempt to register a purchase to a user +export enum PurchaseUpdateError { + CONFLICT = 'Conflict', + INVALID_ORIGINAL_TRANSACTION_ID = 'InvalidOriginalTransactionId', + OTHER_ERROR = 'OtherError', +} + +export enum AppStoreHelperError { + CREDENTIALS_NOT_FOUND = 'CredentialsNotFound', +} diff --git a/packages/fxa-auth-server/lib/payments/iap/apple-app-store/types/index.ts b/packages/fxa-auth-server/lib/payments/iap/apple-app-store/types/index.ts new file mode 100644 index 00000000000..8d6d83e6ed7 --- /dev/null +++ b/packages/fxa-auth-server/lib/payments/iap/apple-app-store/types/index.ts @@ -0,0 +1,4 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +export { PurchaseQueryError, PurchaseUpdateError } from './errors'; diff --git a/packages/fxa-auth-server/lib/payments/google-play/index.ts b/packages/fxa-auth-server/lib/payments/iap/google-play/index.ts similarity index 100% rename from packages/fxa-auth-server/lib/payments/google-play/index.ts rename to packages/fxa-auth-server/lib/payments/iap/google-play/index.ts diff --git a/packages/fxa-auth-server/lib/payments/google-play/play-billing.ts b/packages/fxa-auth-server/lib/payments/iap/google-play/play-billing.ts similarity index 59% rename from packages/fxa-auth-server/lib/payments/google-play/play-billing.ts rename to packages/fxa-auth-server/lib/payments/iap/google-play/play-billing.ts index 0ce049e2ba1..a3fc789986f 100644 --- a/packages/fxa-auth-server/lib/payments/google-play/play-billing.ts +++ b/packages/fxa-auth-server/lib/payments/iap/google-play/play-billing.ts @@ -4,29 +4,20 @@ import { Firestore } from '@google-cloud/firestore'; import { Auth, google } from 'googleapis'; import { Container } from 'typedi'; -import { TypedCollectionReference } from 'typesafe-node-firestore'; -import { AppConfig, AuthFirestore, AuthLogger } from '../../types'; +import { AppConfig, AuthFirestore, AuthLogger } from '../../../types'; import { PurchaseManager } from './purchase-manager'; -import { IapConfig } from './types'; import { UserManager } from './user-manager'; export class PlayBilling { private firestore: Firestore; private log: AuthLogger; private prefix: string; - private iapConfigDbRef: TypedCollectionReference; - public purchaseManager: PurchaseManager; public userManager: UserManager; constructor() { const config = Container.get(AppConfig); - this.prefix = `${config.authFirestore.prefix}iap-`; - this.firestore = Container.get(AuthFirestore); - this.iapConfigDbRef = this.firestore.collection( - `${config.authFirestore.prefix}iap-config` - ) as TypedCollectionReference; this.log = Container.get(AuthLogger); // Initialize Google Play Developer API client @@ -42,6 +33,8 @@ export class PlayBilling { version: 'v3', auth: new Auth.JWT(authConfig), }); + this.prefix = `${config.authFirestore.prefix}iap-`; + this.firestore = Container.get(AuthFirestore); const purchasesDbRef = this.firestore.collection( `${this.prefix}play-purchases` ); @@ -51,30 +44,4 @@ export class PlayBilling { ); this.userManager = new UserManager(purchasesDbRef, this.purchaseManager); } - - /** - * Fetch the Google plan object for Android client usage. - */ - public async plans(appName: string) { - // TODO: use a cached version of the iap config - const doc = await this.iapConfigDbRef.doc(appName).get(); - if (doc.exists) { - return doc.data()?.plans; - } else { - throw Error(`IAP Plans document does not exist for ${appName}`); - } - } - - /** - * Fetch the Google Play packageName for the given appName. - */ - public async packageName(appName: string) { - // TODO: use a cached version of the iap config - const doc = await this.iapConfigDbRef.doc(appName).get(); - if (doc.exists) { - return doc.data()?.packageName; - } else { - throw Error(`IAP Plans document does not exist for ${appName}`); - } - } } diff --git a/packages/fxa-auth-server/lib/payments/google-play/purchase-manager.ts b/packages/fxa-auth-server/lib/payments/iap/google-play/purchase-manager.ts similarity index 99% rename from packages/fxa-auth-server/lib/payments/google-play/purchase-manager.ts rename to packages/fxa-auth-server/lib/payments/iap/google-play/purchase-manager.ts index 0355692da27..68aaa6b52cb 100644 --- a/packages/fxa-auth-server/lib/payments/google-play/purchase-manager.ts +++ b/packages/fxa-auth-server/lib/payments/iap/google-play/purchase-manager.ts @@ -20,7 +20,7 @@ import { CollectionReference } from '@google-cloud/firestore'; import { androidpublisher_v3 } from 'googleapis'; import Container from 'typedi'; -import { AuthLogger } from '../../types'; +import { AuthLogger } from '../../../types'; import { mergePurchaseWithFirestorePurchaseRecord, SubscriptionPurchase, diff --git a/packages/fxa-auth-server/lib/payments/google-play/subscription-purchase.ts b/packages/fxa-auth-server/lib/payments/iap/google-play/subscription-purchase.ts similarity index 100% rename from packages/fxa-auth-server/lib/payments/google-play/subscription-purchase.ts rename to packages/fxa-auth-server/lib/payments/iap/google-play/subscription-purchase.ts diff --git a/packages/fxa-auth-server/lib/payments/google-play/subscriptions.ts b/packages/fxa-auth-server/lib/payments/iap/google-play/subscriptions.ts similarity index 94% rename from packages/fxa-auth-server/lib/payments/google-play/subscriptions.ts rename to packages/fxa-auth-server/lib/payments/iap/google-play/subscriptions.ts index 271813b1e47..f8d409bb1fc 100644 --- a/packages/fxa-auth-server/lib/payments/google-play/subscriptions.ts +++ b/packages/fxa-auth-server/lib/payments/iap/google-play/subscriptions.ts @@ -8,9 +8,9 @@ import { } from 'fxa-shared/subscriptions/types'; import Container from 'typedi'; -import { internalValidationError } from '../../../lib/error'; -import { AppConfig } from '../../types'; -import { StripeHelper } from '../stripe'; +import { internalValidationError } from '../../../../lib/error'; +import { AppConfig } from '../../../types'; +import { StripeHelper } from '../../stripe'; import { PlayBilling } from './play-billing'; import { SubscriptionPurchase } from './subscription-purchase'; diff --git a/packages/fxa-auth-server/lib/payments/google-play/types/errors.ts b/packages/fxa-auth-server/lib/payments/iap/google-play/types/errors.ts similarity index 100% rename from packages/fxa-auth-server/lib/payments/google-play/types/errors.ts rename to packages/fxa-auth-server/lib/payments/iap/google-play/types/errors.ts diff --git a/packages/fxa-auth-server/lib/payments/google-play/types/index.ts b/packages/fxa-auth-server/lib/payments/iap/google-play/types/index.ts similarity index 91% rename from packages/fxa-auth-server/lib/payments/google-play/types/index.ts rename to packages/fxa-auth-server/lib/payments/iap/google-play/types/index.ts index dc6540ed994..df11e828036 100644 --- a/packages/fxa-auth-server/lib/payments/google-play/types/index.ts +++ b/packages/fxa-auth-server/lib/payments/iap/google-play/types/index.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ export { PurchaseQueryError, PurchaseUpdateError } from './errors'; -export { IapConfig } from './firestore'; export { DeveloperNotification, NotificationType, diff --git a/packages/fxa-auth-server/lib/payments/google-play/types/notifications.ts b/packages/fxa-auth-server/lib/payments/iap/google-play/types/notifications.ts similarity index 100% rename from packages/fxa-auth-server/lib/payments/google-play/types/notifications.ts rename to packages/fxa-auth-server/lib/payments/iap/google-play/types/notifications.ts diff --git a/packages/fxa-auth-server/lib/payments/google-play/types/purchases.ts b/packages/fxa-auth-server/lib/payments/iap/google-play/types/purchases.ts similarity index 100% rename from packages/fxa-auth-server/lib/payments/google-play/types/purchases.ts rename to packages/fxa-auth-server/lib/payments/iap/google-play/types/purchases.ts diff --git a/packages/fxa-auth-server/lib/payments/google-play/user-manager.ts b/packages/fxa-auth-server/lib/payments/iap/google-play/user-manager.ts similarity index 98% rename from packages/fxa-auth-server/lib/payments/google-play/user-manager.ts rename to packages/fxa-auth-server/lib/payments/iap/google-play/user-manager.ts index 060f50c03e4..d2c01ed362f 100644 --- a/packages/fxa-auth-server/lib/payments/google-play/user-manager.ts +++ b/packages/fxa-auth-server/lib/payments/iap/google-play/user-manager.ts @@ -19,7 +19,7 @@ import { CollectionReference } from '@google-cloud/firestore'; import Container from 'typedi'; -import { AuthLogger } from '../../types'; +import { AuthLogger } from '../../../types'; import { PurchaseManager } from './purchase-manager'; import { GOOGLE_PLAY_FORM_OF_PAYMENT, diff --git a/packages/fxa-auth-server/lib/payments/iap/iap-config.ts b/packages/fxa-auth-server/lib/payments/iap/iap-config.ts new file mode 100644 index 00000000000..454679b5d06 --- /dev/null +++ b/packages/fxa-auth-server/lib/payments/iap/iap-config.ts @@ -0,0 +1,65 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import { Firestore } from '@google-cloud/firestore'; +import { Container } from 'typedi'; +import { TypedCollectionReference } from 'typesafe-node-firestore'; + +import { AppConfig, AuthFirestore, AuthLogger } from '../../types'; +import { IapConfig } from './types'; + +export class IAPConfig { + private firestore: Firestore; + private log: AuthLogger; + private iapConfigDbRef: TypedCollectionReference; + private prefix: string; + + constructor() { + this.log = Container.get(AuthLogger); + + const { authFirestore } = Container.get(AppConfig); + this.prefix = `${authFirestore.prefix}iap-`; + this.firestore = Container.get(AuthFirestore); + this.iapConfigDbRef = this.firestore.collection( + `${this.prefix}iap-config` + ) as TypedCollectionReference; + } + + /** + * Fetch the Play Store/App Store plans for Android/iOS client usage. + */ + public async plans(appName: string) { + const doc = await this.iapConfigDbRef.doc(appName).get(); + if (doc.exists) { + return doc.data()?.plans; + } else { + throw Error(`IAP Plans document does not exist for ${appName}`); + } + } + + /** + * Fetch the Google Play packageName for the given appName. + */ + public async packageName(appName: string) { + // TODO: use a cached version of the iap config + const doc = await this.iapConfigDbRef.doc(appName).get(); + if (doc.exists) { + return doc.data()?.packageName; + } else { + throw Error(`IAP Plans document does not exist for ${appName}`); + } + } + + /** + * Fetch the App Store bundleId for the given appName. + */ + public async getBundleId(appName: string) { + // TODO: use a cached version of the iap config + const doc = await this.iapConfigDbRef.doc(appName).get(); + if (doc.exists) { + return doc.data()?.bundleId; + } else { + throw Error(`IAP Plans document does not exist for ${appName}`); + } + } +} diff --git a/packages/fxa-auth-server/lib/payments/google-play/types/firestore.ts b/packages/fxa-auth-server/lib/payments/iap/types/firestore.ts similarity index 69% rename from packages/fxa-auth-server/lib/payments/google-play/types/firestore.ts rename to packages/fxa-auth-server/lib/payments/iap/types/firestore.ts index 73aa0f257c2..342e5824c9a 100644 --- a/packages/fxa-auth-server/lib/payments/google-play/types/firestore.ts +++ b/packages/fxa-auth-server/lib/payments/iap/types/firestore.ts @@ -2,6 +2,8 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import { RequireAtLeastOne } from 'type-fest'; + /** * Represents the plan blob that is returned to the client as-is. * @@ -14,7 +16,16 @@ * Apple and are intended for consumption by the relying party that * services the subscription. */ -export interface IapConfig { - packageName: string; +export interface IapConfigOptions { + // Apple only + bundleId?: string; + // Google only + packageName?: string; + // Shared plans: any; } + +export type IapConfig = RequireAtLeastOne< + IapConfigOptions, + 'packageName' | 'bundleId' +>; diff --git a/packages/fxa-auth-server/lib/payments/iap/types/index.ts b/packages/fxa-auth-server/lib/payments/iap/types/index.ts new file mode 100644 index 00000000000..8622994a14f --- /dev/null +++ b/packages/fxa-auth-server/lib/payments/iap/types/index.ts @@ -0,0 +1,5 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +export { IapConfig } from './firestore'; diff --git a/packages/fxa-auth-server/lib/payments/stripe.ts b/packages/fxa-auth-server/lib/payments/stripe.ts index 2c53dd77cec..5ec15208cd7 100644 --- a/packages/fxa-auth-server/lib/payments/stripe.ts +++ b/packages/fxa-auth-server/lib/payments/stripe.ts @@ -58,7 +58,7 @@ import { import { AppConfig, AuthFirestore, AuthLogger } from '../types'; import { PaymentConfigManager } from './configuration/manager'; import { CurrencyHelper } from './currencies'; -import { SubscriptionPurchase } from './google-play/subscription-purchase'; +import { SubscriptionPurchase } from './iap/google-play/subscription-purchase'; import { FirestoreStripeError, StripeFirestore } from './stripe-firestore'; // @ts-ignore diff --git a/packages/fxa-auth-server/lib/routes/account.ts b/packages/fxa-auth-server/lib/routes/account.ts index 902724f1151..6a550350d66 100644 --- a/packages/fxa-auth-server/lib/routes/account.ts +++ b/packages/fxa-auth-server/lib/routes/account.ts @@ -24,7 +24,7 @@ import { getClientById } from '../oauth/client'; import { generateAccessToken } from '../oauth/grant'; import jwt from '../oauth/jwt'; import { CapabilityService } from '../payments/capability'; -import { PlaySubscriptions } from '../payments/google-play/subscriptions'; +import { PlaySubscriptions } from '../payments/iap/google-play/subscriptions'; import { PayPalHelper } from '../payments/paypal/helper'; import { StripeHelper } from '../payments/stripe'; import { AuthLogger, AuthRequest } from '../types'; diff --git a/packages/fxa-auth-server/lib/routes/subscriptions/google.ts b/packages/fxa-auth-server/lib/routes/subscriptions/google.ts index e41c8a4d44f..937aff836cd 100644 --- a/packages/fxa-auth-server/lib/routes/subscriptions/google.ts +++ b/packages/fxa-auth-server/lib/routes/subscriptions/google.ts @@ -8,20 +8,23 @@ import { Container } from 'typedi'; import error from '../../error'; import { CapabilityService } from '../../payments/capability'; -import { PlayBilling } from '../../payments/google-play/play-billing'; -import { PurchaseUpdateError } from '../../payments/google-play/types/errors'; -import { SkuType } from '../../payments/google-play/types/purchases'; +import { PlayBilling } from '../../payments/iap/google-play/play-billing'; +import { PurchaseUpdateError } from '../../payments/iap/google-play/types/errors'; +import { SkuType } from '../../payments/iap/google-play/types/purchases'; +import { IAPConfig } from '../../payments/iap/iap-config'; import { AuthLogger, AuthRequest } from '../../types'; import { handleAuthScoped } from './utils'; export class GoogleIapHandler { private log: AuthLogger; + private iapConfig: IAPConfig; private playBilling: PlayBilling; private capabilityService: CapabilityService; private db: any; constructor(db: any) { this.db = db; + this.iapConfig = Container.get(IAPConfig); this.log = Container.get(AuthLogger); this.playBilling = Container.get(PlayBilling); this.capabilityService = Container.get(CapabilityService); @@ -32,7 +35,7 @@ export class GoogleIapHandler { public async plans(request: AuthRequest) { const { appName } = request.params; this.log.begin('googleIap.plans', request); - return this.playBilling.plans(appName); + return this.iapConfig.plans(appName); } /** @@ -46,7 +49,7 @@ export class GoogleIapHandler { const { appName } = request.params; const { sku, token } = request.payload as any; - const packageName = await this.playBilling.packageName(appName); + const packageName = await this.iapConfig.packageName(appName); if (!packageName) { throw error.unknownAppName(appName); } diff --git a/packages/fxa-auth-server/lib/routes/subscriptions/mozilla.ts b/packages/fxa-auth-server/lib/routes/subscriptions/mozilla.ts index bf969df0d33..9d443f832ed 100644 --- a/packages/fxa-auth-server/lib/routes/subscriptions/mozilla.ts +++ b/packages/fxa-auth-server/lib/routes/subscriptions/mozilla.ts @@ -4,7 +4,7 @@ import { ServerRoute } from '@hapi/hapi'; import { MozillaSubscription } from 'fxa-shared/subscriptions/types'; import { Container } from 'typedi'; -import { PlaySubscriptions } from '../../../lib/payments/google-play/subscriptions'; +import { PlaySubscriptions } from '../../../lib/payments/iap/google-play/subscriptions'; import error from '../../error'; import { PaymentBillingDetails, StripeHelper } from '../../payments/stripe'; import { AuthLogger, AuthRequest } from '../../types'; diff --git a/packages/fxa-auth-server/lib/routes/subscriptions/play-pubsub.ts b/packages/fxa-auth-server/lib/routes/subscriptions/play-pubsub.ts index f9b7494af6f..4aac58e9b01 100644 --- a/packages/fxa-auth-server/lib/routes/subscriptions/play-pubsub.ts +++ b/packages/fxa-auth-server/lib/routes/subscriptions/play-pubsub.ts @@ -7,8 +7,8 @@ import { Container } from 'typedi'; import error from '../../error'; import { CapabilityService } from '../../payments/capability'; -import { PlayBilling } from '../../payments/google-play/play-billing'; -import { DeveloperNotification } from '../../payments/google-play/types'; +import { PlayBilling } from '../../payments/iap/google-play/play-billing'; +import { DeveloperNotification } from '../../payments/iap/google-play/types'; import { reportSentryError } from '../../sentry'; import { AuthLogger, AuthRequest } from '../../types'; diff --git a/packages/fxa-auth-server/lib/routes/support-panel.ts b/packages/fxa-auth-server/lib/routes/support-panel.ts index c00145fa9eb..e8044b17265 100644 --- a/packages/fxa-auth-server/lib/routes/support-panel.ts +++ b/packages/fxa-auth-server/lib/routes/support-panel.ts @@ -7,7 +7,7 @@ import isA from '@hapi/joi'; import { MozillaSubscriptionTypes } from 'fxa-shared/subscriptions/types'; import { Container } from 'typedi'; import { ConfigType } from '../../config'; -import { PlaySubscriptions } from '../../lib/payments/google-play/subscriptions'; +import { PlaySubscriptions } from '../../lib/payments/iap/google-play/subscriptions'; import { StripeHelper } from '../payments/stripe'; import { AuthLogger, AuthRequest } from '../types'; import validators from './validators'; diff --git a/packages/fxa-auth-server/package.json b/packages/fxa-auth-server/package.json index 34a1a24d7b5..727fcf5ad5f 100644 --- a/packages/fxa-auth-server/package.json +++ b/packages/fxa-auth-server/package.json @@ -68,6 +68,7 @@ "@types/ejs": "^3.0.6", "@types/mjml": "^4.7.0", "ajv": "^6.12.2", + "app-store-server-api": "^0.3.0", "aws-sdk": "^2.1116.0", "base64url": "3.0.1", "buf": "0.1.1", @@ -201,6 +202,7 @@ "simplesmtp": "0.3.35", "sinon": "^9.0.3", "through": "2.3.8", + "type-fest": "^2.12.2", "typesafe-node-firestore": "^1.4.0", "typescript": "^4.5.2", "webpack": "^4.43.0", diff --git a/packages/fxa-auth-server/test/local/payments/capability.js b/packages/fxa-auth-server/test/local/payments/capability.js index 0ab08447250..acbd9f3ff5c 100644 --- a/packages/fxa-auth-server/test/local/payments/capability.js +++ b/packages/fxa-auth-server/test/local/payments/capability.js @@ -11,7 +11,7 @@ const { Container } = require('typedi'); const { mockLog } = require('../../mocks'); const { AuthLogger } = require('../../../lib/types'); const { StripeHelper } = require('../../../lib/payments/stripe'); -const { PlayBilling } = require('../../../lib/payments/google-play'); +const { PlayBilling } = require('../../../lib/payments/iap/google-play'); const subscriptionCreated = require('./fixtures/stripe/subscription_created.json').data.object; @@ -19,13 +19,13 @@ const subscriptionCreated = const { ProfileClient } = require('../../../lib/types'); const { SubscriptionPurchase, -} = require('../../../lib/payments/google-play/subscription-purchase'); +} = require('../../../lib/payments/iap/google-play/subscription-purchase'); const proxyquire = require('proxyquire').noPreserveCache(); const authDbModule = require('fxa-shared/db/models/auth'); const { PurchaseQueryError, -} = require('../../../lib/payments/google-play/types'); +} = require('../../../lib/payments/iap/google-play/types'); const mockAuthEvents = {}; diff --git a/packages/fxa-auth-server/test/local/payments/iap/apple-app-store/app-store-helper.js b/packages/fxa-auth-server/test/local/payments/iap/apple-app-store/app-store-helper.js new file mode 100644 index 00000000000..fbe1e6e4dc0 --- /dev/null +++ b/packages/fxa-auth-server/test/local/payments/iap/apple-app-store/app-store-helper.js @@ -0,0 +1,144 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict'; + +const sinon = require('sinon'); +const { assert } = require('chai'); +const { default: Container } = require('typedi'); +const proxyquire = require('proxyquire').noPreserveCache(); + +const { mockLog } = require('../../../../mocks'); +const { AuthLogger, AppConfig } = require('../../../../../lib/types'); +const { AppStoreServerAPI } = require('app-store-server-api'); + +const mockAppStoreServerAPI = sinon.createStubInstance(AppStoreServerAPI); +const { AppStoreHelper } = proxyquire( + '../../../../../lib/payments/iap/apple-app-store/app-store-helper', + { + 'app-store-server-api': { + AppStoreServerAPI: function () { + return mockAppStoreServerAPI; + }, + }, + } +); + +const mockBundleIdWithUnderscores = 'org_mozilla_ios_FirefoxVPN'; +const mockBundleId = mockBundleIdWithUnderscores.replace('_', '.'); +const mockConfig = { + subscriptions: { + appStore: { + credentials: { + [mockBundleIdWithUnderscores]: { + issuerId: 'issuer_id', + serverApiKey: 'key', + serverApiKeyId: 'key_id', + }, + }, + sandbox: true, + }, + }, +}; + +describe('AppStoreHelper', () => { + let appStoreHelper; + let sandbox; + let log; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + log = mockLog(); + Container.set(AuthLogger, log); + Container.set(AppConfig, mockConfig); + appStoreHelper = Container.get(AppStoreHelper); + }); + + afterEach(() => { + Container.reset(); + sandbox.restore(); + }); + + it('can be instantiated', () => { + const appStoreServerApiClients = { + [mockBundleId]: mockAppStoreServerAPI, + }; + const credentialsByBundleId = { + [mockBundleId]: + mockConfig.subscriptions.appStore.credentials[ + mockBundleIdWithUnderscores + ], + }; + assert.strictEqual(appStoreHelper.log, log); + assert.deepEqual( + appStoreHelper.appStoreServerApiClients, + appStoreServerApiClients + ); + assert.deepEqual( + appStoreHelper.credentialsByBundleId, + credentialsByBundleId + ); + assert.strictEqual(appStoreHelper.environment, 'Sandbox'); + }); + + describe('clientByBundleId', () => { + let mockApiClient; + + beforeEach(() => { + mockApiClient = {}; + }); + + it('returns the existing API client for the bundleId', () => { + appStoreHelper.appStoreServerApiClients[mockBundleId] = mockApiClient; + const actual = appStoreHelper.clientByBundleId(mockBundleId); + const expected = mockApiClient; + assert.deepEqual(actual, expected); + }); + it("initializes an API client for a given bundleId if it doesn't exist", () => { + appStoreHelper.appStoreServerApiClients = {}; + const actual = appStoreHelper.clientByBundleId(mockBundleId); + const expected = mockAppStoreServerAPI; + assert.deepEqual(actual, expected); + }); + it('throws an error if no credentials are found for the given bundleId', () => { + appStoreHelper.appStoreServerApiClients = {}; + appStoreHelper.credentialsByBundleId = {}; + const expectedMessage = `No App Store credentials found for app with bundleId: ${mockBundleId}.`; + try { + appStoreHelper.clientByBundleId(mockBundleId); + assert.fail('should throw an error'); + } catch (err) { + assert.equal(expectedMessage, err.message); + } + }); + }); + + describe('getSubscriptionStatuses', async () => { + it('calls the corresponding method on the API client', async () => { + const mockOriginalTransactionId = '100000000'; + // Mock App Store Client API response + const expected = { data: 'wow' }; + mockAppStoreServerAPI.getSubscriptionStatuses = sinon + .stub() + .resolves(expected); + appStoreHelper.clientByBundleId = sandbox + .stub() + .returns(mockAppStoreServerAPI); + const actual = await appStoreHelper.getSubscriptionStatuses( + mockBundleId, + mockOriginalTransactionId + ); + assert.deepEqual(actual, expected); + + sinon.assert.calledOnceWithExactly( + appStoreHelper.clientByBundleId, + mockBundleId + ); + sinon.assert.calledOnceWithExactly( + mockAppStoreServerAPI.getSubscriptionStatuses, + mockOriginalTransactionId + ); + }); + }); +}); diff --git a/packages/fxa-auth-server/test/local/payments/iap/apple-app-store/apple-iap.js b/packages/fxa-auth-server/test/local/payments/iap/apple-app-store/apple-iap.js new file mode 100644 index 00000000000..51ea31f12f1 --- /dev/null +++ b/packages/fxa-auth-server/test/local/payments/iap/apple-app-store/apple-iap.js @@ -0,0 +1,69 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict'; + +const sinon = require('sinon'); +const { assert } = require('chai'); +const { default: Container } = require('typedi'); + +const { mockLog } = require('../../../../mocks'); +const { + AuthFirestore, + AuthLogger, + AppConfig, +} = require('../../../../../lib/types'); +const { AppleIAP } = require('../../../../../lib/payments/iap/apple-app-store'); + +const mockConfig = { + authFirestore: { + prefix: 'mock-fxa-', + }, + subscriptions: { + appStore: { + credentials: { + org_mozilla_ios_FirefoxVPN: { + issuerId: 'issuer_id', + serverApiKey: 'key', + serverApiKeyId: 'key_id', + }, + }, + }, + }, +}; + +describe('AppleIAP', () => { + let collectionMock; + let purchasesDbRefMock; + let sandbox; + let firestore; + let log; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + log = mockLog(); + collectionMock = sinon.stub(); + firestore = { + collection: collectionMock, + }; + purchasesDbRefMock = {}; + collectionMock.returns(purchasesDbRefMock); + Container.set(AuthFirestore, firestore); + Container.set(AuthLogger, log); + Container.set(AppConfig, mockConfig); + Container.remove(AppleIAP); + }); + + afterEach(() => { + Container.reset(); + sandbox.restore(); + }); + + it('can be instantiated', () => { + const appleIAP = Container.get(AppleIAP); + assert.strictEqual(appleIAP.log, log); + assert.strictEqual(appleIAP.firestore, firestore); + assert.strictEqual(appleIAP.prefix, 'mock-fxa-iap-'); + }); +}); diff --git a/packages/fxa-auth-server/test/local/payments/iap/apple-app-store/purchase-manager.js b/packages/fxa-auth-server/test/local/payments/iap/apple-app-store/purchase-manager.js new file mode 100644 index 00000000000..95d3fa767c6 --- /dev/null +++ b/packages/fxa-auth-server/test/local/payments/iap/apple-app-store/purchase-manager.js @@ -0,0 +1,519 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict'; + +const sinon = require('sinon'); +const { assert } = require('chai'); +const { default: Container } = require('typedi'); +const proxyquire = require('proxyquire').noPreserveCache(); +const { SubscriptionStatus } = require('app-store-server-api/dist/cjs'); + +const { mockLog } = require('../../../../mocks'); +const { AppConfig, AuthLogger } = require('../../../../../lib/types'); +const { + PurchaseQueryError, + PurchaseUpdateError, +} = require('../../../../../lib/payments/iap/apple-app-store/types'); +const { + SubscriptionPurchase, +} = require('../../../../../lib/payments/iap/apple-app-store/subscription-purchase'); + +const sandbox = sinon.createSandbox(); + +const mockSubscriptionPurchase = {}; +const mockMergePurchase = sinon.fake.returns({}); +const mockDecodeTransactionInfo = sandbox.fake.resolves({}); +const mockDecodeRenewalInfo = sandbox.fake.resolves({}); +const mockBundleId = 'testBundleId'; +const mockOriginalTransactionId = 'testOriginalTransactionId'; +const mockApiResult = { + bundleId: mockBundleId, + data: [ + { + lastTransactions: [ + { + originalTransactionId: mockOriginalTransactionId, + status: SubscriptionStatus.Active, + signedTransactionInfo: {}, + signedRenewalInfo: {}, + }, + ], + }, + ], +}; + +const { PurchaseManager } = proxyquire( + '../../../../../lib/payments/iap/apple-app-store/purchase-manager', + { + './subscription-purchase': { + SubscriptionPurchase: mockSubscriptionPurchase, + mergePurchaseWithFirestorePurchaseRecord: mockMergePurchase, + }, + 'app-store-server-api': { + decodeRenewalInfo: mockDecodeRenewalInfo, + decodeTransaction: mockDecodeTransactionInfo, + }, + } +); + +// For queryCurrentSubscriptionPurchases method only which is the analog to +// Google Play's UserManager.queryCurrentSubscriptions originally. +// These tests use an actual SubscriptionPurchase class and helper methods +// from that module. +// TODO: rename proxyquired PurchaseManager to use MockPurchaseManager alias +// and use real name here. +const { PurchaseManager: UnmockedPurchaseManager } = proxyquire( + '../../../../../lib/payments/iap/apple-app-store/purchase-manager', + { + 'app-store-server-api': { + decodeRenewalInfo: mockDecodeRenewalInfo, + decodeTransaction: mockDecodeTransactionInfo, + }, + } +); + +describe('PurchaseManager', () => { + let log; + let mockAppStoreHelper; + let mockPurchaseDbRef; + + const mockConfig = { + authFirestore: { + prefix: 'mock-fxa-', + }, + subscriptions: { + appStore: { + credentials: { + org_mozilla_ios_FirefoxVPN: { + issuerId: 'issuer_id', + serverApiKey: 'key', + serverApiKeyId: 'key_id', + }, + }, + }, + }, + }; + + beforeEach(() => { + mockAppStoreHelper = {}; + log = mockLog(); + Container.set(AuthLogger, log); + Container.set(AppConfig, mockConfig); + }); + + afterEach(() => { + Container.reset(); + }); + + it('can be instantiated', () => { + const purchaseManager = new PurchaseManager( + mockPurchaseDbRef, + mockAppStoreHelper + ); + assert.ok(purchaseManager); + }); + + describe('querySubscriptionPurchase', () => { + let purchaseManager; + let mockPurchaseDoc; + let mockSubscription; + const firestoreObject = {}; + mockPurchaseDbRef = {}; + + beforeEach(() => { + mockAppStoreHelper = { + getSubscriptionStatuses: sinon.fake.resolves(mockApiResult), + }; + mockPurchaseDoc = { + exists: false, + ref: { + set: sinon.fake.resolves({}), + update: sinon.fake.resolves({}), + }, + }; + mockPurchaseDbRef.doc = sinon.fake.returns({ + get: sinon.fake.resolves(mockPurchaseDoc), + }); + mockSubscription = { + toFirestoreObject: sinon.fake.returns(firestoreObject), + }; + mockSubscriptionPurchase.fromApiResponse = + sinon.fake.returns(mockSubscription); + purchaseManager = new PurchaseManager( + mockPurchaseDbRef, + mockAppStoreHelper + ); + }); + + afterEach(() => { + sandbox.reset(); + }); + + it('queries with no found firestore doc', async () => { + const result = await purchaseManager.querySubscriptionPurchase( + mockBundleId, + mockOriginalTransactionId + ); + assert.strictEqual(result, mockSubscription); + + sinon.assert.calledOnce(mockAppStoreHelper.getSubscriptionStatuses); + sinon.assert.calledOnce(mockDecodeTransactionInfo); + sinon.assert.calledOnce(mockDecodeRenewalInfo); + + sinon.assert.calledOnce(mockPurchaseDbRef.doc); + sinon.assert.calledOnce(mockPurchaseDbRef.doc().get); + sinon.assert.calledOnce(mockSubscriptionPurchase.fromApiResponse); + sinon.assert.calledOnce(mockSubscription.toFirestoreObject); + + sinon.assert.calledWithExactly(mockPurchaseDoc.ref.set, firestoreObject); + }); + + it('queries with found firestore doc', async () => { + mockPurchaseDoc.data = sinon.fake.returns({}); + mockPurchaseDoc.exists = true; + const result = await purchaseManager.querySubscriptionPurchase( + mockBundleId, + mockOriginalTransactionId + ); + assert.strictEqual(result, mockSubscription); + + sinon.assert.calledOnce(mockAppStoreHelper.getSubscriptionStatuses); + sinon.assert.calledOnce(mockDecodeTransactionInfo); + sinon.assert.calledOnce(mockDecodeRenewalInfo); + + sinon.assert.calledOnce(mockPurchaseDbRef.doc); + sinon.assert.calledOnce(mockPurchaseDbRef.doc().get); + sinon.assert.calledOnce(mockSubscriptionPurchase.fromApiResponse); + sinon.assert.calledOnce(mockSubscription.toFirestoreObject); + + sinon.assert.calledWithExactly( + mockPurchaseDoc.ref.update, + firestoreObject + ); + sinon.assert.calledOnce(mockMergePurchase); + sinon.assert.calledOnce(mockPurchaseDoc.data); + }); + + it('throws unexpected library error', async () => { + mockPurchaseDoc.ref.set = sinon.fake.rejects(new Error('test')); + try { + await purchaseManager.querySubscriptionPurchase( + mockBundleId, + mockOriginalTransactionId + ); + assert.fail('Expected error'); + } catch (err) { + assert.equal(err.name, PurchaseQueryError.OTHER_ERROR); + } + }); + }); + + describe('forceRegisterToUserAccount', () => { + let purchaseManager; + + beforeEach(() => { + mockPurchaseDbRef.doc = sinon.fake.returns({ + update: sinon.fake.resolves({}), + }); + purchaseManager = new PurchaseManager( + mockPurchaseDbRef, + mockAppStoreHelper + ); + }); + + it('updates the user for a doc', async () => { + const result = await purchaseManager.forceRegisterToUserAccount( + mockBundleId, + 'testUserId' + ); + assert.isUndefined(result); + sinon.assert.calledOnce(mockPurchaseDbRef.doc); + sinon.assert.calledWithExactly(mockPurchaseDbRef.doc().update, { + userId: 'testUserId', + }); + }); + + it('throws library error on unknown', async () => { + mockPurchaseDbRef.doc = sinon.fake.returns({ + update: sinon.fake.rejects(new Error('Oops')), + }); + try { + await purchaseManager.forceRegisterToUserAccount( + 'testToken', + 'testUserId' + ); + assert.fail('Expected error'); + } catch (err) { + assert.equal(err.name, PurchaseQueryError.OTHER_ERROR); + } + }); + }); + + describe('getSubscriptionPurchase', () => { + let purchaseManager; + let mockPurchaseDoc; + + beforeEach(() => { + mockPurchaseDoc = { + exists: true, + data: sinon.fake.returns({}), + }; + + mockPurchaseDbRef.doc = sinon.fake.returns({ + get: sinon.fake.resolves(mockPurchaseDoc), + }); + purchaseManager = new PurchaseManager( + mockPurchaseDbRef, + mockAppStoreHelper + ); + mockSubscriptionPurchase.fromFirestoreObject = sinon.fake.returns({}); + }); + + it('returns an existing doc', async () => { + const result = await purchaseManager.getSubscriptionPurchase( + mockOriginalTransactionId + ); + assert.deepEqual(result, {}); + }); + + it('returns undefined with no doc', async () => { + mockPurchaseDoc.exists = false; + const result = await purchaseManager.getSubscriptionPurchase( + mockOriginalTransactionId + ); + assert.isUndefined(result); + }); + }); + + describe('registerToUserAccount', () => { + let purchaseManager; + let mockPurchaseDoc; + let mockSubscription; + + beforeEach(() => { + mockPurchaseDoc = { + exists: false, + data: sinon.fake.returns({}), + ref: { + set: sinon.fake.resolves({}), + update: sinon.fake.resolves({}), + }, + }; + mockSubscription = {}; + mockSubscription.isRegisterable = sinon.fake.returns(true); + mockPurchaseDbRef.doc = sinon.fake.returns({ + get: sinon.fake.resolves(mockPurchaseDoc), + }); + purchaseManager = new PurchaseManager( + mockPurchaseDbRef, + mockAppStoreHelper + ); + purchaseManager.querySubscriptionPurchase = + sinon.fake.resolves(mockSubscription); + purchaseManager.forceRegisterToUserAccount = sinon.fake.resolves({}); + }); + + it('registers successfully for non-cached original transaction id', async () => { + const result = await purchaseManager.registerToUserAccount( + mockBundleId, + mockOriginalTransactionId, + 'testUserId' + ); + assert.strictEqual(result, mockSubscription); + sinon.assert.calledOnce(purchaseManager.querySubscriptionPurchase); + sinon.assert.calledOnce(purchaseManager.forceRegisterToUserAccount); + }); + + it('skips doing anything for cached original transaction id', async () => { + mockPurchaseDoc.exists = true; + mockSubscription.userId = 'testUserId'; + mockSubscriptionPurchase.fromFirestoreObject = + sinon.fake.returns(mockSubscription); + const result = await purchaseManager.registerToUserAccount( + mockBundleId, + mockOriginalTransactionId, + 'testUserId' + ); + assert.strictEqual(result, mockSubscription); + sinon.assert.notCalled(purchaseManager.querySubscriptionPurchase); + sinon.assert.notCalled(purchaseManager.forceRegisterToUserAccount); + }); + + it('throws conflict error for existing original transaction id registered to other user', async () => { + mockPurchaseDoc.exists = true; + mockSubscription.userId = 'otherUserId'; + mockSubscriptionPurchase.fromFirestoreObject = + sinon.fake.returns(mockSubscription); + try { + await purchaseManager.registerToUserAccount( + mockBundleId, + mockOriginalTransactionId, + 'testUserId' + ); + assert.fail('Expected error'); + } catch (err) { + assert.equal(err.name, PurchaseUpdateError.CONFLICT); + sinon.assert.calledOnce(log.info); + } + }); + + it('throws invalid original transaction id error if purchase cant be queried', async () => { + purchaseManager.querySubscriptionPurchase = sinon.fake.rejects( + new Error('Oops') + ); + try { + await purchaseManager.registerToUserAccount( + mockBundleId, + mockOriginalTransactionId, + 'testUserId' + ); + assert.fail('Expected error'); + } catch (err) { + assert.equal( + err.name, + PurchaseUpdateError.INVALID_ORIGINAL_TRANSACTION_ID + ); + assert.equal(err.message, 'Oops'); + } + }); + }); + + describe('queryCurrentSubscriptionPurchases', () => { + let purchaseManager; + let mockPurchaseDbRef; + let mockPurchaseDoc; + let mockStatus; + let queryResult; + const USER_ID = 'testUser'; + const mockVerifiedAt = 123; + + beforeEach(() => { + queryResult = { + docs: [], + }; + mockStatus = SubscriptionStatus.Active; + mockPurchaseDbRef = { + where: () => mockPurchaseDbRef, + get: sinon.fake.resolves(queryResult), + }; + mockPurchaseDoc = { + exists: false, + ref: { + set: sinon.fake.resolves({}), + update: sinon.fake.resolves({}), + }, + }; + mockPurchaseDbRef.doc = sinon.fake.returns({ + get: sinon.fake.resolves(mockPurchaseDoc), + }); + purchaseManager = new UnmockedPurchaseManager( + mockPurchaseDbRef, + mockAppStoreHelper + ); + }); + + afterEach(() => { + Container.reset(); + }); + + it('returns the current subscriptions', async () => { + const subscriptionPurchase = SubscriptionPurchase.fromApiResponse( + mockApiResult, + mockStatus, + {}, + {}, + mockOriginalTransactionId, + mockVerifiedAt + ); + const subscriptionSnapshot = { + data: sinon.fake.returns(subscriptionPurchase.toFirestoreObject()), + }; + queryResult.docs.push(subscriptionSnapshot); + const result = await purchaseManager.queryCurrentSubscriptionPurchases( + USER_ID + ); + assert.deepEqual(result, [subscriptionPurchase]); + sinon.assert.calledOnce(mockPurchaseDbRef.get); + }); + + it('queries expired subscription purchases', async () => { + const mockApiExpiredResult = { + bundleId: mockBundleId, + data: [ + { + lastTransactions: [ + { + originalTransactionId: mockOriginalTransactionId, + status: SubscriptionStatus.Expired, + signedTransactionInfo: {}, + signedRenewalInfo: {}, + }, + ], + }, + ], + }; + mockStatus = SubscriptionStatus.Expired; + const subscriptionPurchase = SubscriptionPurchase.fromApiResponse( + mockApiExpiredResult, + mockStatus, + {}, + {}, + mockOriginalTransactionId, + mockVerifiedAt + ); + const subscriptionSnapshot = { + data: sinon.fake.returns(subscriptionPurchase.toFirestoreObject()), + }; + queryResult.docs.push(subscriptionSnapshot); + purchaseManager.querySubscriptionPurchase = + sinon.fake.resolves(subscriptionPurchase); + const result = await purchaseManager.queryCurrentSubscriptionPurchases( + USER_ID + ); + assert.deepEqual(result, []); + sinon.assert.calledOnce(purchaseManager.querySubscriptionPurchase); + }); + + it('throws library error on failure', async () => { + const mockApiExpiredResult = { + bundleId: mockBundleId, + data: [ + { + lastTransactions: [ + { + originalTransactionId: mockOriginalTransactionId, + status: SubscriptionStatus.Expired, + signedTransactionInfo: {}, + signedRenewalInfo: {}, + }, + ], + }, + ], + }; + mockStatus = SubscriptionStatus.Expired; + const subscriptionPurchase = SubscriptionPurchase.fromApiResponse( + mockApiExpiredResult, + mockStatus, + {}, + {}, + mockOriginalTransactionId, + mockVerifiedAt + ); + const subscriptionSnapshot = { + data: sinon.fake.returns(subscriptionPurchase.toFirestoreObject()), + }; + queryResult.docs.push(subscriptionSnapshot); + purchaseManager.querySubscriptionPurchase = sinon.fake.rejects( + new Error('oops') + ); + try { + await purchaseManager.queryCurrentSubscriptionPurchases(USER_ID); + assert.fail('should have thrown'); + } catch (err) { + assert.strictEqual(err.name, PurchaseQueryError.OTHER_ERROR); + } + }); + }); +}); diff --git a/packages/fxa-auth-server/test/local/payments/iap/apple-app-store/subscription-purchase.js b/packages/fxa-auth-server/test/local/payments/iap/apple-app-store/subscription-purchase.js new file mode 100644 index 00000000000..8897d8ae19b --- /dev/null +++ b/packages/fxa-auth-server/test/local/payments/iap/apple-app-store/subscription-purchase.js @@ -0,0 +1,198 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict'; + +const { assert } = require('chai'); +const { + SubscriptionStatus, + OfferType, + Environment, +} = require('app-store-server-api/dist/cjs'); + +const { + APPLE_APP_STORE_FORM_OF_PAYMENT, + SUBSCRIPTION_PURCHASE_REQUIRED_PROPERTIES, + SubscriptionPurchase, +} = require('../../../../../lib/payments/iap/apple-app-store/subscription-purchase'); + +describe('SubscriptionPurchase', () => { + const autoRenewStatus = 1; + const originalTransactionId = '1000000000000000'; + const bundleId = 'org.mozilla.ios.Product'; + const subscriptionGroupIdentifier = '22222222'; + const productId = `${bundleId}.product.1_month_subscription`; + const status = SubscriptionStatus.Active; + const type = 'Auto-Renewable Subscription'; + const expirationIntent = 1; + const expiresDate = 1649330045000; + const isInBillingRetry = false; + const environment = 'Production'; + const inAppOwnershipType = 'PURCHASED'; + const originalPurchaseDate = 1627306493000; + const autoRenewProductId = productId; + const apiResponse = { + data: { + subscriptionGroupIdentifier, + lastTransactions: [ + { + originalTransactionId, + status, + signedRenewalInfo: {}, + signedTransactionInfo: {}, + }, + ], + }, + environment, + appAppleId: '1234567890', + bundleId, + }; + const transactionInfo = { + transactionId: '2000000000000000', + originalTransactionId, + webOrderLineItemId: '2000000000000000', + bundleId, + productId, + subscriptionGroupIdentifier, + purchaseDate: 1649329745000, + originalPurchaseDate, + expiresDate, + quantity: 1, + type, + inAppOwnershipType, + signedDate: 1649792142801, + environment, + }; + const renewalInfo = { + expirationIntent, + originalTransactionId, + autoRenewProductId, + productId, + autoRenewStatus, + isInBillingRetryPeriod: isInBillingRetry, + signedDate: 1649792142801, + environment, + }; + const verifiedAt = Date.now(); + describe('fromApiResponse', () => { + it('parses active subscription correctly', () => { + const subscription = SubscriptionPurchase.fromApiResponse( + apiResponse, + status, + transactionInfo, + renewalInfo, + originalTransactionId, + verifiedAt + ); + + assert.isTrue(subscription.isEntitlementActive()); + assert.isTrue(subscription.willRenew()); + assert.isFalse(subscription.isTestPurchase()); + assert.isFalse(subscription.isInBillingRetryPeriod()); + assert.isFalse(subscription.isInGracePeriod()); + assert.isFalse(subscription.isFreeTrial()); + + // Verify that the required properties of the original API response + // are all copied to the SubscriptionPurchase object. + SUBSCRIPTION_PURCHASE_REQUIRED_PROPERTIES.forEach((key) => { + assert.isDefined( + subscription[key], + `Required key, ${key}, is in API response and SubscriptionPurchase` + ); + }); + const expected = { + autoRenewStatus, + bundleId, + originalTransactionId, + productId, + status, + type, + verifiedAt, + expirationIntent, + expiresDate, + isInBillingRetry, + environment, + inAppOwnershipType, + originalPurchaseDate, + autoRenewProductId, + }; + assert.deepEqual(expected, subscription); + }); + + it('parses trial subscription correctly', () => { + const subscription = SubscriptionPurchase.fromApiResponse( + apiResponse, + status, + transactionInfo, + { ...renewalInfo, offerType: OfferType.Introductory }, + originalTransactionId, + verifiedAt + ); + assert.isTrue(subscription.isFreeTrial()); + }); + + it('parses test purchase correctly', () => { + const subscription = SubscriptionPurchase.fromApiResponse( + { ...apiResponse, environment: Environment.Sandbox }, + status, + transactionInfo, + renewalInfo, + originalTransactionId, + verifiedAt + ); + assert.isTrue(subscription.isTestPurchase()); + }); + }); + + describe('firestore', () => { + let subscription; + + beforeEach(() => { + subscription = SubscriptionPurchase.fromApiResponse( + apiResponse, + status, + transactionInfo, + renewalInfo, + originalTransactionId, + verifiedAt + ); + }); + + it('converts to firestore', () => { + const result = subscription.toFirestoreObject(); + assert.strictEqual(result.formOfPayment, APPLE_APP_STORE_FORM_OF_PAYMENT); + }); + + it('converts from firestore', () => { + const firestoreObj = subscription.toFirestoreObject(); + firestoreObj.userId = 'testUser'; + const result = SubscriptionPurchase.fromFirestoreObject(firestoreObj); + // Internal keys are not defined on the subscription purchase. + assert.isUndefined(result.formOfPayment); + assert.strictEqual(result.userId, 'testUser'); + }); + + it('merges purchase with firestore object', () => { + // The firestore object will not have its internal keys copied, only keys + // not on the purchase already are copied over. The subscription does not + // have a offerType key, so we will rely on the merge copying it over. + const testRenewalInfo = { + ...renewalInfo, + offerType: OfferType.Introductory, + }; + const testSubscription = SubscriptionPurchase.fromApiResponse( + apiResponse, + status, + transactionInfo, + testRenewalInfo, // decoded from apiResponse + originalTransactionId, + verifiedAt + ); + assert.isFalse(subscription.isFreeTrial()); + const firestoreObject = testSubscription.toFirestoreObject(); + testSubscription.mergeWithFirestorePurchaseRecord(firestoreObject); + assert.isTrue(testSubscription.isFreeTrial()); + }); + }); +}); diff --git a/packages/fxa-auth-server/test/local/payments/iap/google-play/play-billing.js b/packages/fxa-auth-server/test/local/payments/iap/google-play/play-billing.js new file mode 100644 index 00000000000..e9eb7e1a2d2 --- /dev/null +++ b/packages/fxa-auth-server/test/local/payments/iap/google-play/play-billing.js @@ -0,0 +1,65 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict'; + +const sinon = require('sinon'); +const { assert } = require('chai'); +const { default: Container } = require('typedi'); + +const { mockLog } = require('../../../../mocks'); +const { + AuthFirestore, + AuthLogger, + AppConfig, +} = require('../../../../../lib/types'); +const { PlayBilling } = require('../../../../../lib/payments/iap/google-play'); + +const mockConfig = { + authFirestore: { + prefix: 'mock-fxa-', + }, + subscriptions: { + playApiServiceAccount: { + credentials: { + clientEmail: 'mock-client-email', + }, + keyFile: 'mock-private-keyfile', + }, + }, +}; + +describe('PlayBilling', () => { + let sandbox; + let firestore; + let log; + let purchasesDbRefMock; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + purchasesDbRefMock = {}; + const collectionMock = sinon.stub(); + collectionMock.returns(purchasesDbRefMock); + firestore = { + collection: collectionMock, + }; + log = mockLog(); + Container.set(AuthFirestore, firestore); + Container.set(AuthLogger, log); + Container.set(AppConfig, mockConfig); + Container.remove(PlayBilling); + }); + + afterEach(() => { + Container.reset(); + sandbox.restore(); + }); + + it('can be instantiated', () => { + const playBilling = Container.get(PlayBilling); + assert.strictEqual(playBilling.log, log); + assert.strictEqual(playBilling.firestore, firestore); + assert.strictEqual(playBilling.prefix, 'mock-fxa-iap-'); + }); +}); diff --git a/packages/fxa-auth-server/test/local/payments/google-play/purchase-manager.js b/packages/fxa-auth-server/test/local/payments/iap/google-play/purchase-manager.js similarity index 98% rename from packages/fxa-auth-server/test/local/payments/google-play/purchase-manager.js rename to packages/fxa-auth-server/test/local/payments/iap/google-play/purchase-manager.js index 9ac19177226..c0fca26e705 100644 --- a/packages/fxa-auth-server/test/local/payments/google-play/purchase-manager.js +++ b/packages/fxa-auth-server/test/local/payments/iap/google-play/purchase-manager.js @@ -9,19 +9,19 @@ const { assert } = require('chai'); const { default: Container } = require('typedi'); const proxyquire = require('proxyquire').noPreserveCache(); -const { mockLog } = require('../../../mocks'); -const { AuthLogger } = require('../../../../lib/types'); +const { mockLog } = require('../../../../mocks'); +const { AuthLogger } = require('../../../../../lib/types'); const { PurchaseQueryError, SkuType, PurchaseUpdateError, NotificationType, -} = require('../../../../lib/payments/google-play/types'); +} = require('../../../../../lib/payments/iap/google-play/types'); const mockSubscriptionPurchase = {}; const mockMergePurchase = sinon.fake.returns({}); const { PurchaseManager } = proxyquire( - '../../../../lib/payments/google-play/purchase-manager', + '../../../../../lib/payments/iap/google-play/purchase-manager', { './subscription-purchase': { SubscriptionPurchase: mockSubscriptionPurchase, diff --git a/packages/fxa-auth-server/test/local/payments/google-play/subscription-purchase.js b/packages/fxa-auth-server/test/local/payments/iap/google-play/subscription-purchase.js similarity index 97% rename from packages/fxa-auth-server/test/local/payments/google-play/subscription-purchase.js rename to packages/fxa-auth-server/test/local/payments/iap/google-play/subscription-purchase.js index 9a0481e5cc9..7845ffb9349 100644 --- a/packages/fxa-auth-server/test/local/payments/google-play/subscription-purchase.js +++ b/packages/fxa-auth-server/test/local/payments/iap/google-play/subscription-purchase.js @@ -9,8 +9,10 @@ const { assert } = require('chai'); const { SubscriptionPurchase, GOOGLE_PLAY_FORM_OF_PAYMENT, -} = require('../../../../lib/payments/google-play/subscription-purchase'); -const { SkuType } = require('../../../../lib/payments/google-play/types'); +} = require('../../../../../lib/payments/iap/google-play/subscription-purchase'); +const { + SkuType, +} = require('../../../../../lib/payments/iap/google-play/types'); describe('SubscriptionPurchase', () => { beforeEach(() => {}); diff --git a/packages/fxa-auth-server/test/local/payments/google-play/subscriptions.js b/packages/fxa-auth-server/test/local/payments/iap/google-play/subscriptions.js similarity index 95% rename from packages/fxa-auth-server/test/local/payments/google-play/subscriptions.js rename to packages/fxa-auth-server/test/local/payments/iap/google-play/subscriptions.js index b70891d5218..025e59cae32 100644 --- a/packages/fxa-auth-server/test/local/payments/google-play/subscriptions.js +++ b/packages/fxa-auth-server/test/local/payments/iap/google-play/subscriptions.js @@ -5,14 +5,14 @@ const sinon = require('sinon'); const assert = { ...sinon.assert, ...require('chai').assert }; const { Container } = require('typedi'); -const { PlayBilling } = require('../../../../lib/payments/google-play'); +const { PlayBilling } = require('../../../../../lib/payments/iap/google-play'); const { PlaySubscriptions, abbrevPlayPurchaseFromSubscriptionPurchase, -} = require('../../../../lib/payments/google-play/subscriptions'); +} = require('../../../../../lib/payments/iap/google-play/subscriptions'); const { MozillaSubscriptionTypes } = require('fxa-shared/subscriptions/types'); -const { AppConfig } = require('../../../../lib/types'); -const { StripeHelper } = require('../../../../lib/payments/stripe'); +const { AppConfig } = require('../../../../../lib/types'); +const { StripeHelper } = require('../../../../../lib/payments/stripe'); describe('PlaySubscriptions', () => { const mockConfig = { subscriptions: { enabled: true } }; diff --git a/packages/fxa-auth-server/test/local/payments/google-play/user-manager.js b/packages/fxa-auth-server/test/local/payments/iap/google-play/user-manager.js similarity index 91% rename from packages/fxa-auth-server/test/local/payments/google-play/user-manager.js rename to packages/fxa-auth-server/test/local/payments/iap/google-play/user-manager.js index fa9e18746ae..47f01920504 100644 --- a/packages/fxa-auth-server/test/local/payments/google-play/user-manager.js +++ b/packages/fxa-auth-server/test/local/payments/iap/google-play/user-manager.js @@ -8,18 +8,18 @@ const sinon = require('sinon'); const { assert } = require('chai'); const { default: Container } = require('typedi'); -const { mockLog } = require('../../../mocks'); +const { mockLog } = require('../../../../mocks'); const { UserManager, -} = require('../../../../lib/payments/google-play/user-manager'); -const { AuthLogger } = require('../../../../lib/types'); +} = require('../../../../../lib/payments/iap/google-play/user-manager'); +const { AuthLogger } = require('../../../../../lib/types'); const { SubscriptionPurchase, -} = require('../../../../lib/payments/google-play/subscription-purchase'); +} = require('../../../../../lib/payments/iap/google-play/subscription-purchase'); const { PurchaseQueryError, -} = require('../../../../lib/payments/google-play/types'); +} = require('../../../../../lib/payments/iap/google-play/types'); const USER_ID = 'testUser'; const VALID_SUB_API_RESPONSE = { diff --git a/packages/fxa-auth-server/test/local/payments/google-play/play-billing.js b/packages/fxa-auth-server/test/local/payments/iap/iap-config.js similarity index 59% rename from packages/fxa-auth-server/test/local/payments/google-play/play-billing.js rename to packages/fxa-auth-server/test/local/payments/iap/iap-config.js index 5a6702c5588..acfffcc35f3 100644 --- a/packages/fxa-auth-server/test/local/payments/google-play/play-billing.js +++ b/packages/fxa-auth-server/test/local/payments/iap/iap-config.js @@ -6,7 +6,7 @@ const sinon = require('sinon'); const { assert } = require('chai'); -const { default: Container } = require('typedi'); +const { Container } = require('typedi'); const { mockLog } = require('../../../mocks'); const { @@ -14,37 +14,26 @@ const { AuthLogger, AppConfig, } = require('../../../../lib/types'); -const { PlayBilling } = require('../../../../lib/payments/google-play'); +const { IAPConfig } = require('../../../../lib/payments/iap/iap-config'); const mockConfig = { authFirestore: { prefix: 'mock-fxa-', }, - subscriptions: { - playApiServiceAccount: { - credentials: { - clientEmail: 'mock-client-email', - }, - keyFile: 'mock-private-keyfile', - }, - }, }; -describe('PlayBilling', () => { +describe('IAPConfig', () => { let sandbox; let firestore; let log; - let playBilling; + let iapConfig; let planDbRefMock; - let purchasesDbRefMock; beforeEach(() => { sandbox = sinon.createSandbox(); planDbRefMock = {}; - purchasesDbRefMock = {}; const collectionMock = sinon.stub(); - collectionMock.onFirstCall().returns(planDbRefMock); - collectionMock.onSecondCall().returns(purchasesDbRefMock); + collectionMock.returns(planDbRefMock); firestore = { collection: collectionMock, }; @@ -52,7 +41,7 @@ describe('PlayBilling', () => { Container.set(AuthFirestore, firestore); Container.set(AuthLogger, log); Container.set(AppConfig, mockConfig); - Container.remove(PlayBilling); + Container.remove(IAPConfig); }); afterEach(() => { @@ -61,17 +50,17 @@ describe('PlayBilling', () => { }); it('can be instantiated', () => { - const playBilling = Container.get(PlayBilling); - assert.strictEqual(playBilling.log, log); - assert.strictEqual(playBilling.firestore, firestore); - assert.strictEqual(playBilling.prefix, 'mock-fxa-iap-'); + const iapConfig = Container.get(IAPConfig); + assert.strictEqual(iapConfig.log, log); + assert.strictEqual(iapConfig.firestore, firestore); + assert.strictEqual(iapConfig.prefix, 'mock-fxa-iap-'); }); describe('plans', () => { beforeEach(() => { // Create and set a new one per test - playBilling = new PlayBilling(); - Container.set(PlayBilling, playBilling); + iapConfig = new IAPConfig(); + Container.set(IAPConfig, iapConfig); }); it('returns successfully', async () => { @@ -81,7 +70,7 @@ describe('PlayBilling', () => { data: sinon.fake.returns({ plans: 'testObject' }), }), }); - const result = await playBilling.plans(); + const result = await iapConfig.plans(); assert.strictEqual(result, 'testObject'); }); @@ -92,7 +81,7 @@ describe('PlayBilling', () => { }), }); try { - await playBilling.plans('testApp'); + await iapConfig.plans('testApp'); assert.fail('Expected exception thrown.'); } catch (err) { assert.strictEqual( @@ -106,8 +95,8 @@ describe('PlayBilling', () => { describe('packageName', () => { beforeEach(() => { // Create and set a new one per test - playBilling = new PlayBilling(); - Container.set(PlayBilling, playBilling); + iapConfig = new IAPConfig(); + Container.set(IAPConfig, iapConfig); }); it('returns successfully', async () => { @@ -120,7 +109,46 @@ describe('PlayBilling', () => { }), }), }); - const result = await playBilling.packageName('testApp'); + const result = await iapConfig.packageName('testApp'); + assert.strictEqual(result, 'org.mozilla.testApp'); + }); + + it('throws error with no document found', async () => { + planDbRefMock.doc = sinon.fake.returns({ + get: sinon.fake.resolves({ + exists: false, + }), + }); + try { + await iapConfig.packageName('testApp'); + assert.fail('Expected exception thrown.'); + } catch (err) { + assert.strictEqual( + err.message, + 'IAP Plans document does not exist for testApp' + ); + } + }); + }); + + describe('getBundleId', () => { + beforeEach(() => { + // Create and set a new one per test + iapConfig = new IAPConfig(); + Container.set(IAPConfig, iapConfig); + }); + + it('returns successfully', async () => { + planDbRefMock.doc = sinon.fake.returns({ + get: sinon.fake.resolves({ + exists: true, + data: sinon.fake.returns({ + bundleId: 'org.mozilla.testApp', + plans: 'testObject', + }), + }), + }); + const result = await iapConfig.getBundleId('testApp'); assert.strictEqual(result, 'org.mozilla.testApp'); }); @@ -131,7 +159,7 @@ describe('PlayBilling', () => { }), }); try { - await playBilling.packageName('testApp'); + await iapConfig.getBundleId('testApp'); assert.fail('Expected exception thrown.'); } catch (err) { assert.strictEqual( diff --git a/packages/fxa-auth-server/test/local/payments/stripe.js b/packages/fxa-auth-server/test/local/payments/stripe.js index 6d28592ea6a..dfda12347a7 100644 --- a/packages/fxa-auth-server/test/local/payments/stripe.js +++ b/packages/fxa-auth-server/test/local/payments/stripe.js @@ -83,7 +83,7 @@ const { } = require('fxa-shared/db/models/auth'); const { SubscriptionPurchase, -} = require('../../../lib/payments/google-play/subscription-purchase'); +} = require('../../../lib/payments/iap/google-play/subscription-purchase'); const { AuthFirestore, AuthLogger, AppConfig } = require('../../../lib/types'); const { INVOICES_RESOURCE, diff --git a/packages/fxa-auth-server/test/local/routes/account.js b/packages/fxa-auth-server/test/local/routes/account.js index e7d13911eba..1ba72f3b1a4 100644 --- a/packages/fxa-auth-server/test/local/routes/account.js +++ b/packages/fxa-auth-server/test/local/routes/account.js @@ -24,7 +24,7 @@ const { normalizeEmail } = require('fxa-shared').email.helpers; const { MozillaSubscriptionTypes } = require('fxa-shared/subscriptions/types'); const { PlaySubscriptions, -} = require('../../../lib/payments/google-play/subscriptions'); +} = require('../../../lib/payments/iap/google-play/subscriptions'); const { AccountHandler } = require('../../../lib/routes/account'); diff --git a/packages/fxa-auth-server/test/local/routes/subscriptions/google.js b/packages/fxa-auth-server/test/local/routes/subscriptions/google.js index 9e2138b2882..893db93561a 100644 --- a/packages/fxa-auth-server/test/local/routes/subscriptions/google.js +++ b/packages/fxa-auth-server/test/local/routes/subscriptions/google.js @@ -15,10 +15,11 @@ const { } = require('../../../../lib/routes/subscriptions/google'); const { PurchaseUpdateError, -} = require('../../../../lib/payments/google-play/types/errors'); +} = require('../../../../lib/payments/iap/google-play/types/errors'); const error = require('../../../../lib/error'); const { AuthLogger } = require('../../../../lib/types'); -const { PlayBilling } = require('../../../../lib/payments/google-play'); +const { PlayBilling } = require('../../../../lib/payments/iap/google-play'); +const { IAPConfig } = require('../../../../lib/payments/iap/iap-config'); const { OAUTH_SCOPE_SUBSCRIPTIONS_IAP } = require('fxa-shared/oauth/constants'); const { CapabilityService } = require('../../../../lib/payments/capability'); @@ -37,6 +38,7 @@ const VALID_REQUEST = { }; describe('GoogleIapHandler', () => { + let iapConfig; let playBilling; let log; let googleIapHandler; @@ -47,6 +49,8 @@ describe('GoogleIapHandler', () => { log = mocks.mockLog(); playBilling = {}; Container.set(AuthLogger, log); + iapConfig = {}; + Container.set(IAPConfig, iapConfig); Container.set(PlayBilling, playBilling); db = mocks.mockDB({ uid: UID, @@ -67,11 +71,11 @@ describe('GoogleIapHandler', () => { describe('plans', () => { it('returns the plans', async () => { - playBilling.plans = sinon.fake.resolves({ test: 'plan' }); + iapConfig.plans = sinon.fake.resolves({ test: 'plan' }); const result = await googleIapHandler.plans({ params: { appName: 'test' }, }); - assert.calledOnce(playBilling.plans); + assert.calledOnce(iapConfig.plans); assert.deepEqual(result, { test: 'plan' }); }); }); @@ -87,10 +91,10 @@ describe('GoogleIapHandler', () => { playBilling.purchaseManager = { registerToUserAccount: sinon.fake.resolves({}), }; - playBilling.packageName = sinon.fake.resolves('testPackage'); + iapConfig.packageName = sinon.fake.resolves('testPackage'); const result = await googleIapHandler.registerToken(request); assert.calledOnce(playBilling.purchaseManager.registerToUserAccount); - assert.calledOnce(playBilling.packageName); + assert.calledOnce(iapConfig.packageName); assert.calledOnce(mockCapabilityService.playUpdate); assert.deepEqual(result, { tokenValid: true }); }); @@ -102,10 +106,10 @@ describe('GoogleIapHandler', () => { playBilling.purchaseManager = { registerToUserAccount: sinon.fake.resolves({}), }; - playBilling.packageName = sinon.fake.resolves('testPackage'); + iapConfig.packageName = sinon.fake.resolves('testPackage'); const result = await googleIapHandler.registerToken(request); assert.calledOnce(playBilling.purchaseManager.registerToUserAccount); - assert.calledOnce(playBilling.packageName); + assert.calledOnce(iapConfig.packageName); assert.calledOnce(mockCapabilityService.playUpdate); assert.deepEqual(result, { tokenValid: true }); }); @@ -114,12 +118,12 @@ describe('GoogleIapHandler', () => { playBilling.purchaseManager = { registerToUserAccount: sinon.fake.resolves({}), }; - playBilling.packageName = sinon.fake.resolves(undefined); + iapConfig.packageName = sinon.fake.resolves(undefined); try { await googleIapHandler.registerToken(request); assert.fail('Expected failure'); } catch (err) { - assert.calledOnce(playBilling.packageName); + assert.calledOnce(iapConfig.packageName); assert.strictEqual(err.errno, error.ERRNO.IAP_UNKNOWN_APPNAME); } }); @@ -131,14 +135,14 @@ describe('GoogleIapHandler', () => { playBilling.purchaseManager = { registerToUserAccount: sinon.fake.rejects(libraryError), }; - playBilling.packageName = sinon.fake.resolves('testPackage'); + iapConfig.packageName = sinon.fake.resolves('testPackage'); try { await googleIapHandler.registerToken(request); assert.fail('Expected failure'); } catch (err) { assert.strictEqual(err.errno, error.ERRNO.IAP_INVALID_TOKEN); assert.calledOnce(playBilling.purchaseManager.registerToUserAccount); - assert.calledOnce(playBilling.packageName); + assert.calledOnce(iapConfig.packageName); } }); @@ -149,14 +153,14 @@ describe('GoogleIapHandler', () => { playBilling.purchaseManager = { registerToUserAccount: sinon.fake.rejects(libraryError), }; - playBilling.packageName = sinon.fake.resolves('testPackage'); + iapConfig.packageName = sinon.fake.resolves('testPackage'); try { await googleIapHandler.registerToken(request); assert.fail('Expected failure'); } catch (err) { assert.strictEqual(err.errno, error.ERRNO.IAP_INTERNAL_OTHER); assert.calledOnce(playBilling.purchaseManager.registerToUserAccount); - assert.calledOnce(playBilling.packageName); + assert.calledOnce(iapConfig.packageName); } }); @@ -164,14 +168,14 @@ describe('GoogleIapHandler', () => { playBilling.purchaseManager = { registerToUserAccount: sinon.fake.rejects(new Error('Unknown error')), }; - playBilling.packageName = sinon.fake.resolves('testPackage'); + iapConfig.packageName = sinon.fake.resolves('testPackage'); try { await googleIapHandler.registerToken(request); assert.fail('Expected failure'); } catch (err) { assert.strictEqual(err.errno, error.ERRNO.BACKEND_SERVICE_FAILURE); assert.calledOnce(playBilling.purchaseManager.registerToUserAccount); - assert.calledOnce(playBilling.packageName); + assert.calledOnce(iapConfig.packageName); } }); }); diff --git a/packages/fxa-auth-server/test/local/routes/subscriptions/paypal.js b/packages/fxa-auth-server/test/local/routes/subscriptions/paypal.js index 5728690d5a4..eab15a21dc4 100644 --- a/packages/fxa-auth-server/test/local/routes/subscriptions/paypal.js +++ b/packages/fxa-auth-server/test/local/routes/subscriptions/paypal.js @@ -27,7 +27,7 @@ const buildRoutes = require('../../../../lib/routes/subscriptions'); const ACCOUNT_LOCALE = 'en-US'; const { OAUTH_SCOPE_SUBSCRIPTIONS } = require('fxa-shared/oauth/constants'); const { CapabilityService } = require('../../../../lib/payments/capability'); -const { PlayBilling } = require('../../../../lib/payments/google-play'); +const { PlayBilling } = require('../../../../lib/payments/iap/google-play'); const TEST_EMAIL = 'test@email.com'; const UID = uuid.v4({}, Buffer.alloc(16)).toString('hex'); const MOCK_SCOPES = ['profile:email', OAUTH_SCOPE_SUBSCRIPTIONS]; diff --git a/packages/fxa-auth-server/test/local/routes/subscriptions/play-pubsub.js b/packages/fxa-auth-server/test/local/routes/subscriptions/play-pubsub.js index a8e4cd86f39..3bdf438bb20 100644 --- a/packages/fxa-auth-server/test/local/routes/subscriptions/play-pubsub.js +++ b/packages/fxa-auth-server/test/local/routes/subscriptions/play-pubsub.js @@ -16,7 +16,7 @@ const { const { default: Container } = require('typedi'); const { mockLog } = require('../../../mocks'); const { AuthLogger } = require('../../../../lib/types'); -const { PlayBilling } = require('../../../../lib/payments/google-play'); +const { PlayBilling } = require('../../../../lib/payments/iap/google-play'); const { CapabilityService } = require('../../../../lib/payments/capability'); const ACCOUNT_LOCALE = 'en-US'; diff --git a/packages/fxa-auth-server/test/local/routes/subscriptions/stripe.js b/packages/fxa-auth-server/test/local/routes/subscriptions/stripe.js index 96a9dd0e03a..cdc92445425 100644 --- a/packages/fxa-auth-server/test/local/routes/subscriptions/stripe.js +++ b/packages/fxa-auth-server/test/local/routes/subscriptions/stripe.js @@ -34,7 +34,7 @@ const { StripeHandler: DirectStripeRoutes } = proxyquire( const { AuthLogger, AppConfig } = require('../../../../lib/types'); const { CapabilityService } = require('../../../../lib/payments/capability'); -const { PlayBilling } = require('../../../../lib/payments/google-play'); +const { PlayBilling } = require('../../../../lib/payments/iap/google-play'); const { stripeInvoiceToFirstInvoicePreviewDTO, stripeInvoicesToSubsequentInvoicePreviewsDTO, diff --git a/packages/fxa-auth-server/test/mocks.js b/packages/fxa-auth-server/test/mocks.js index 4bba190e3f7..926d17c65e7 100644 --- a/packages/fxa-auth-server/test/mocks.js +++ b/packages/fxa-auth-server/test/mocks.js @@ -851,6 +851,6 @@ function mockPayPalHelper(methods) { function mockPlaySubscriptions(methods) { return mockObject( methods, - require('../lib/payments/google-play/subscriptions').PlaySubscriptions + require('../lib/payments/iap/google-play/subscriptions').PlaySubscriptions ); } diff --git a/packages/fxa-auth-server/test/remote/subscription_tests.js b/packages/fxa-auth-server/test/remote/subscription_tests.js index 2fa22759b56..e24bb941eea 100644 --- a/packages/fxa-auth-server/test/remote/subscription_tests.js +++ b/packages/fxa-auth-server/test/remote/subscription_tests.js @@ -18,7 +18,7 @@ const { StripeHelper } = require('../../lib/payments/stripe'); const { AuthLogger } = require('../../lib/types'); const { PlaySubscriptions, -} = require('../../lib/payments/google-play/subscriptions'); +} = require('../../lib/payments/iap/google-play/subscriptions'); const validClients = config.oauthServer.clients.filter( (client) => client.trusted && client.canGrant && client.publicClient diff --git a/yarn.lock b/yarn.lock index 53905c6aa75..563ceb2e885 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12397,6 +12397,17 @@ __metadata: languageName: node linkType: hard +"app-store-server-api@npm:^0.3.0": + version: 0.3.0 + resolution: "app-store-server-api@npm:0.3.0" + dependencies: + jose: ^4.3.7 + node-fetch: ^2.6.6 + uuid: ^8.3.2 + checksum: 0cb15c82270cf4d216d71913412b402cebea051cfe769cef3478dbcf525564d940f04940355a92f9c9e418e522b7c9eb21d9805159e32a6a94330c321f998800 + languageName: node + linkType: hard + "append-field@npm:^1.0.0": version: 1.0.0 resolution: "append-field@npm:1.0.0" @@ -22229,6 +22240,7 @@ fsevents@~2.1.1: "@types/webpack": 5.28.0 acorn: ^8.0.1 ajv: ^6.12.2 + app-store-server-api: ^0.3.0 async-retry: ^1.3.3 audit-filter: ^0.5.0 aws-sdk: ^2.1116.0 @@ -22325,6 +22337,7 @@ fsevents@~2.1.1: stripe: ^8.218.0 superagent: ^7.1.2 through: 2.3.8 + type-fest: ^2.12.2 typedi: ^0.8.0 typesafe-node-firestore: ^1.4.0 typescript: ^4.5.2 @@ -28603,6 +28616,13 @@ fsevents@~2.1.1: languageName: node linkType: hard +"jose@npm:^4.3.7": + version: 4.6.1 + resolution: "jose@npm:4.6.1" + checksum: f701b41ef385e819a2a6369f2c63277ed51694bac4c0dac211e58f71a7b902b5c42cb0fbaf922e5a14d6f02219f0cb4e1128147f6cbd003f9f6abb7b8f7b91e5 + languageName: node + linkType: hard + "jpeg-js@npm:0.4.3": version: 0.4.3 resolution: "jpeg-js@npm:0.4.3" @@ -32352,7 +32372,7 @@ fsevents@~2.1.1: languageName: node linkType: hard -"node-fetch@npm:^2.6.7": +"node-fetch@npm:^2.6.6, node-fetch@npm:^2.6.7": version: 2.6.7 resolution: "node-fetch@npm:2.6.7" dependencies: @@ -42412,6 +42432,13 @@ resolve@^2.0.0-next.3: languageName: node linkType: hard +"type-fest@npm:^2.12.2": + version: 2.12.2 + resolution: "type-fest@npm:2.12.2" + checksum: ee69676da1f69d2b14bbec28c7b95220a3221ab14093f54bde179c59e185a80470859553eada535ec35d8637a245c2f0efe9d7c99cc46a43f3b4c7ef64db7957 + languageName: node + linkType: hard + "type-is@npm:^1.6.16, type-is@npm:^1.6.4, type-is@npm:~1.6.17, type-is@npm:~1.6.18": version: 1.6.18 resolution: "type-is@npm:1.6.18"