diff --git a/libs/payments/eligibility/src/lib/eligibility.service.spec.ts b/libs/payments/eligibility/src/lib/eligibility.service.spec.ts index 6dcf64131ba..5f953cc8167 100644 --- a/libs/payments/eligibility/src/lib/eligibility.service.spec.ts +++ b/libs/payments/eligibility/src/lib/eligibility.service.spec.ts @@ -48,15 +48,29 @@ import { import { LocationConfig, MockLocationConfigProvider } from './location.config'; import { AppleIapClient, + AppleIapClientBundleError, AppleIapPurchaseManager, + AppleIapServiceUnavailableError, GoogleIapClient, GoogleIapPurchaseManager, MockAppleIapClientConfigProvider, MockGoogleIapClientConfigProvider, } from '@fxa/payments/iap'; +import { AppStoreError } from 'app-store-server-api'; import { faker } from '@faker-js/faker'; import { Logger } from '@nestjs/common'; +// Apple reports a transient internal failure as errorCode 5000001, which +// app-store-server-api flags as retryable. +const APPLE_RETRYABLE_ERROR = new AppleIapServiceUnavailableError( + new AppStoreError(5000001, 'An unknown error occurred. Please try again.') +); +// A 401 from the App Store Server API surfaces as a plain Error, meaning our +// JWT is invalid — never that the customer has no Apple subscriptions. +const APPLE_INVALID_JWT_ERROR = new AppleIapClientBundleError( + new Error('The request is unauthorized; the JSON Web Token (JWT) is invalid.') +); + describe('EligibilityService', () => { let productConfigurationManager: ProductConfigurationManager; let eligibilityManager: EligibilityManager; @@ -64,6 +78,7 @@ describe('EligibilityService', () => { let subscriptionManager: SubscriptionManager; let appleIapPurchaseManager: AppleIapPurchaseManager; let googleIapPurchaseManager: GoogleIapPurchaseManager; + let logger: Logger; beforeEach(async () => { const module = await Test.createTestingModule({ @@ -97,6 +112,7 @@ describe('EligibilityService', () => { subscriptionManager = module.get(SubscriptionManager); appleIapPurchaseManager = module.get(AppleIapPurchaseManager); googleIapPurchaseManager = module.get(GoogleIapPurchaseManager); + logger = module.get(Logger); }); describe('checkEligibility', () => { @@ -310,6 +326,109 @@ describe('EligibilityService', () => { }); }); + it('returns BLOCKED_IAP from the cached Apple purchases when the App Store Server API is unavailable', async () => { + const mockCustomer = StripeCustomerFactory(); + const interval = SubplatInterval.Monthly; + const offeringApiIdentifier = faker.string.uuid(); + + jest + .spyOn(appleIapPurchaseManager, 'getForUser') + .mockRejectedValue(APPLE_RETRYABLE_ERROR); + jest + .spyOn(appleIapPurchaseManager, 'getStaleCachedForUser') + .mockResolvedValue([{ productId: 'apple_product_1' } as any]); + jest.spyOn(googleIapPurchaseManager, 'getForUser').mockResolvedValue([]); + const warn = jest.spyOn(logger, 'warn').mockImplementation(); + jest + .spyOn(productConfigurationManager, 'getEligibilityContentByOffering') + .mockResolvedValue( + new EligibilityContentByOfferingResultUtil( + EligibilityContentByOfferingResultFactory({ + offerings: [ + EligibilityContentOfferingResultFactory({ + apiIdentifier: offeringApiIdentifier, + }), + ], + }) + ) + ); + jest + .spyOn(productConfigurationManager, 'getIapOfferings') + .mockResolvedValue( + new IapOfferingsByStoreIDsResultUtil( + IapOfferingByStoreIDResultFactory({ + iaps: [ + IapWithOfferingResultFactory({ + offering: IapOfferingResultFactory({ + apiIdentifier: offeringApiIdentifier, + subGroups: [ + IapOfferingSubGroupResultFactory({ + offerings: [ + IapOfferingSubGroupOfferingResultFactory({ + apiIdentifier: offeringApiIdentifier, + }), + ], + }), + ], + }), + }), + ], + }) + ) + ); + const uid = faker.string.uuid(); + + const result = await eligibilityService.checkEligibility( + interval, + offeringApiIdentifier, + uid, + mockCustomer.id + ); + + expect(result).toEqual({ + subscriptionEligibilityResult: EligibilityStatus.BLOCKED_IAP, + }); + expect(appleIapPurchaseManager.getStaleCachedForUser).toHaveBeenCalledWith(uid); + expect(warn).toHaveBeenCalledWith('checkEligibility.appleIapUnavailable', { + uid, + }); + }); + + it('rethrows Apple IAP errors Apple did not ask us to retry', async () => { + const mockCustomer = StripeCustomerFactory(); + const interval = SubplatInterval.Monthly; + const offeringApiIdentifier = faker.string.uuid(); + + jest + .spyOn(appleIapPurchaseManager, 'getForUser') + .mockRejectedValue(APPLE_INVALID_JWT_ERROR); + jest.spyOn(appleIapPurchaseManager, 'getStaleCachedForUser'); + jest.spyOn(googleIapPurchaseManager, 'getForUser').mockResolvedValue([]); + jest + .spyOn(productConfigurationManager, 'getEligibilityContentByOffering') + .mockResolvedValue( + new EligibilityContentByOfferingResultUtil( + EligibilityContentByOfferingResultFactory({ + offerings: [ + EligibilityContentOfferingResultFactory({ + apiIdentifier: offeringApiIdentifier, + }), + ], + }) + ) + ); + + await expect( + eligibilityService.checkEligibility( + interval, + offeringApiIdentifier, + faker.string.uuid(), + mockCustomer.id + ) + ).rejects.toBe(APPLE_INVALID_JWT_ERROR); + expect(appleIapPurchaseManager.getStaleCachedForUser).not.toHaveBeenCalled(); + }); + it('returns CREATE when Google IAP subscription is for a different product', async () => { const interval = SubplatInterval.Monthly; const offeringApiIdentifier = faker.string.uuid(); diff --git a/libs/payments/eligibility/src/lib/eligibility.service.ts b/libs/payments/eligibility/src/lib/eligibility.service.ts index 886be09d031..48a53c1123e 100644 --- a/libs/payments/eligibility/src/lib/eligibility.service.ts +++ b/libs/payments/eligibility/src/lib/eligibility.service.ts @@ -2,12 +2,18 @@ * 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 { Injectable } from '@nestjs/common'; +import { + Inject, + Injectable, + Logger, + type LoggerService, +} from '@nestjs/common'; import { SubscriptionManager, SubplatInterval } from '@fxa/payments/customer'; import { ProductConfigurationManager } from '@fxa/shared/cms'; import { GoogleIapPurchaseManager, AppleIapPurchaseManager, + AppleIapServiceUnavailableError, } from '@fxa/payments/iap'; import { EligibilityManager } from './eligibility.manager'; import { @@ -26,7 +32,8 @@ export class EligibilityService { private eligibilityManager: EligibilityManager, private subscriptionManager: SubscriptionManager, private googleIapPurchaseManager: GoogleIapPurchaseManager, - private appleIapPurchaseManager: AppleIapPurchaseManager + private appleIapPurchaseManager: AppleIapPurchaseManager, + @Inject(Logger) private log: LoggerService ) {} /** @@ -56,7 +63,15 @@ export class EligibilityService { const targetOffering = targetOfferingResult.getOffering(); const [appleIapPurchases, googleIapPurchases] = await Promise.all([ - this.appleIapPurchaseManager.getForUser(uid), + this.appleIapPurchaseManager.getForUser(uid).catch((error) => { + if (error instanceof AppleIapServiceUnavailableError) { + // Unconfirmed must not read as absent, or an overlapping purchase + // slips through — including a stale cache that reads inactive. + this.log.warn('checkEligibility.appleIapUnavailable', { uid }); + return this.appleIapPurchaseManager.getStaleCachedForUser(uid); + } + throw error; + }), this.googleIapPurchaseManager.getForUser(uid), ]); if (appleIapPurchases.length || googleIapPurchases.length) { diff --git a/libs/payments/iap/src/index.ts b/libs/payments/iap/src/index.ts index a28a786d91e..539fbffa583 100644 --- a/libs/payments/iap/src/index.ts +++ b/libs/payments/iap/src/index.ts @@ -8,6 +8,7 @@ export * from './lib/google/subscription-purchase'; export * from './lib/apple/apple-iap.client'; export * from './lib/google/google-iap.client.config'; export * from './lib/apple/apple-iap-purchase.manager'; +export * from './lib/apple/apple-iap.error'; export * from './lib/apple/subscription-purchase'; export * from './lib/google/google-iap.client'; export * from './lib/apple/apple-iap.client.config'; diff --git a/libs/payments/iap/src/lib/apple/apple-iap-purchase.manager.spec.ts b/libs/payments/iap/src/lib/apple/apple-iap-purchase.manager.spec.ts index 84b85564eb7..bf7e9236e72 100644 --- a/libs/payments/iap/src/lib/apple/apple-iap-purchase.manager.spec.ts +++ b/libs/payments/iap/src/lib/apple/apple-iap-purchase.manager.spec.ts @@ -12,13 +12,17 @@ import { MockAppleIapClientConfigProvider } from './apple-iap.client.config'; import * as repository from './apple-iap-purchase.repository'; import { AppStoreSubscriptionPurchase } from './subscription-purchase'; import { + AppStoreError, decodeRenewalInfo, decodeTransaction, SubscriptionStatus, type StatusResponse, } from 'app-store-server-api'; import { FirestoreAppleIapPurchaseRecordFactory } from '../factories'; -import { AppleIapNotFoundError } from './apple-iap.error'; +import { + AppleIapNotFoundError, + AppleIapServiceUnavailableError, +} from './apple-iap.error'; jest.mock('./apple-iap-purchase.repository', () => ({ getActivePurchasesForUserId: jest.fn(), @@ -33,6 +37,7 @@ jest.mock('app-store-server-api', () => { return { Environment: actual.Environment, SubscriptionStatus: actual.SubscriptionStatus, + AppStoreError: actual.AppStoreError, decodeTransaction: jest.fn(), decodeRenewalInfo: jest.fn(), AppStoreServerAPI: jest.fn().mockImplementation(() => ({})), @@ -214,6 +219,33 @@ describe('AppleIapPurchaseManager', () => { await expect(manager.getForUser(userId)).resolves.not.toThrow(); }); + // Callers decide how to degrade — the read paths skip the purchases, while + // EligibilityService falls back to the cached records — so this has to keep + // propagating rather than being swallowed here. + it('propagates AppleIapServiceUnavailableError', async () => { + const userId = faker.string.uuid(); + + jest.spyOn(repository, 'getActivePurchasesForUserId').mockResolvedValue([ + FirestoreAppleIapPurchaseRecordFactory({ + status: SubscriptionStatus.Expired, + }), + ]); + jest + .spyOn(appleIapClient, 'getSubscriptionStatuses') + .mockRejectedValue( + new AppleIapServiceUnavailableError( + new AppStoreError( + 5000001, + 'An unknown error occurred. Please try again.' + ) + ) + ); + + await expect(manager.getForUser(userId)).rejects.toThrow( + AppleIapServiceUnavailableError + ); + }); + it('throws for unknown errors', async () => { const userId = faker.string.uuid(); @@ -252,6 +284,44 @@ describe('AppleIapPurchaseManager', () => { }); }); + describe('getStaleCachedForUser', () => { + it('returns the cached purchases without querying Apple', async () => { + const userId = faker.string.uuid(); + const originalTransactionId = faker.string.uuid(); + + jest.spyOn(repository, 'getActivePurchasesForUserId').mockResolvedValue([ + FirestoreAppleIapPurchaseRecordFactory({ + status: SubscriptionStatus.Expired, + originalTransactionId, + }), + ]); + jest.spyOn(appleIapClient, 'getSubscriptionStatuses'); + + const result = await manager.getStaleCachedForUser(userId); + + expect(repository.getActivePurchasesForUserId).toHaveBeenCalledWith( + expect.anything(), + userId + ); + expect(appleIapClient.getSubscriptionStatuses).not.toHaveBeenCalled(); + expect(result).toHaveLength(1); + expect(result[0]).toBeInstanceOf(AppStoreSubscriptionPurchase); + expect(result[0].originalTransactionId).toBe(originalTransactionId); + }); + + it('returns an empty list when the user has no cached purchases', async () => { + const userId = faker.string.uuid(); + + jest + .spyOn(repository, 'getActivePurchasesForUserId') + .mockResolvedValue([]); + + const result = await manager.getStaleCachedForUser(userId); + + expect(result).toEqual([]); + }); + }); + describe('getStaleCached', () => { it('returns converted AppStoreSubscriptionPurchase', async () => { const record = { id: faker.string.uuid() }; diff --git a/libs/payments/iap/src/lib/apple/apple-iap-purchase.manager.ts b/libs/payments/iap/src/lib/apple/apple-iap-purchase.manager.ts index 3c89fa33d2f..5a0a9124f01 100644 --- a/libs/payments/iap/src/lib/apple/apple-iap-purchase.manager.ts +++ b/libs/payments/iap/src/lib/apple/apple-iap-purchase.manager.ts @@ -240,6 +240,27 @@ export class AppleIapPurchaseManager { }); } + /** + * Return the user's purchases as they are cached in Firestore, without + * refreshing them against the App Store Server API. + * + * Intended for callers that still have to make a decision when Apple is + * unreachable. Since the records can be stale, an inactive entitlement here + * is not proof that the subscription has lapsed. + */ + async getStaleCachedForUser( + userId: string + ): Promise { + const firestorePurchaseRecords = await getActivePurchasesForUserId( + this.collectionRef, + userId + ); + + return firestorePurchaseRecords.map((firestorePurchaseRecord) => + AppStoreSubscriptionPurchase.fromFirestoreObject(firestorePurchaseRecord) + ); + } + async getStaleCached( originalTransactionId: string ): Promise { diff --git a/libs/payments/iap/src/lib/apple/apple-iap.client.spec.ts b/libs/payments/iap/src/lib/apple/apple-iap.client.spec.ts index fbf166fc0f1..3596ed5591e 100644 --- a/libs/payments/iap/src/lib/apple/apple-iap.client.spec.ts +++ b/libs/payments/iap/src/lib/apple/apple-iap.client.spec.ts @@ -4,8 +4,16 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AppleIapClient } from './apple-iap.client'; -import { AppleIapMissingCredentialsError } from './apple-iap.error'; -import { Environment, StatusResponse } from 'app-store-server-api'; +import { + AppleIapClientBundleError, + AppleIapMissingCredentialsError, + AppleIapServiceUnavailableError, +} from './apple-iap.error'; +import { + AppStoreError, + Environment, + StatusResponse, +} from 'app-store-server-api'; import { AppleIapClientConfig, MockAppleIapClientConfig, @@ -17,6 +25,7 @@ jest.mock('app-store-server-api', () => { const actual = jest.requireActual('app-store-server-api'); return { Environment: actual.Environment, + AppStoreError: actual.AppStoreError, AppStoreServerAPI: jest.fn().mockImplementation(() => ({ getSubscriptionStatuses: jest.fn(), })), @@ -82,6 +91,64 @@ describe('AppleIapClient', () => { ); }); + // Apple reports internal errors in the 5xxxxxx range — 5000001 is the one + // it marks retryable, 5000000 is not — and rate limiting as 4290000. + it.each([ + { + description: 'a retryable internal error', + errorCode: 5000001, + errorMessage: 'An unknown error occurred. Please try again.', + }, + { + description: 'an internal error Apple does not mark retryable', + errorCode: 5000000, + errorMessage: 'An unknown error occurred.', + }, + { + description: 'rate limiting', + errorCode: 4290000, + errorMessage: 'Rate limit exceeded.', + }, + ])( + 'throws AppleIapServiceUnavailableError for $description', + async ({ errorCode, errorMessage }) => { + const mockApiInstance = appleIapClient.appStoreServerApiClients + .values() + .next().value; + assert(mockApiInstance); + + jest + .spyOn(mockApiInstance, 'getSubscriptionStatuses') + .mockRejectedValue(new AppStoreError(errorCode, errorMessage)); + + await expect( + appleIapClient.getSubscriptionStatuses( + appleIapClientConfig.credentials[0].bundleId, + faker.string.uuid() + ) + ).rejects.toThrow(AppleIapServiceUnavailableError); + } + ); + + it('throws AppleIapClientBundleError for an App Store error Apple does not want retried', async () => { + const mockApiInstance = appleIapClient.appStoreServerApiClients + .values() + .next().value; + assert(mockApiInstance); + + jest.spyOn(mockApiInstance, 'getSubscriptionStatuses').mockRejectedValue( + // 4000006 InvalidTransactionIdError — a caller bug, not a retry. + new AppStoreError(4000006, 'Invalid transaction id.') + ); + + await expect( + appleIapClient.getSubscriptionStatuses( + appleIapClientConfig.credentials[0].bundleId, + faker.string.uuid() + ) + ).rejects.toThrow(AppleIapClientBundleError); + }); + it('should throw AppleIapMissingCredentialsError if no credentials exist for bundleId', () => { const mockTransactionId = faker.string.uuid(); const mockBundleId = faker.string.uuid(); diff --git a/libs/payments/iap/src/lib/apple/apple-iap.client.ts b/libs/payments/iap/src/lib/apple/apple-iap.client.ts index 408cf04a770..4c72685734c 100644 --- a/libs/payments/iap/src/lib/apple/apple-iap.client.ts +++ b/libs/payments/iap/src/lib/apple/apple-iap.client.ts @@ -9,6 +9,7 @@ import { AppleIapError, AppleIapMissingCredentialsError, AppleIapNotFoundError, + AppleIapServiceUnavailableError, } from './apple-iap.error'; import { AppStoreError, @@ -20,6 +21,14 @@ import { AppleIapClientConfigCredential, } from './apple-iap.client.config'; +/** + * Apple's error codes are range-structured, so 5xxxxxx identifies a failure on + * Apple's side rather than a problem with our request. + * See https://developer.apple.com/documentation/appstoreserverapi/error-codes + */ +const isAppStoreServerErrorCode = (errorCode: number) => + Math.floor(errorCode / 1_000_000) === 5; + @Injectable() export class AppleIapClient { appStoreServerApiClients = new Map(); @@ -84,6 +93,15 @@ export class AppleIapClient { return new AppleIapNotFoundError(e); } + if ( + e instanceof AppStoreError && + (isAppStoreServerErrorCode(e.errorCode) || + e.isRetryable || + e.isRateLimitExceeded) + ) { + return new AppleIapServiceUnavailableError(e); + } + if (e instanceof Error) { return new AppleIapClientBundleError(e); } diff --git a/libs/payments/iap/src/lib/apple/apple-iap.error.ts b/libs/payments/iap/src/lib/apple/apple-iap.error.ts index 6d336e74fc7..b843ca3afdd 100644 --- a/libs/payments/iap/src/lib/apple/apple-iap.error.ts +++ b/libs/payments/iap/src/lib/apple/apple-iap.error.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 type { AppStoreError } from 'app-store-server-api'; + import { BaseError } from '@fxa/shared/error'; /** @@ -60,6 +62,17 @@ export class GetFromAppStoreIapUnknownError extends AppleIapUnknownError { } } +export class AppleIapServiceUnavailableError extends AppleIapError { + constructor(cause: AppStoreError) { + super( + 'Apple IAP service unavailable', + { errorCode: cause.errorCode, retryAfter: cause.retryAfter }, + cause + ); + this.name = 'AppleIapServiceUnavailableError'; + } +} + export class AppleIapNotFoundError extends AppleIapError { constructor(cause: Error) { super('Apple IAP Not Found (4040010)', cause); diff --git a/packages/fxa-auth-server/lib/payments/initSubplat.ts b/packages/fxa-auth-server/lib/payments/initSubplat.ts index 02bf83c44e7..79397afae88 100644 --- a/packages/fxa-auth-server/lib/payments/initSubplat.ts +++ b/packages/fxa-auth-server/lib/payments/initSubplat.ts @@ -160,7 +160,8 @@ export async function initSubplat({ eligibilityManager, subscriptionManager, googleIapPurchaseManager, - appleIapPurchaseManager + appleIapPurchaseManager, + logger ); const churnInterventionService = new ChurnInterventionService( accountCustomerManager,