diff --git a/libs/payments/cart/.eslintrc.json b/libs/payments/cart/.eslintrc.json new file mode 100644 index 0000000000..3456be9b90 --- /dev/null +++ b/libs/payments/cart/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/payments/cart/README.md b/libs/payments/cart/README.md new file mode 100644 index 0000000000..01a8945732 --- /dev/null +++ b/libs/payments/cart/README.md @@ -0,0 +1,11 @@ +# payments-cart + +This library was generated with [Nx](https://nx.dev). + +## Building + +Run `nx build payments-cart` to build the library. + +## Running unit tests + +Run `nx test payments-cart` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/libs/payments/cart/jest.config.ts b/libs/payments/cart/jest.config.ts new file mode 100644 index 0000000000..57e9e351ba --- /dev/null +++ b/libs/payments/cart/jest.config.ts @@ -0,0 +1,11 @@ +/* eslint-disable */ +export default { + displayName: 'payments-cart', + preset: '../../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../../coverage/libs/payments/cart', +}; diff --git a/libs/payments/cart/package.json b/libs/payments/cart/package.json new file mode 100644 index 0000000000..9186ec982d --- /dev/null +++ b/libs/payments/cart/package.json @@ -0,0 +1,5 @@ +{ + "name": "@fxa/payments/cart", + "version": "0.0.1", + "type": "commonjs" +} diff --git a/libs/payments/cart/project.json b/libs/payments/cart/project.json new file mode 100644 index 0000000000..15393d8dc2 --- /dev/null +++ b/libs/payments/cart/project.json @@ -0,0 +1,40 @@ +{ + "name": "payments-cart", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/payments/cart/src", + "projectType": "library", + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/payments/cart", + "main": "libs/payments/cart/src/index.ts", + "tsConfig": "libs/payments/cart/tsconfig.lib.json", + "assets": ["libs/payments/cart/*.md"] + } + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["libs/payments/cart/**/*.ts"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/payments/cart/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + } + }, + "tags": [] +} diff --git a/libs/payments/cart/src/gql/cart-id.input.ts b/libs/payments/cart/src/gql/cart-id.input.ts new file mode 100644 index 0000000000..91b3c1a51c --- /dev/null +++ b/libs/payments/cart/src/gql/cart-id.input.ts @@ -0,0 +1,12 @@ +/* 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 { Field, InputType } from '@nestjs/graphql'; + +@InputType() +export class CartIdInput { + @Field({ + description: 'Cart ID', + }) + public id!: string; +} diff --git a/libs/payments/cart/src/gql/cart.model.ts b/libs/payments/cart/src/gql/cart.model.ts new file mode 100644 index 0000000000..947611b552 --- /dev/null +++ b/libs/payments/cart/src/gql/cart.model.ts @@ -0,0 +1,79 @@ +/* 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 { Field, ID, ObjectType, registerEnumType } from '@nestjs/graphql'; +import { CartState } from '../lib/types'; +import { TaxAddress } from './'; +import { Invoice } from './invoice.model'; + +registerEnumType(CartState, { + name: 'CartState', +}); + +@ObjectType({ + description: + 'The Cart associated with a customer Subscription Platform checkout', +}) +export class Cart { + @Field((type) => ID, { description: 'Cart unique identifier' }) + public id!: string; + + @Field((type) => ID, { + nullable: true, + description: 'Firefox Account User ID', + }) + public uid?: string; + + @Field((type) => CartState, { description: 'State of the cart' }) + public state!: CartState; + + @Field({ nullable: true, description: 'Error reason ID' }) + public errorReasonId?: string; + + @Field({ description: 'Offering ID configured in the CMS' }) + public offeringConfigId!: string; + + @Field({ description: 'Interval' }) + public interval!: string; + + @Field({ + nullable: true, + description: 'Experiment associated with the cart', + }) + public experiment?: string; + + @Field((type) => TaxAddress, { + nullable: true, + description: 'Tax address', + }) + public taxAddress?: TaxAddress; + + @Field((type) => Invoice, { description: 'The previous invoice' }) + public previousInvoice?: Invoice; + + @Field((type) => Invoice, { + description: 'The next, also known as upcoming, invoice', + }) + public nextInvoice!: Invoice; + + @Field({ description: 'Timestamp when the cart was created' }) + public createdAt!: number; + + @Field({ description: 'Timestamp the cart was last updated' }) + public updatedAt!: number; + + @Field({ nullable: true, description: 'Applied coupon code' }) + public couponCode?: string; + + @Field({ + nullable: true, + description: 'Stripe customer ID of cart customer', + }) + public stripeCustomerId?: string; + + @Field({ nullable: true, description: 'Email set by customer' }) + public email?: string; + + @Field({ description: 'Amount of plan at checkout' }) + public amount!: number; +} diff --git a/libs/payments/cart/src/gql/cart.resolver.spec.ts b/libs/payments/cart/src/gql/cart.resolver.spec.ts new file mode 100644 index 0000000000..8470d8556b --- /dev/null +++ b/libs/payments/cart/src/gql/cart.resolver.spec.ts @@ -0,0 +1,90 @@ +/* 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 { Provider } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { MozLoggerService } from 'fxa-shared/nestjs/logger/logger.service'; +import { Logger } from '../../../../shared/log/src'; +import { + CartIdInputFactory, + SetupCartInputFactory, + UpdateCartInputFactory, +} from '../lib/factories'; +import { CartManager } from '../lib/manager'; +import { CartResolver } from './cart.resolver'; +const fakeSetupCart = jest.fn(); +const fakeRestartCart = jest.fn(); +const fakeCheckoutCart = jest.fn(); +const fakeUpdateCart = jest.fn(); + +jest.mock('../lib/manager', () => { + // Works and lets you check for constructor calls: + return { + CartManager: jest.fn().mockImplementation(() => { + return { + setupCart: fakeSetupCart, + restartCart: fakeRestartCart, + checkoutCart: fakeCheckoutCart, + updateCart: fakeUpdateCart, + }; + }), + }; +}); + +describe('#payments-cart - resolvers', () => { + let resolver: CartResolver; + const mockLogger: Logger = { + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + trace: jest.fn(), + warn: jest.fn(), + }; + + beforeEach(async () => { + (CartManager as jest.Mock).mockClear(); + + const MockMozLogger: Provider = { + provide: MozLoggerService, + useValue: mockLogger, + }; + const module: TestingModule = await Test.createTestingModule({ + providers: [CartResolver, MockMozLogger], + }).compile(); + + resolver = module.get(CartResolver); + }); + describe('setupCart', () => { + it('should successfully create a new cart', async () => { + const input = SetupCartInputFactory({ + interval: 'annually', + }); + await resolver.setupCart(input); + expect(fakeSetupCart).toBeCalledWith(expect.objectContaining(input)); + }); + }); + + describe('restartCart', () => { + it('should successfully set cart state to "START"', async () => { + const input = CartIdInputFactory(); + await resolver.restartCart(input); + expect(fakeRestartCart).toBeCalledWith(input.id); + }); + }); + + describe('checkoutCart', () => { + it('should successfully set cart state to "PROCESSING"', async () => { + const input = CartIdInputFactory(); + await resolver.checkoutCart(input); + expect(fakeCheckoutCart).toBeCalledWith(input.id); + }); + }); + + describe('updateCart', () => { + it('should successfully update an existing cart', async () => { + const input = UpdateCartInputFactory(); + await resolver.updateCart(input); + expect(fakeUpdateCart).toBeCalledWith(input); + }); + }); +}); diff --git a/libs/payments/cart/src/gql/cart.resolver.ts b/libs/payments/cart/src/gql/cart.resolver.ts new file mode 100644 index 0000000000..50b9c45c94 --- /dev/null +++ b/libs/payments/cart/src/gql/cart.resolver.ts @@ -0,0 +1,71 @@ +/* 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 { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; +import { MozLoggerService } from 'fxa-shared/nestjs/logger/logger.service'; +import { Cart } from '../../../../shared/db/mysql/account/src'; +import { InvoiceFactory } from '../lib/factories'; +import { CartManager } from '../lib/manager'; +import { + CartIdInput, + Cart as CartType, + SetupCartInput, + UpdateCartInput, +} from './index'; + +@Resolver((of: any) => CartType) +export class CartResolver { + private cartManager: CartManager; + constructor(private log: MozLoggerService) { + this.cartManager = new CartManager(this.log); + } + + @Query((returns) => CartType, { nullable: true }) + public async cart(): Promise { + // Query just for testing purposes + // TODO - To be done in FXA-7521 + const cart = await Cart.query().first(); + if (!cart) { + return null; + } + + return { + ...cart, + nextInvoice: InvoiceFactory(), // Temporary + }; + } + + @Mutation((returns) => CartType, { nullable: true }) + public async setupCart( + @Args('input', { type: () => SetupCartInput }) + input: SetupCartInput + ): Promise { + return this.cartManager.setupCart({ + ...input, + }); + } + + @Mutation((returns) => CartType, { nullable: true }) + public async restartCart( + @Args('input', { type: () => CartIdInput }) + input: CartIdInput + ): Promise { + return this.cartManager.restartCart(input.id); + } + + @Mutation((returns) => CartType, { nullable: true }) + public async checkoutCart( + @Args('input', { type: () => CartIdInput }) + input: CartIdInput + ): Promise { + return this.cartManager.checkoutCart(input.id); + } + + @Mutation((returns) => CartType, { nullable: true }) + public async updateCart( + @Args('input', { type: () => UpdateCartInput }) + input: UpdateCartInput + ): Promise { + return this.cartManager.updateCart(input); + } +} diff --git a/libs/payments/cart/src/gql/index.ts b/libs/payments/cart/src/gql/index.ts new file mode 100644 index 0000000000..9ccf1ad7b5 --- /dev/null +++ b/libs/payments/cart/src/gql/index.ts @@ -0,0 +1,12 @@ +/* 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 { CartIdInput } from './cart-id.input'; +export { Cart } from './cart.model'; +export { CartResolver } from './cart.resolver'; +export { Invoice } from './invoice.model'; +export { SetupCartInput } from './setup-cart.input'; +export { Subscription } from './subscription.model'; +export { TaxAddress } from './tax-address.model'; +export { TaxAmount } from './tax-amount.model'; +export { UpdateCartInput } from './update-cart.input'; diff --git a/libs/payments/cart/src/gql/invoice.model.ts b/libs/payments/cart/src/gql/invoice.model.ts new file mode 100644 index 0000000000..0f95fd3134 --- /dev/null +++ b/libs/payments/cart/src/gql/invoice.model.ts @@ -0,0 +1,16 @@ +/* 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 { Field, ObjectType } from '@nestjs/graphql'; +import { TaxAmount } from './tax-amount.model'; + +@ObjectType() +export class Invoice { + @Field({ description: 'Total of invoice' }) + public totalAmount!: number; + + @Field((type) => [TaxAmount], { + description: 'Tax amounts of the invoice', + }) + public taxAmounts!: TaxAmount[]; +} diff --git a/libs/payments/cart/src/gql/setup-cart.input.ts b/libs/payments/cart/src/gql/setup-cart.input.ts new file mode 100644 index 0000000000..089a4ef579 --- /dev/null +++ b/libs/payments/cart/src/gql/setup-cart.input.ts @@ -0,0 +1,25 @@ +/* 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 { Field, InputType } from '@nestjs/graphql'; + +@InputType() +export class SetupCartInput { + @Field() + public offeringConfigId!: string; + + @Field({ nullable: true }) + public interval?: string; + + @Field({ + nullable: true, + description: 'Cart ID', + }) + public id?: string; + + @Field({ + nullable: true, + description: 'FxA UID', + }) + public uid?: string; +} diff --git a/libs/payments/cart/src/gql/subscription.model.ts b/libs/payments/cart/src/gql/subscription.model.ts new file mode 100644 index 0000000000..28751aa7df --- /dev/null +++ b/libs/payments/cart/src/gql/subscription.model.ts @@ -0,0 +1,19 @@ +/* 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 { Field, ObjectType } from '@nestjs/graphql'; +import { Invoice } from './invoice.model'; + +@ObjectType({ + description: 'Subscription representation used within the Cart context', +}) +export class Subscription { + @Field() + public pageConfigId!: string; + + @Field((type) => Invoice) + public previousInvoice!: Invoice; + + @Field((type) => Invoice) + public nextInvoice!: Invoice; +} diff --git a/libs/payments/cart/src/gql/tax-address.model.ts b/libs/payments/cart/src/gql/tax-address.model.ts new file mode 100644 index 0000000000..7ab0aeea51 --- /dev/null +++ b/libs/payments/cart/src/gql/tax-address.model.ts @@ -0,0 +1,18 @@ +/* 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 { Field, ObjectType } from '@nestjs/graphql'; + +@ObjectType({ + description: 'The Tax Address associated with the Cart', +}) +export class TaxAddress { + @Field({ + nullable: true, + description: 'Country code for tax', + }) + public countryCode!: string; + + @Field({ description: 'Postal code for tax' }) + public postalCode!: string; +} diff --git a/libs/payments/cart/src/gql/tax-amount.model.ts b/libs/payments/cart/src/gql/tax-amount.model.ts new file mode 100644 index 0000000000..cda3cb65a1 --- /dev/null +++ b/libs/payments/cart/src/gql/tax-amount.model.ts @@ -0,0 +1,15 @@ +/* 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 { Field, ObjectType } from '@nestjs/graphql'; + +@ObjectType({ + description: 'Tax amounts used within the Cart', +}) +export class TaxAmount { + @Field() + public title!: string; + + @Field() + public amount!: number; +} diff --git a/libs/payments/cart/src/gql/update-cart.input.ts b/libs/payments/cart/src/gql/update-cart.input.ts new file mode 100644 index 0000000000..0cd990db8e --- /dev/null +++ b/libs/payments/cart/src/gql/update-cart.input.ts @@ -0,0 +1,13 @@ +/* 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 { Field, InputType } from '@nestjs/graphql'; +import { SetupCartInput } from './setup-cart.input'; + +@InputType() +export class UpdateCartInput extends SetupCartInput { + @Field({ + description: 'Cart ID', + }) + public id!: string; +} diff --git a/libs/payments/cart/src/index.ts b/libs/payments/cart/src/index.ts new file mode 100644 index 0000000000..1bcbd9d132 --- /dev/null +++ b/libs/payments/cart/src/index.ts @@ -0,0 +1,6 @@ +/* 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 * from './lib/types'; +export * from './lib/factories'; +export * from './gql'; diff --git a/libs/payments/cart/src/lib/factories.ts b/libs/payments/cart/src/lib/factories.ts new file mode 100644 index 0000000000..9c5851fbed --- /dev/null +++ b/libs/payments/cart/src/lib/factories.ts @@ -0,0 +1,96 @@ +/* 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 { faker } from '@faker-js/faker'; +import { + Cart, + CartIdInput, + Invoice, + SetupCartInput, + Subscription, + TaxAddress, + TaxAmount, + UpdateCartInput, +} from '../gql'; +import { CartState, SetupCart } from './types'; + +const OFFERING_CONFIG_IDS = [ + 'vpn', + 'relay-phone', + 'relay-email', + 'hubs', + 'mdnplus', +]; + +export const CartFactory = (override?: Partial): Cart => ({ + id: faker.string.uuid(), + state: CartState.START, + offeringConfigId: faker.helpers.arrayElement(OFFERING_CONFIG_IDS), + interval: faker.helpers.arrayElement([ + 'daily', + 'monthly', + 'semiannually', + 'annually', + ]), + nextInvoice: InvoiceFactory(), + createdAt: faker.date.recent().getTime(), + updatedAt: faker.date.recent().getTime(), + amount: faker.number.int(10000), + ...override, +}); + +export const TaxAmountFactory = (override?: Partial): TaxAmount => ({ + title: faker.location.state({ abbreviated: true }), + amount: faker.number.int(10000), + ...override, +}); + +export const InvoiceFactory = (override?: Partial): Invoice => ({ + totalAmount: faker.number.int(10000), + taxAmounts: [TaxAmountFactory()], + ...override, +}); + +export const SubscriptionFactory = ( + override?: Partial +): Subscription => ({ + pageConfigId: faker.helpers.arrayElement(['default', 'alternate-pricing']), + previousInvoice: InvoiceFactory(), + nextInvoice: InvoiceFactory(), + ...override, +}); + +export const TaxAddressFactory = ( + override?: Partial +): TaxAddress => ({ + countryCode: faker.location.countryCode(), + postalCode: faker.location.zipCode(), + ...override, +}); + +export const CartIdInputFactory = ( + override?: Partial +): CartIdInput => ({ + id: faker.string.uuid(), + ...override, +}); + +export const SetupCartInputFactory = ( + override?: Partial +): SetupCartInput => ({ + offeringConfigId: faker.helpers.arrayElement(OFFERING_CONFIG_IDS), + ...override, +}); + +export const UpdateCartInputFactory = ( + override?: Partial +): UpdateCartInput => ({ + id: faker.string.uuid(), + offeringConfigId: faker.helpers.arrayElement(OFFERING_CONFIG_IDS), + ...override, +}); + +export const SetupCartFactory = (override?: Partial): SetupCart => ({ + offeringConfigId: faker.helpers.arrayElement(OFFERING_CONFIG_IDS), + ...override, +}); diff --git a/libs/payments/cart/src/lib/manager.spec.ts b/libs/payments/cart/src/lib/manager.spec.ts new file mode 100644 index 0000000000..3224e4c1e2 --- /dev/null +++ b/libs/payments/cart/src/lib/manager.spec.ts @@ -0,0 +1,113 @@ +/* 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 { Knex } from 'knex'; +import { NotFoundError } from 'objection'; +import { v4 as uuidv4 } from 'uuid'; +import { Cart, CartFactory } from '../../../../shared/db/mysql/account/src'; +import { Logger } from '../../../../shared/log/src'; +import { SetupCartFactory, UpdateCartInputFactory } from './factories'; +import { CartManager, ERRORS } from './manager'; +import { testCartDatabaseSetup } from './tests'; +import { CartState } from './types'; + +const CART_ID = '8730e0d5939c450286e6e6cc1bbeeab2'; +const RANDOM_ID = uuidv4({}, Buffer.alloc(16)).toString('hex'); + +describe('#payments-cart - manager', () => { + let knex: Knex; + let cartManager: CartManager; + const mockLogger: Logger = { + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + trace: jest.fn(), + warn: jest.fn(), + }; + + beforeAll(async () => { + cartManager = new CartManager(mockLogger); + knex = await testCartDatabaseSetup(); + await Cart.query().insert({ + ...CartFactory(), + id: CART_ID, + }); + }); + + afterAll(async () => { + await knex.destroy(); + }); + + describe('setupCart', () => { + it('should successfully create a new cart', async () => { + const setupCart = SetupCartFactory({ + interval: 'annually', + }); + const cart = await cartManager.setupCart(setupCart); + expect(cart).toEqual(expect.objectContaining(setupCart)); + }); + }); + + describe('restartCart', () => { + it('should successfully set cart state to "START"', async () => { + await Cart.query().update({ state: CartState.PROCESSING }); + const cart = await cartManager.restartCart(CART_ID); + expect(cart?.state).toBe(CartState.START); + }); + + it('should return null if no cart with provided ID is found', async () => { + try { + await cartManager.restartCart(RANDOM_ID); + } catch (error) { + expect(error).toStrictEqual( + new NotFoundError({ message: ERRORS.CART_NOT_FOUND }) + ); + } + }); + }); + + describe('checkoutCart', () => { + it('should successfully set cart state to "PROCESSING"', async () => { + await Cart.query().update({ state: CartState.START }); + const cart = await cartManager.checkoutCart(CART_ID); + expect(cart?.state).toBe(CartState.PROCESSING); + }); + + it('should return null if no cart with provided ID is found', async () => { + try { + await cartManager.checkoutCart(RANDOM_ID); + } catch (error) { + expect(error).toStrictEqual( + new NotFoundError({ message: ERRORS.CART_NOT_FOUND }) + ); + } + }); + }); + + describe('updateCart', () => { + it('should successfully update an existing cart', async () => { + const updateCart = UpdateCartInputFactory({ + id: CART_ID, + interval: 'monthly', + offeringConfigId: 'randomproduct', + }); + const cart = await cartManager.updateCart(updateCart); + expect(cart).toEqual(expect.objectContaining(updateCart)); + }); + + it('should return null if no cart with provided ID is found', async () => { + const updateCart = UpdateCartInputFactory({ + id: RANDOM_ID, + interval: 'monthly', + offeringConfigId: 'randomproduct', + }); + try { + await cartManager.updateCart(updateCart); + } catch (error) { + expect(error).toStrictEqual( + new NotFoundError({ message: ERRORS.CART_NOT_FOUND }) + ); + } + }); + }); +}); diff --git a/libs/payments/cart/src/lib/manager.ts b/libs/payments/cart/src/lib/manager.ts new file mode 100644 index 0000000000..12a17304a6 --- /dev/null +++ b/libs/payments/cart/src/lib/manager.ts @@ -0,0 +1,115 @@ +/* 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 { Cart } from '../../../../shared/db/mysql/account/src'; +import { + generateFxAUuid, + uuidTransformer, +} from '../../../../shared/db/mysql/core/src'; +import { Logger } from '../../../../shared/log/src'; +import { Cart as CartType } from '../gql'; +import { UpdateCartInput } from '../gql/update-cart.input'; +import { InvoiceFactory } from './factories'; +import { CartState, SetupCart } from './types'; + +const DEFAULT_INTERVAL = 'monthly'; + +// TODO - Adopt error library developed as part of FXA-7656 +export enum ERRORS { + CART_NOT_FOUND = 'Cart not found for id', +} + +export class CartManager { + private log: Logger; + constructor(log: Logger) { + this.log = log; + } + + public async setupCart(input: SetupCart): Promise { + const currentDate = Date.now(); + const cart = await Cart.query().insert({ + ...input, + id: generateFxAUuid(), + state: CartState.START, + interval: input.interval || DEFAULT_INTERVAL, + amount: 0, // Hardcoded to 0 for now. TODO - Actual amount to be added in FXA-7521 + createdAt: currentDate, + updatedAt: currentDate, + }); + + return { + ...cart, + nextInvoice: InvoiceFactory(), // Temporary + }; + } + + public async restartCart(cartId: string): Promise { + const id = uuidTransformer.to(cartId); + const cart = await Cart.query() + .where('id', id) + .first() + .throwIfNotFound({ message: ERRORS.CART_NOT_FOUND }); + + // Patch and fetch instance + // Use update if you update the whole row with all its columns. Otherwise, using the patch method is recommended. + // https://vincit.github.io/objection.js/api/query-builder/mutate-methods.html#update + await cart + .$query() + .patchAndFetch({ state: CartState.START, updatedAt: Date.now() }); + + // Patch changes to the DB + await Cart.query().patch(cart).where('id', id); + + return { + ...cart, + nextInvoice: InvoiceFactory(), // Temporary + }; + } + + public async checkoutCart(cartId: string): Promise { + const id = uuidTransformer.to(cartId); + const cart = await Cart.query() + .where('id', id) + .first() + .throwIfNotFound({ message: ERRORS.CART_NOT_FOUND }); + // Patch and fetch instance + // Use update if you update the whole row with all its columns. Otherwise, using the patch method is recommended. + // https://vincit.github.io/objection.js/api/query-builder/mutate-methods.html#update + await cart + .$query() + .patchAndFetch({ state: CartState.PROCESSING, updatedAt: Date.now() }); + + // Patch changes to the DB + await Cart.query().patch(cart).where('id', id); + + return { + ...cart, + nextInvoice: InvoiceFactory(), // Temporary + }; + } + + public async updateCart(input: UpdateCartInput): Promise { + const { id: cartId, ...rest } = input; + const id = uuidTransformer.to(cartId); + + const cart = await Cart.query() + .where('id', id) + .first() + .throwIfNotFound({ message: ERRORS.CART_NOT_FOUND }); + // Patch and fetch instance + // Use update if you update the whole row with all its columns. Otherwise, using the patch method is recommended. + // https://vincit.github.io/objection.js/api/query-builder/mutate-methods.html#update + await cart.$query().patchAndFetch({ + ...rest, + updatedAt: Date.now(), + }); + + // Patch changes to the DB + await Cart.query().patch(cart).where('id', id); + + return { + ...cart, + nextInvoice: InvoiceFactory(), // Temporary + }; + } +} diff --git a/libs/payments/cart/src/lib/tests.ts b/libs/payments/cart/src/lib/tests.ts new file mode 100644 index 0000000000..5673e15113 --- /dev/null +++ b/libs/payments/cart/src/lib/tests.ts @@ -0,0 +1,42 @@ +/* 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 knex, { Knex } from 'knex'; +import { + runSql, + setupAccountDatabase, +} from '../../../../shared/db/mysql/core/src'; + +const CART_TEST_DB = 'testCart'; +const SQL_FILE_LOCATION = '../../../account/src/test'; + +export async function testCartDatabaseSetup(): Promise { + // Create the db if it doesn't exist + let instance = knex({ + client: 'mysql', + connection: { + charset: 'UTF8MB4_BIN', + host: 'localhost', + password: '', + port: 3306, + user: 'root', + }, + }); + + await instance.raw(`DROP DATABASE IF EXISTS ${CART_TEST_DB}`); + await instance.raw(`CREATE DATABASE ${CART_TEST_DB}`); + await instance.destroy(); + + instance = setupAccountDatabase({ + database: CART_TEST_DB, + host: 'localhost', + password: '', + port: 3306, + user: 'root', + }); + + await runSql([`${SQL_FILE_LOCATION}/accounts.sql`], instance); + await runSql([`${SQL_FILE_LOCATION}/carts.sql`], instance); + + return instance; +} diff --git a/libs/payments/cart/src/lib/types.ts b/libs/payments/cart/src/lib/types.ts new file mode 100644 index 0000000000..69050e0676 --- /dev/null +++ b/libs/payments/cart/src/lib/types.ts @@ -0,0 +1,23 @@ +/* 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 { Cart } from '../../../../shared/db/mysql/account/src'; + +export enum CartState { + START = 'start', + PROCESSING = 'processing', + SUCCESS = 'success', + FAIL = 'fail', +} + +export type SetupCart = Pick< + Cart, + | 'uid' + | 'errorReasonId' + | 'offeringConfigId' + | 'experiment' + | 'taxAddress' + | 'couponCode' + | 'stripeCustomerId' + | 'email' +> & { interval?: string }; diff --git a/libs/payments/cart/tsconfig.json b/libs/payments/cart/tsconfig.json new file mode 100644 index 0000000000..25f7201d87 --- /dev/null +++ b/libs/payments/cart/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs" + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/payments/cart/tsconfig.lib.json b/libs/payments/cart/tsconfig.lib.json new file mode 100644 index 0000000000..4befa7f099 --- /dev/null +++ b/libs/payments/cart/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/payments/cart/tsconfig.spec.json b/libs/payments/cart/tsconfig.spec.json new file mode 100644 index 0000000000..69a251f328 --- /dev/null +++ b/libs/payments/cart/tsconfig.spec.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/shared/db/mysql/account/src/index.ts b/libs/shared/db/mysql/account/src/index.ts index 0ff9cc604f..b93fc4eac9 100644 --- a/libs/shared/db/mysql/account/src/index.ts +++ b/libs/shared/db/mysql/account/src/index.ts @@ -1,12 +1,15 @@ /* 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 { AccountCustomers } from './lib/account-customers'; export { Account } from './lib/account'; +export { AccountCustomers } from './lib/account-customers'; +export { BaseModel } from './lib/base'; +export { Cart } from './lib/cart'; export { Device } from './lib/device'; -export { EmailBounce } from './lib/email-bounce'; export { Email } from './lib/email'; +export { EmailBounce } from './lib/email-bounce'; export { EmailType } from './lib/email-type'; +export { CartFactory } from './lib/factories'; export { LinkedAccount } from './lib/linked-account'; export { MetaData } from './lib/metadata'; export { PayPalBillingAgreements } from './lib/paypal-ba'; @@ -15,4 +18,5 @@ export { RelyingParty } from './lib/relying-party'; export { SecurityEvent } from './lib/security-event'; export { SentEmail } from './lib/sent-email'; export { SignInCodes } from './lib/sign-in-codes'; +export { CartFields } from './lib/types'; export { UnblockCodes } from './lib/unblock-codes'; diff --git a/libs/shared/db/mysql/account/src/lib/base.ts b/libs/shared/db/mysql/account/src/lib/base.ts index 4f2f1b5023..604d185ad2 100644 --- a/libs/shared/db/mysql/account/src/lib/base.ts +++ b/libs/shared/db/mysql/account/src/lib/base.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { Model } from 'objection'; -import { intBoolTransformer, uuidTransformer } from '@fxa/shared/db/mysql/core'; +import { intBoolTransformer, uuidTransformer } from '../../../core/src'; /** * Base Model for helpers that should be present on all models. diff --git a/libs/shared/db/mysql/account/src/lib/cart.ts b/libs/shared/db/mysql/account/src/lib/cart.ts new file mode 100644 index 0000000000..e56c675333 --- /dev/null +++ b/libs/shared/db/mysql/account/src/lib/cart.ts @@ -0,0 +1,39 @@ +/* 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 { BaseModel } from './base'; +import { Account } from './account'; +import { CartState, TaxAddress } from '@fxa/payments/cart'; + +export class Cart extends BaseModel { + static tableName = 'carts'; + + protected $uuidFields = ['id', 'uid']; + + // table fields + id!: string; + uid?: string; + state!: CartState; + errorReasonId?: string; + offeringConfigId!: string; + interval!: string; + experiment?: string; + taxAddress?: TaxAddress; + createdAt!: number; + updatedAt!: number; + couponCode?: string; + stripeCustomerId?: string; + email?: string; + amount!: number; + + static relationMappings = { + account: { + join: { + from: 'carts.uid', + to: 'accounts.uid', + }, + modelClass: Account, + relation: BaseModel.BelongsToOneRelation, + }, + }; +} diff --git a/libs/shared/db/mysql/account/src/lib/factories.ts b/libs/shared/db/mysql/account/src/lib/factories.ts new file mode 100644 index 0000000000..5360a21370 --- /dev/null +++ b/libs/shared/db/mysql/account/src/lib/factories.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 { faker } from '@faker-js/faker'; +import { CartState } from '../../../../../../payments/cart/src'; +import { Cart } from './cart'; +import { CartFields } from './types'; + +export const CartFactory = (override?: Partial): CartFields => ({ + id: faker.string.uuid(), + state: CartState.START, + offeringConfigId: faker.helpers.arrayElement([ + 'vpn', + 'relay-phone', + 'relay-email', + 'hubs', + 'mdnplus', + ]), + interval: faker.helpers.arrayElement([ + 'daily', + 'monthly', + 'semiannually', + 'annually', + ]), + createdAt: faker.date.recent().getTime(), + updatedAt: faker.date.recent().getTime(), + amount: faker.number.int(10000), + ...override, +}); diff --git a/libs/shared/db/mysql/account/src/lib/types.ts b/libs/shared/db/mysql/account/src/lib/types.ts new file mode 100644 index 0000000000..93543de4a7 --- /dev/null +++ b/libs/shared/db/mysql/account/src/lib/types.ts @@ -0,0 +1,22 @@ +/* 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 { Cart } from './cart'; + +export type CartFields = Pick< + Cart, + | 'id' + | 'uid' + | 'state' + | 'errorReasonId' + | 'offeringConfigId' + | 'interval' + | 'experiment' + | 'taxAddress' + | 'createdAt' + | 'updatedAt' + | 'couponCode' + | 'stripeCustomerId' + | 'email' + | 'amount' +>; diff --git a/libs/shared/db/mysql/account/src/test/accounts.sql b/libs/shared/db/mysql/account/src/test/accounts.sql new file mode 100644 index 0000000000..be42759104 --- /dev/null +++ b/libs/shared/db/mysql/account/src/test/accounts.sql @@ -0,0 +1,23 @@ +CREATE TABLE `accounts` ( + `uid` binary(16) NOT NULL, + `normalizedEmail` varchar(255) COLLATE utf8_unicode_ci NOT NULL, + `email` varchar(255) COLLATE utf8_unicode_ci NOT NULL, + `emailCode` binary(16) NOT NULL, + `emailVerified` tinyint(1) NOT NULL DEFAULT '0', + `kA` binary(32) NOT NULL, + `wrapWrapKb` binary(32) NOT NULL, + `authSalt` binary(32) NOT NULL, + `verifyHash` binary(32) NOT NULL, + `verifierVersion` tinyint(3) unsigned NOT NULL, + `verifierSetAt` bigint(20) unsigned NOT NULL, + `createdAt` bigint(20) unsigned NOT NULL, + `locale` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, + `lockedAt` bigint(20) unsigned DEFAULT NULL, + `profileChangedAt` bigint(20) unsigned DEFAULT NULL, + `keysChangedAt` bigint(20) unsigned DEFAULT NULL, + `ecosystemAnonId` text CHARACTER SET ascii COLLATE ascii_bin, + `disabledAt` bigint(20) unsigned DEFAULT NULL, + `metricsOptOutAt` bigint(20) unsigned DEFAULT NULL, + PRIMARY KEY (`uid`), + UNIQUE KEY `normalizedEmail` (`normalizedEmail`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; diff --git a/libs/shared/db/mysql/account/src/test/carts.sql b/libs/shared/db/mysql/account/src/test/carts.sql new file mode 100644 index 0000000000..630670facd --- /dev/null +++ b/libs/shared/db/mysql/account/src/test/carts.sql @@ -0,0 +1,19 @@ +CREATE TABLE `carts` ( + `id` binary(16) NOT NULL, + `uid` binary(16) DEFAULT NULL, + `state` enum('start','processing','success','fail') COLLATE utf8mb4_bin NOT NULL, + `errorReasonId` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL, + `offeringConfigId` varchar(255) COLLATE utf8mb4_bin NOT NULL, + `interval` varchar(255) COLLATE utf8mb4_bin NOT NULL, + `experiment` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL, + `taxAddress` json DEFAULT NULL, + `createdAt` bigint unsigned NOT NULL, + `updatedAt` bigint unsigned NOT NULL, + `couponCode` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL, + `stripeCustomerId` varchar(32) COLLATE utf8mb4_bin DEFAULT NULL, + `email` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL, + `amount` int unsigned NOT NULL, + PRIMARY KEY (`id`), + KEY `uid` (`uid`), + CONSTRAINT `carts_ibfk_1` FOREIGN KEY (`uid`) REFERENCES `accounts` (`uid`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; diff --git a/libs/shared/db/mysql/core/src/index.ts b/libs/shared/db/mysql/core/src/index.ts index ab61fe0e23..7eddf3c195 100644 --- a/libs/shared/db/mysql/core/src/index.ts +++ b/libs/shared/db/mysql/core/src/index.ts @@ -3,8 +3,14 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ export { MySQLConfig, - makeValidatedMySQLConfig, makeConvictMySQLConfig, + makeValidatedMySQLConfig, } from './lib/config'; -export { createKnex, monitorKnexConnectionPool } from './lib/core'; -export { uuidTransformer, intBoolTransformer } from './lib/transformers'; +export { + createKnex, + generateFxAUuid, + monitorKnexConnectionPool, +} from './lib/core'; +export { setupAccountDatabase } from './lib/setup'; +export { runSql } from './lib/tests'; +export { intBoolTransformer, uuidTransformer } from './lib/transformers'; diff --git a/libs/shared/db/mysql/core/src/lib/core.ts b/libs/shared/db/mysql/core/src/lib/core.ts index e6d3b3f594..20ea6e554e 100644 --- a/libs/shared/db/mysql/core/src/lib/core.ts +++ b/libs/shared/db/mysql/core/src/lib/core.ts @@ -4,11 +4,13 @@ import { knex, Knex } from 'knex'; import { promisify } from 'util'; -import { ConsoleLogger, Logger } from '@fxa/shared/log'; -import { localStatsD, StatsD } from '@fxa/shared/metrics/statsd'; +import { ConsoleLogger, Logger } from '../../../../../log/src'; +import { localStatsD, StatsD } from '../../../../../metrics/statsd/src'; import { MySQLConfig } from './config'; +import { v4 as uuidv4 } from 'uuid'; + const REQUIRED_SQL_MODES = ['STRICT_ALL_TABLES', 'NO_ENGINE_SUBSTITUTION']; /** @@ -102,3 +104,7 @@ export function createKnex( return db; } + +export function generateFxAUuid() { + return uuidv4({}, Buffer.alloc(16)).toString('hex'); +} diff --git a/libs/shared/db/mysql/core/src/lib/setup.ts b/libs/shared/db/mysql/core/src/lib/setup.ts new file mode 100644 index 0000000000..f045ce1636 --- /dev/null +++ b/libs/shared/db/mysql/core/src/lib/setup.ts @@ -0,0 +1,15 @@ +import { BaseModel as AccountBaseModel } from '../../../account/src'; +import { Logger } from '../../../../../log/src'; +import { StatsD } from '../../../../../metrics/statsd/src'; +import { MySQLConfig } from './config'; +import { createKnex } from './core'; + +export function setupAccountDatabase( + opts: MySQLConfig, + log?: Logger, + metrics?: StatsD +) { + const knex = createKnex(opts, log, metrics); + AccountBaseModel.knex(knex); + return knex; +} diff --git a/libs/shared/db/mysql/core/src/lib/tests.ts b/libs/shared/db/mysql/core/src/lib/tests.ts new file mode 100644 index 0000000000..55df70414d --- /dev/null +++ b/libs/shared/db/mysql/core/src/lib/tests.ts @@ -0,0 +1,14 @@ +/* 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 fs from 'fs'; +import { Knex } from 'knex'; +import path from 'path'; + +export const runSql = async (filePaths: string[], instance: Knex) => + Promise.all( + filePaths + .map((x) => path.join(__dirname, x)) + .map((x) => fs.readFileSync(x, 'utf8')) + .map((x) => instance.raw.bind(instance)(x)) + ); diff --git a/packages/fxa-graphql-api/src/database/database.service.ts b/packages/fxa-graphql-api/src/database/database.service.ts index ad5a2d64f8..2febff5324 100644 --- a/packages/fxa-graphql-api/src/database/database.service.ts +++ b/packages/fxa-graphql-api/src/database/database.service.ts @@ -4,6 +4,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { setupAuthDatabase, setupProfileDatabase } from 'fxa-shared/db'; +import { setupAccountDatabase } from '../../../../libs/shared/db/mysql/core/src'; import { Account } from 'fxa-shared/db/models/auth'; import { StatsD } from 'hot-shots'; import { MozLoggerService } from 'fxa-shared/nestjs/logger/logger.service'; @@ -15,6 +16,7 @@ import { AppConfig } from '../config'; export class DatabaseService { public authKnex: Knex; public profileKnex: Knex; + public accountKnex: Knex; constructor( configService: ConfigService, @@ -28,6 +30,11 @@ export class DatabaseService { logger, metrics ); + this.accountKnex = setupAccountDatabase( + dbConfig.mysql.auth, + logger, + metrics + ); } async dbHealthCheck(): Promise> { diff --git a/packages/fxa-graphql-api/src/gql/gql.module.ts b/packages/fxa-graphql-api/src/gql/gql.module.ts index 77ab29d5ec..2f0449767c 100644 --- a/packages/fxa-graphql-api/src/gql/gql.module.ts +++ b/packages/fxa-graphql-api/src/gql/gql.module.ts @@ -25,6 +25,7 @@ import Config, { AppConfig } from '../config'; import { AccountResolver } from './account.resolver'; import { SessionResolver } from './session.resolver'; import { LegalResolver } from './legal.resolver'; +import { CartResolver } from '../../../../libs/payments/cart/src'; import { Request, Response } from 'express'; const config = Config.getProperties(); @@ -66,7 +67,13 @@ export const GraphQLConfigFactory = async ( @Module({ imports: [BackendModule, CustomsModule], - providers: [AccountResolver, CustomsService, SessionResolver, LegalResolver], + providers: [ + AccountResolver, + CustomsService, + SessionResolver, + LegalResolver, + CartResolver, + ], }) export class GqlModule implements NestModule { configure(consumer: MiddlewareConsumer) { diff --git a/tsconfig.base.json b/tsconfig.base.json index 685849721f..755986b52f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -30,7 +30,8 @@ ], "@fxa/shared/db/mysql/core": ["libs/shared/db/mysql/core/src/index.ts"], "@fxa/shared/log": ["libs/shared/log/src/index.ts"], - "@fxa/shared/metrics/statsd": ["libs/shared/metrics/statsd/src/index.ts"] + "@fxa/shared/metrics/statsd": ["libs/shared/metrics/statsd/src/index.ts"], + "@fxa/payments/cart": ["libs/payments/cart/src/index.ts"], }, "typeRoots": [ "./types",