Skip to content

Commit acb82e8

Browse files
authored
Merge pull request #18535 from mozilla/FXA-7836
feat(payments-iap): create payments-iap library
2 parents 896bb10 + 32b987a commit acb82e8

22 files changed

+940
-0
lines changed

libs/payments/iap/.eslintrc.json

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"extends": ["../../../.eslintrc.json"],
3+
"ignorePatterns": ["!**/*"],
4+
"overrides": [
5+
{
6+
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
7+
"rules": {}
8+
},
9+
{
10+
"files": ["*.ts", "*.tsx"],
11+
"rules": {}
12+
},
13+
{
14+
"files": ["*.js", "*.jsx"],
15+
"rules": {}
16+
}
17+
]
18+
}

libs/payments/iap/.swcrc

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"jsc": {
3+
"target": "es2017",
4+
"parser": {
5+
"syntax": "typescript",
6+
"decorators": true,
7+
"dynamicImport": true
8+
},
9+
"transform": {
10+
"decoratorMetadata": true,
11+
"legacyDecorator": true
12+
}
13+
}
14+
}
15+

libs/payments/iap/README.md

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# payments-iap
2+
3+
This library was generated with [Nx](https://nx.dev).
4+
5+
It serves as a client for communicating with the CMS GraphQL API, and supports strongly typed queries via codegen. See the codegen section for more information about how to manage typings.
6+
7+
## Building
8+
9+
Run `nx build payments-iap` to build the library.
10+
11+
## Running unit tests
12+
13+
Run `nx test payments-iap` to execute the unit tests via [Jest](https://jestjs.io).

libs/payments/iap/jest.config.ts

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/* eslint-disable */
2+
import { readFileSync } from 'fs';
3+
4+
// Reading the SWC compilation config and remove the "exclude"
5+
// for the test files to be compiled by SWC
6+
const { exclude: _, ...swcJestConfig } = JSON.parse(
7+
readFileSync(`${__dirname}/.swcrc`, 'utf-8')
8+
);
9+
10+
// disable .swcrc look-up by SWC core because we're passing in swcJestConfig ourselves.
11+
// If we do not disable this, SWC Core will read .swcrc and won't transform our test files due to "exclude"
12+
if (swcJestConfig.swcrc === undefined) {
13+
swcJestConfig.swcrc = false;
14+
}
15+
16+
// Uncomment if using global setup/teardown files being transformed via swc
17+
// https://nx.dev/packages/jest/documents/overview#global-setup/teardown-with-nx-libraries
18+
// jest needs EsModule Interop to find the default exported setup/teardown functions
19+
// swcJestConfig.module.noInterop = false;
20+
21+
export default {
22+
displayName: 'payments-iap',
23+
preset: '../../../jest.preset.js',
24+
transform: {
25+
'^.+\\.[tj]s$': ['@swc/jest', swcJestConfig],
26+
},
27+
moduleFileExtensions: ['ts', 'js', 'html'],
28+
testEnvironment: 'node',
29+
coverageDirectory: '../../../coverage/libs/payments/iap',
30+
};

libs/payments/iap/package.json

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"name": "@fxa/payments/iap",
3+
"version": "0.0.1"
4+
}

libs/payments/iap/project.json

+60
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
{
2+
"name": "payments-iap",
3+
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
4+
"sourceRoot": "libs/payments/iap/src",
5+
"projectType": "library",
6+
"tags": [],
7+
"targets": {
8+
"codegen": {
9+
"executor": "nx:run-commands",
10+
"options": {
11+
"command": "graphql-codegen --config libs/payments/iap/codegen.config.ts"
12+
}
13+
},
14+
"build": {
15+
"executor": "@nx/esbuild:esbuild",
16+
"outputs": ["{options.outputPath}"],
17+
"defaultConfiguration": "production",
18+
"options": {
19+
"main": "libs/payments/iap/src/index.ts",
20+
"outputPath": "dist/libs/payments/iap",
21+
"outputFileName": "main.js",
22+
"tsConfig": "libs/payments/iap/tsconfig.lib.json",
23+
"declaration": true,
24+
"assets": [
25+
{
26+
"glob": "libs/payments/iap/README.md",
27+
"input": ".",
28+
"output": "."
29+
}
30+
],
31+
"platform": "node"
32+
},
33+
"configurations": {
34+
"development": {
35+
"minify": false
36+
},
37+
"production": {
38+
"minify": true
39+
}
40+
}
41+
},
42+
"lint": {
43+
"executor": "@nx/linter:eslint",
44+
"outputs": ["{options.outputFile}"],
45+
"options": {
46+
"lintFilePatterns": [
47+
"libs/payments/iap/**/*.ts",
48+
"libs/payments/iap/package.json"
49+
]
50+
}
51+
},
52+
"test-unit": {
53+
"executor": "@nx/jest:jest",
54+
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
55+
"options": {
56+
"jestConfig": "libs/payments/iap/jest.config.ts"
57+
}
58+
}
59+
}
60+
}

libs/payments/iap/src/index.ts

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
export * from './lib/constants';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import { faker } from '@faker-js/faker';
6+
import { Provider } from '@nestjs/common';
7+
import { Environment } from 'app-store-server-api';
8+
import { Type } from 'class-transformer';
9+
import { IsArray, IsEnum, IsString, ValidateNested } from 'class-validator';
10+
11+
export class AppleIapClientConfigCredential {
12+
@IsString()
13+
public readonly key!: string;
14+
15+
@IsString()
16+
public readonly keyId!: string;
17+
18+
@IsString()
19+
public readonly issuerId!: string;
20+
21+
@IsString()
22+
public readonly bundleId!: string;
23+
}
24+
25+
export class AppleIapClientConfig {
26+
@Type(() => AppleIapClientConfigCredential)
27+
@ValidateNested({ each: true })
28+
@IsArray()
29+
public readonly credentials!: AppleIapClientConfigCredential[];
30+
31+
@IsEnum(Environment)
32+
public readonly environment!: Environment;
33+
}
34+
35+
export const MockAppleIapClientConfig = {
36+
credentials: [
37+
{
38+
key: faker.string.uuid(),
39+
keyId: faker.string.uuid(),
40+
issuerId: faker.string.uuid(),
41+
bundleId: faker.string.uuid(),
42+
},
43+
],
44+
environment: Environment.Sandbox,
45+
} satisfies AppleIapClientConfig;
46+
47+
export const MockAppleIapClientConfigProvider = {
48+
provide: AppleIapClientConfig,
49+
useValue: MockAppleIapClientConfig,
50+
} satisfies Provider<AppleIapClientConfig>;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import { Test, TestingModule } from '@nestjs/testing';
6+
import { AppleIapClient } from './apple-iap.client';
7+
import { AppleIapMissingCredentialsError } from './apple-iap.error';
8+
import { Environment, StatusResponse } from 'app-store-server-api';
9+
import {
10+
AppleIapClientConfig,
11+
MockAppleIapClientConfigProvider,
12+
} from './apple-iap.client.config';
13+
import assert from 'assert';
14+
import { faker } from '@faker-js/faker';
15+
16+
jest.mock('app-store-server-api', () => {
17+
const actual = jest.requireActual('app-store-server-api');
18+
return {
19+
Environment: actual.Environment,
20+
AppStoreServerAPI: jest.fn().mockImplementation(() => ({
21+
getSubscriptionStatuses: jest.fn(),
22+
})),
23+
};
24+
});
25+
26+
describe('AppleIapClient', () => {
27+
let appleIapClient: AppleIapClient;
28+
let appleIapClientConfig: AppleIapClientConfig;
29+
30+
beforeEach(async () => {
31+
const module: TestingModule = await Test.createTestingModule({
32+
providers: [MockAppleIapClientConfigProvider, AppleIapClient],
33+
}).compile();
34+
35+
appleIapClient = module.get(AppleIapClient);
36+
appleIapClientConfig = module.get(AppleIapClientConfig);
37+
});
38+
39+
describe('getSubscriptionStatuses', () => {
40+
it('should return subscription status', async () => {
41+
const mockTransactionId = faker.string.uuid();
42+
const mockStatusResponse = {
43+
environment: Environment.Sandbox,
44+
} as StatusResponse;
45+
const mockApiInstance = appleIapClient.appStoreServerApiClients
46+
.values()
47+
.next().value;
48+
assert(mockApiInstance);
49+
50+
jest
51+
.spyOn(mockApiInstance, 'getSubscriptionStatuses')
52+
.mockResolvedValue(mockStatusResponse);
53+
54+
const result = await appleIapClient.getSubscriptionStatuses(
55+
appleIapClientConfig.credentials[0].bundleId,
56+
mockTransactionId
57+
);
58+
59+
expect(result).toEqual(mockStatusResponse);
60+
expect(mockApiInstance.getSubscriptionStatuses).toHaveBeenCalledWith(
61+
mockTransactionId
62+
);
63+
});
64+
65+
it('should throw AppleIapMissingCredentialsError if no credentials exist for bundleId', () => {
66+
const mockTransactionId = faker.string.uuid();
67+
const mockBundleId = faker.string.uuid();
68+
69+
expect(async () =>
70+
appleIapClient.getSubscriptionStatuses(mockBundleId, mockTransactionId)
71+
).rejects.toThrowError(AppleIapMissingCredentialsError);
72+
});
73+
});
74+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import { Injectable } from '@nestjs/common';
6+
import {
7+
AppleIapError,
8+
AppleIapMissingCredentialsError,
9+
AppleIapUnknownError,
10+
} from './apple-iap.error';
11+
import { AppStoreServerAPI, StatusResponse } from 'app-store-server-api';
12+
import {
13+
AppleIapClientConfig,
14+
AppleIapClientConfigCredential,
15+
} from './apple-iap.client.config';
16+
17+
@Injectable()
18+
export class AppleIapClient {
19+
appStoreServerApiClients = new Map<string, AppStoreServerAPI>();
20+
private credentialsByBundleId = new Map<
21+
string,
22+
AppleIapClientConfigCredential
23+
>();
24+
25+
constructor(private config: AppleIapClientConfig) {
26+
for (const credential of config.credentials) {
27+
this.credentialsByBundleId.set(credential.bundleId, credential);
28+
this.clientByBundleId(credential.bundleId);
29+
}
30+
}
31+
32+
async getSubscriptionStatuses(
33+
bundleId: string,
34+
originalTransactionId: string
35+
): Promise<StatusResponse> {
36+
try {
37+
const apiClient = this.clientByBundleId(bundleId);
38+
return await apiClient.getSubscriptionStatuses(originalTransactionId);
39+
} catch (e) {
40+
throw this.convertError(e);
41+
}
42+
}
43+
44+
/**
45+
* Returns an App Store Server API client by bundleId, initializing it first
46+
* if needed.
47+
*/
48+
private clientByBundleId(bundleId: string): AppStoreServerAPI {
49+
const existingClient = this.appStoreServerApiClients.get(bundleId);
50+
if (existingClient) {
51+
return existingClient;
52+
}
53+
54+
const credential = this.credentialsByBundleId.get(bundleId);
55+
if (!credential) {
56+
throw new AppleIapMissingCredentialsError(bundleId);
57+
}
58+
59+
const { key, keyId, issuerId } = credential;
60+
61+
const client = new AppStoreServerAPI(
62+
key,
63+
keyId,
64+
issuerId,
65+
bundleId,
66+
this.config.environment
67+
);
68+
this.appStoreServerApiClients.set(bundleId, client);
69+
return client;
70+
}
71+
72+
private convertError(e: unknown) {
73+
if (e instanceof AppleIapError) {
74+
return e;
75+
}
76+
77+
if (e instanceof Error) {
78+
return new AppleIapUnknownError('Unknown Apple IAP Error', {
79+
cause: e,
80+
});
81+
}
82+
83+
return new AppleIapUnknownError('Unknown Apple IAP Error', {
84+
cause: new Error(`Unknown error: ${e}`),
85+
});
86+
}
87+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import { BaseError } from '@fxa/shared/error';
6+
7+
export class AppleIapError extends BaseError {
8+
constructor(...args: ConstructorParameters<typeof BaseError>) {
9+
super(...args);
10+
}
11+
}
12+
13+
export class AppleIapUnknownError extends AppleIapError {
14+
constructor(...args: ConstructorParameters<typeof AppleIapError>) {
15+
super(...args);
16+
}
17+
}
18+
19+
export class AppleIapMissingCredentialsError extends AppleIapError {
20+
constructor(bundleId: string) {
21+
super(`No App Store credentials found for app with bundleId: ${bundleId}.`);
22+
}
23+
}

0 commit comments

Comments
 (0)