feat(payments): add cart library and factories

Because:

* Need a library to handle the cart db table and related functions

This commit:

* Add cart DB model
* Initializes the Cart library and creates factories and types
* Adds CartManager library
* Adds Cart related resolvers with basic Cart DB queries

Closes: #FXA-7508 #FXA-7505
This commit is contained in:
Reino Muhl 2023-07-11 14:21:03 -04:00
Родитель 2c2299f64f
Коммит 868a723be6
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: C86660FCF998897A
39 изменённых файлов: 1092 добавлений и 10 удалений

Просмотреть файл

@ -0,0 +1,18 @@
{
"extends": ["../../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
]
}

Просмотреть файл

@ -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).

Просмотреть файл

@ -0,0 +1,11 @@
/* eslint-disable */
export default {
displayName: 'payments-cart',
preset: '../../../jest.preset.js',
testEnvironment: 'node',
transform: {
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../../coverage/libs/payments/cart',
};

Просмотреть файл

@ -0,0 +1,5 @@
{
"name": "@fxa/payments/cart",
"version": "0.0.1",
"type": "commonjs"
}

Просмотреть файл

@ -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": []
}

Просмотреть файл

@ -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;
}

Просмотреть файл

@ -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;
}

Просмотреть файл

@ -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>(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);
});
});
});

Просмотреть файл

@ -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<CartType | null> {
// 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<CartType | null> {
return this.cartManager.setupCart({
...input,
});
}
@Mutation((returns) => CartType, { nullable: true })
public async restartCart(
@Args('input', { type: () => CartIdInput })
input: CartIdInput
): Promise<CartType | null> {
return this.cartManager.restartCart(input.id);
}
@Mutation((returns) => CartType, { nullable: true })
public async checkoutCart(
@Args('input', { type: () => CartIdInput })
input: CartIdInput
): Promise<CartType | null> {
return this.cartManager.checkoutCart(input.id);
}
@Mutation((returns) => CartType, { nullable: true })
public async updateCart(
@Args('input', { type: () => UpdateCartInput })
input: UpdateCartInput
): Promise<CartType | null> {
return this.cartManager.updateCart(input);
}
}

Просмотреть файл

@ -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';

Просмотреть файл

@ -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[];
}

Просмотреть файл

@ -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;
}

Просмотреть файл

@ -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;
}

Просмотреть файл

@ -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;
}

Просмотреть файл

@ -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;
}

Просмотреть файл

@ -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;
}

Просмотреть файл

@ -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';

Просмотреть файл

@ -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>): 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>): TaxAmount => ({
title: faker.location.state({ abbreviated: true }),
amount: faker.number.int(10000),
...override,
});
export const InvoiceFactory = (override?: Partial<Invoice>): Invoice => ({
totalAmount: faker.number.int(10000),
taxAmounts: [TaxAmountFactory()],
...override,
});
export const SubscriptionFactory = (
override?: Partial<Subscription>
): Subscription => ({
pageConfigId: faker.helpers.arrayElement(['default', 'alternate-pricing']),
previousInvoice: InvoiceFactory(),
nextInvoice: InvoiceFactory(),
...override,
});
export const TaxAddressFactory = (
override?: Partial<TaxAddress>
): TaxAddress => ({
countryCode: faker.location.countryCode(),
postalCode: faker.location.zipCode(),
...override,
});
export const CartIdInputFactory = (
override?: Partial<CartIdInput>
): CartIdInput => ({
id: faker.string.uuid(),
...override,
});
export const SetupCartInputFactory = (
override?: Partial<SetupCartInput>
): SetupCartInput => ({
offeringConfigId: faker.helpers.arrayElement(OFFERING_CONFIG_IDS),
...override,
});
export const UpdateCartInputFactory = (
override?: Partial<UpdateCartInput>
): UpdateCartInput => ({
id: faker.string.uuid(),
offeringConfigId: faker.helpers.arrayElement(OFFERING_CONFIG_IDS),
...override,
});
export const SetupCartFactory = (override?: Partial<SetupCart>): SetupCart => ({
offeringConfigId: faker.helpers.arrayElement(OFFERING_CONFIG_IDS),
...override,
});

Просмотреть файл

@ -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 })
);
}
});
});
});

Просмотреть файл

@ -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<CartType | null> {
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<CartType | null> {
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<CartType | null> {
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<CartType | null> {
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
};
}
}

Просмотреть файл

@ -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<Knex> {
// 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;
}

Просмотреть файл

@ -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 };

Просмотреть файл

@ -0,0 +1,16 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs"
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.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"]
}

Просмотреть файл

@ -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"
]
}

Просмотреть файл

@ -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';

Просмотреть файл

@ -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.

Просмотреть файл

@ -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,
},
};
}

Просмотреть файл

@ -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<Cart>): 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,
});

Просмотреть файл

@ -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'
>;

Просмотреть файл

@ -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;

Просмотреть файл

@ -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;

Просмотреть файл

@ -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';

Просмотреть файл

@ -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');
}

Просмотреть файл

@ -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;
}

Просмотреть файл

@ -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))
);

Просмотреть файл

@ -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<AppConfig>,
@ -28,6 +30,11 @@ export class DatabaseService {
logger,
metrics
);
this.accountKnex = setupAccountDatabase(
dbConfig.mysql.auth,
logger,
metrics
);
}
async dbHealthCheck(): Promise<Record<string, any>> {

Просмотреть файл

@ -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) {

Просмотреть файл

@ -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",