diff --git a/.pnp.cjs b/.pnp.cjs index 8f9805dd0..9a725cea5 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -2551,6 +2551,7 @@ const RAW_RUNTIME_STATE = ["@types/jest", "npm:29.1.1"],\ ["@types/newrelic", "npm:7.0.3"],\ ["@types/node", "npm:18.0.3"],\ + ["@types/uuid", "npm:8.3.4"],\ ["@typescript-eslint/eslint-plugin", "virtual:c66bf20e88479ada0172094776519a9f51acc4731d22079b60a295bcec7ea42d5545cbce58a77a50d932bf953298799135e99707486e343da6d99ba1d167bdbd#npm:5.30.5"],\ ["aws-sdk", "npm:2.1234.0"],\ ["dayjs", "npm:1.11.6"],\ @@ -2563,9 +2564,11 @@ const RAW_RUNTIME_STATE = ["mysql2", "npm:2.3.3"],\ ["newrelic", "npm:9.0.0"],\ ["reflect-metadata", "npm:0.1.13"],\ + ["shallow-equal-object", "npm:1.1.1"],\ ["ts-jest", "virtual:fd909b174d079e30b336c4ce72c38a88c1e447767b1a8dd7655e07719a1e31b97807f0931368724fc78897ff15e6a6d00b83316c0f76d11f85111f342e08bb79#npm:29.0.3"],\ ["typeorm", "virtual:c66bf20e88479ada0172094776519a9f51acc4731d22079b60a295bcec7ea42d5545cbce58a77a50d932bf953298799135e99707486e343da6d99ba1d167bdbd#npm:0.3.10"],\ ["typescript", "patch:typescript@npm%3A4.8.4#optional!builtin::version=4.8.4&hash=701156"],\ + ["uuid", "npm:9.0.0"],\ ["winston", "npm:3.8.2"]\ ],\ "linkType": "SOFT"\ @@ -12213,6 +12216,15 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["shallow-equal-object", [\ + ["npm:1.1.1", {\ + "packageLocation": "./.yarn/cache/shallow-equal-object-npm-1.1.1-a41b289b2e-9e5e0cd10b.zip/node_modules/shallow-equal-object/",\ + "packageDependencies": [\ + ["shallow-equal-object", "npm:1.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["shebang-command", [\ ["npm:2.0.0", {\ "packageLocation": "./.yarn/cache/shebang-command-npm-2.0.0-eb2b01921d-5907a8d5fa.zip/node_modules/shebang-command/",\ diff --git a/.yarn/cache/shallow-equal-object-npm-1.1.1-a41b289b2e-9e5e0cd10b.zip b/.yarn/cache/shallow-equal-object-npm-1.1.1-a41b289b2e-9e5e0cd10b.zip new file mode 100644 index 000000000..d9f9b27c8 Binary files /dev/null and b/.yarn/cache/shallow-equal-object-npm-1.1.1-a41b289b2e-9e5e0cd10b.zip differ diff --git a/packages/analytics/jest.config.js b/packages/analytics/jest.config.js index 09dfc80db..26ba3e5e0 100644 --- a/packages/analytics/jest.config.js +++ b/packages/analytics/jest.config.js @@ -7,4 +7,5 @@ module.exports = { transform: { ...tsjPreset.transform, }, + coveragePathIgnorePatterns: ['/Infra/'], } diff --git a/packages/analytics/package.json b/packages/analytics/package.json index 4a560b20b..5f72642dc 100644 --- a/packages/analytics/package.json +++ b/packages/analytics/package.json @@ -29,6 +29,7 @@ "@types/jest": "^29.1.1", "@types/newrelic": "^7.0.3", "@types/node": "^18.0.0", + "@types/uuid": "^8.3.0", "@typescript-eslint/eslint-plugin": "^5.30.0", "eslint": "^8.14.0", "eslint-plugin-prettier": "^4.2.1", @@ -51,7 +52,9 @@ "mysql2": "^2.3.3", "newrelic": "^9.0.0", "reflect-metadata": "^0.1.13", + "shallow-equal-object": "^1.1.1", "typeorm": "^0.3.6", + "uuid": "^9.0.0", "winston": "^3.8.1" } } diff --git a/packages/analytics/src/Bootstrap/Container.ts b/packages/analytics/src/Bootstrap/Container.ts index 23d5f9c76..959b71b3a 100644 --- a/packages/analytics/src/Bootstrap/Container.ts +++ b/packages/analytics/src/Bootstrap/Container.ts @@ -44,6 +44,12 @@ import { SubscriptionPurchasedEventHandler } from '../Domain/Handler/Subscriptio import { SubscriptionExpiredEventHandler } from '../Domain/Handler/SubscriptionExpiredEventHandler' import { SubscriptionReactivatedEventHandler } from '../Domain/Handler/SubscriptionReactivatedEventHandler' import { RefundProcessedEventHandler } from '../Domain/Handler/RefundProcessedEventHandler' +import { RevenueModificationRepositoryInterface } from '../Domain/Revenue/RevenueModificationRepositoryInterface' +import { MySQLRevenueModificationRepository } from '../Infra/MySQL/MySQLRevenueModificationRepository' +import { TypeORMRevenueModification } from '../Infra/TypeORM/TypeORMRevenueModification' +import { MapInterface } from '../Domain/Map/MapInterface' +import { RevenueModification } from '../Domain/Revenue/RevenueModification' +import { RevenueModificationMap } from '../Domain/Map/RevenueModificationMap' // eslint-disable-next-line @typescript-eslint/no-var-requires const newrelicFormatter = require('@newrelic/winston-enricher') @@ -116,11 +122,17 @@ export class ContainerConfigLoader { container .bind(TYPES.AnalyticsEntityRepository) .to(MySQLAnalyticsEntityRepository) + container + .bind(TYPES.RevenueModificationRepository) + .to(MySQLRevenueModificationRepository) // ORM container .bind>(TYPES.ORMAnalyticsEntityRepository) .toConstantValue(AppDataSource.getRepository(AnalyticsEntity)) + container + .bind>(TYPES.ORMRevenueModificationRepository) + .toConstantValue(AppDataSource.getRepository(TypeORMRevenueModification)) // Use Case container.bind(TYPES.GetUserAnalyticsId).to(GetUserAnalyticsId) @@ -152,6 +164,11 @@ export class ContainerConfigLoader { .to(SubscriptionReactivatedEventHandler) container.bind(TYPES.RefundProcessedEventHandler).to(RefundProcessedEventHandler) + // Maps + container + .bind>(TYPES.RevenueModificationMap) + .to(RevenueModificationMap) + // Services container.bind(TYPES.DomainEventFactory).to(DomainEventFactory) container.bind(TYPES.PeriodKeyGenerator).toConstantValue(new PeriodKeyGenerator()) diff --git a/packages/analytics/src/Bootstrap/DataSource.ts b/packages/analytics/src/Bootstrap/DataSource.ts index daa895367..e9dd8b22e 100644 --- a/packages/analytics/src/Bootstrap/DataSource.ts +++ b/packages/analytics/src/Bootstrap/DataSource.ts @@ -1,6 +1,7 @@ import { DataSource, LoggerOptions } from 'typeorm' import { AnalyticsEntity } from '../Domain/Entity/AnalyticsEntity' +import { TypeORMRevenueModification } from '../Infra/TypeORM/TypeORMRevenueModification' import { Env } from './Env' @@ -36,7 +37,7 @@ export const AppDataSource = new DataSource({ ], removeNodeErrorCount: 10, }, - entities: [AnalyticsEntity], + entities: [AnalyticsEntity, TypeORMRevenueModification], migrations: [env.get('DB_MIGRATIONS_PATH', true) ?? 'dist/migrations/*.js'], migrationsRun: true, logging: env.get('DB_DEBUG_LEVEL'), diff --git a/packages/analytics/src/Bootstrap/Types.ts b/packages/analytics/src/Bootstrap/Types.ts index ec45c9e00..38f8fce42 100644 --- a/packages/analytics/src/Bootstrap/Types.ts +++ b/packages/analytics/src/Bootstrap/Types.ts @@ -13,8 +13,10 @@ const TYPES = { NEW_RELIC_ENABLED: Symbol.for('NEW_RELIC_ENABLED'), // Repositories AnalyticsEntityRepository: Symbol.for('AnalyticsEntityRepository'), + RevenueModificationRepository: Symbol.for('RevenueModificationRepository'), // ORM ORMAnalyticsEntityRepository: Symbol.for('ORMAnalyticsEntityRepository'), + ORMRevenueModificationRepository: Symbol.for('ORMRevenueModificationRepository'), // Use Case GetUserAnalyticsId: Symbol.for('GetUserAnalyticsId'), // Handlers @@ -29,6 +31,8 @@ const TYPES = { SubscriptionExpiredEventHandler: Symbol.for('SubscriptionExpiredEventHandler'), SubscriptionReactivatedEventHandler: Symbol.for('SubscriptionReactivatedEventHandler'), RefundProcessedEventHandler: Symbol.for('RefundProcessedEventHandler'), + // Maps + RevenueModificationMap: Symbol.for('RevenueModificationMap'), // Services DomainEventPublisher: Symbol.for('DomainEventPublisher'), DomainEventSubscriberFactory: Symbol.for('DomainEventSubscriberFactory'), diff --git a/packages/analytics/src/Domain/Common/Email.spec.ts b/packages/analytics/src/Domain/Common/Email.spec.ts new file mode 100644 index 000000000..ddcecdf54 --- /dev/null +++ b/packages/analytics/src/Domain/Common/Email.spec.ts @@ -0,0 +1,16 @@ +import { Email } from './Email' + +describe('Email', () => { + it('should create a value object', () => { + const valueOrError = Email.create('test@test.te') + + expect(valueOrError.isFailed()).toBeFalsy() + expect(valueOrError.getValue().value).toEqual('test@test.te') + }) + + it('should not create an invalid value object', () => { + const valueOrError = Email.create('') + + expect(valueOrError.isFailed()).toBeTruthy() + }) +}) diff --git a/packages/analytics/src/Domain/Common/Email.ts b/packages/analytics/src/Domain/Common/Email.ts new file mode 100644 index 000000000..577dc5202 --- /dev/null +++ b/packages/analytics/src/Domain/Common/Email.ts @@ -0,0 +1,21 @@ +import { ValueObject } from '../Core/ValueObject' +import { Result } from '../Core/Result' +import { EmailProps } from './EmailProps' + +export class Email extends ValueObject { + get value(): string { + return this.props.value + } + + private constructor(props: EmailProps) { + super(props) + } + + static create(email: string): Result { + if (!!email === false || email.length === 0) { + return Result.fail('Email cannot be empty') + } else { + return Result.ok(new Email({ value: email })) + } + } +} diff --git a/packages/analytics/src/Domain/Common/EmailProps.ts b/packages/analytics/src/Domain/Common/EmailProps.ts new file mode 100644 index 000000000..8b6321975 --- /dev/null +++ b/packages/analytics/src/Domain/Common/EmailProps.ts @@ -0,0 +1,3 @@ +export interface EmailProps { + value: string +} diff --git a/packages/analytics/src/Domain/Common/Uuid.spec.ts b/packages/analytics/src/Domain/Common/Uuid.spec.ts new file mode 100644 index 000000000..a5665b690 --- /dev/null +++ b/packages/analytics/src/Domain/Common/Uuid.spec.ts @@ -0,0 +1,16 @@ +import { Uuid } from './Uuid' + +describe('Uuid', () => { + it('should create a value object', () => { + const valueOrError = Uuid.create('1-2-3') + + expect(valueOrError.isFailed()).toBeFalsy() + expect(valueOrError.getValue().value).toEqual('1-2-3') + }) + + it('should not create an invalid value object', () => { + const valueOrError = Uuid.create('') + + expect(valueOrError.isFailed()).toBeTruthy() + }) +}) diff --git a/packages/analytics/src/Domain/Common/Uuid.ts b/packages/analytics/src/Domain/Common/Uuid.ts new file mode 100644 index 000000000..bcba6f37f --- /dev/null +++ b/packages/analytics/src/Domain/Common/Uuid.ts @@ -0,0 +1,21 @@ +import { ValueObject } from '../Core/ValueObject' +import { Result } from '../Core/Result' +import { UuidProps } from './UuidProps' + +export class Uuid extends ValueObject { + get value(): string { + return this.props.value + } + + private constructor(props: UuidProps) { + super(props) + } + + static create(uuid: string): Result { + if (!!uuid === false || uuid.length === 0) { + return Result.fail('Uuid cannot be empty') + } else { + return Result.ok(new Uuid({ value: uuid })) + } + } +} diff --git a/packages/analytics/src/Domain/Common/UuidProps.ts b/packages/analytics/src/Domain/Common/UuidProps.ts new file mode 100644 index 000000000..177df122f --- /dev/null +++ b/packages/analytics/src/Domain/Common/UuidProps.ts @@ -0,0 +1,3 @@ +export interface UuidProps { + value: string +} diff --git a/packages/analytics/src/Domain/Core/Aggregate.ts b/packages/analytics/src/Domain/Core/Aggregate.ts new file mode 100644 index 000000000..406670084 --- /dev/null +++ b/packages/analytics/src/Domain/Core/Aggregate.ts @@ -0,0 +1,10 @@ +/* istanbul ignore file */ + +import { Entity } from './Entity' +import { UniqueEntityId } from './UniqueEntityId' + +export abstract class Aggregate extends Entity { + get id(): UniqueEntityId { + return this._id + } +} diff --git a/packages/analytics/src/Domain/Core/Entity.ts b/packages/analytics/src/Domain/Core/Entity.ts new file mode 100644 index 000000000..de36994d2 --- /dev/null +++ b/packages/analytics/src/Domain/Core/Entity.ts @@ -0,0 +1,27 @@ +/* istanbul ignore file */ + +import { UniqueEntityId } from './UniqueEntityId' + +export abstract class Entity { + protected readonly _id: UniqueEntityId + + constructor(public readonly props: T, id?: UniqueEntityId) { + this._id = id ? id : new UniqueEntityId() + } + + public equals(object?: Entity): boolean { + if (object == null || object == undefined) { + return false + } + + if (this === object) { + return true + } + + if (!(object instanceof Entity)) { + return false + } + + return this._id.equals(object._id) + } +} diff --git a/packages/analytics/src/Domain/Core/Id.ts b/packages/analytics/src/Domain/Core/Id.ts new file mode 100644 index 000000000..7e614c2d0 --- /dev/null +++ b/packages/analytics/src/Domain/Core/Id.ts @@ -0,0 +1,24 @@ +/* istanbul ignore file */ + +export class Id { + constructor(private value: T) {} + + equals(id?: Id): boolean { + if (id === null || id === undefined) { + return false + } + if (!(id instanceof this.constructor)) { + return false + } + + return id.toValue() === this.value + } + + toString() { + return String(this.value) + } + + toValue(): T { + return this.value + } +} diff --git a/packages/analytics/src/Domain/Core/Result.ts b/packages/analytics/src/Domain/Core/Result.ts new file mode 100644 index 000000000..281cae692 --- /dev/null +++ b/packages/analytics/src/Domain/Core/Result.ts @@ -0,0 +1,35 @@ +/* istanbul ignore file */ + +export class Result { + constructor(private isSuccess: boolean, private error?: T | string, private value?: T) { + Object.freeze(this) + } + + isFailed(): boolean { + return !this.isSuccess + } + + getValue(): T { + if (!this.isSuccess) { + throw new Error('Cannot get value of an unsuccessfull result') + } + + return this.value as T + } + + getError(): T | string { + if (this.isSuccess || this.error === undefined) { + throw new Error('Cannot get an error of a successfull result') + } + + return this.error + } + + static ok(value?: U): Result { + return new Result(true, undefined, value) + } + + static fail(error: U | string): Result { + return new Result(false, error) + } +} diff --git a/packages/analytics/src/Domain/Core/UniqueEntityId.ts b/packages/analytics/src/Domain/Core/UniqueEntityId.ts new file mode 100644 index 000000000..ce8d50b44 --- /dev/null +++ b/packages/analytics/src/Domain/Core/UniqueEntityId.ts @@ -0,0 +1,10 @@ +/* istanbul ignore file */ + +import { v4 as uuid } from 'uuid' +import { Id } from './Id' + +export class UniqueEntityId extends Id { + constructor(id?: string | number) { + super(id ? id : uuid()) + } +} diff --git a/packages/analytics/src/Domain/Core/ValueObject.ts b/packages/analytics/src/Domain/Core/ValueObject.ts new file mode 100644 index 000000000..7705cbfbc --- /dev/null +++ b/packages/analytics/src/Domain/Core/ValueObject.ts @@ -0,0 +1,24 @@ +/* istanbul ignore file */ + +import { shallowEqual } from 'shallow-equal-object' + +import { ValueObjectProps } from './ValueObjectProps' + +export abstract class ValueObject { + public readonly props: T + + constructor(props: T) { + this.props = Object.freeze(props) + } + + equals(valueObject?: ValueObject): boolean { + if (valueObject === null || valueObject === undefined) { + return false + } + if (valueObject.props === undefined) { + return false + } + + return shallowEqual(this.props, valueObject.props) + } +} diff --git a/packages/analytics/src/Domain/Core/ValueObjectProps.ts b/packages/analytics/src/Domain/Core/ValueObjectProps.ts new file mode 100644 index 000000000..02cadf0e8 --- /dev/null +++ b/packages/analytics/src/Domain/Core/ValueObjectProps.ts @@ -0,0 +1,4 @@ +export interface ValueObjectProps { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [index: string]: any +} diff --git a/packages/analytics/src/Domain/Map/MapInterface.ts b/packages/analytics/src/Domain/Map/MapInterface.ts new file mode 100644 index 000000000..0b7bda833 --- /dev/null +++ b/packages/analytics/src/Domain/Map/MapInterface.ts @@ -0,0 +1,4 @@ +export interface MapInterface { + toDomain(persistence: U): T + toPersistence(domain: T): U +} diff --git a/packages/analytics/src/Domain/Map/RevenueModificationMap.ts b/packages/analytics/src/Domain/Map/RevenueModificationMap.ts new file mode 100644 index 000000000..d266c27e7 --- /dev/null +++ b/packages/analytics/src/Domain/Map/RevenueModificationMap.ts @@ -0,0 +1,58 @@ +import { TypeORMRevenueModification } from '../../Infra/TypeORM/TypeORMRevenueModification' +import { UniqueEntityId } from '../Core/UniqueEntityId' +import { MonthlyRevenue } from '../Revenue/MonthlyRevenue' +import { RevenueModification } from '../Revenue/RevenueModification' +import { Subscription } from '../Subscription/Subscription' +import { User } from '../User/User' +import { MapInterface } from './MapInterface' +import { Email } from '../Common/Email' +import { SubscriptionPlanName } from '../Subscription/SubscriptionPlanName' +import { SubscriptionEventType } from '../Subscription/SubscriptionEventType' + +export class RevenueModificationMap implements MapInterface { + toDomain(persistence: TypeORMRevenueModification): RevenueModification { + const user = User.create( + { + email: Email.create(persistence.userEmail).getValue(), + }, + new UniqueEntityId(persistence.userUuid), + ) + const subscription = Subscription.create( + { + billingFrequency: persistence.billingFrequency, + isFirstSubscriptionForUser: persistence.isNewCustomer, + payedAmount: persistence.billingFrequency * persistence.newMonthlyRevenue, + planName: SubscriptionPlanName.create(persistence.subscriptionPlan).getValue(), + }, + new UniqueEntityId(persistence.subscriptionId), + ) + const previousMonthlyRevenueOrError = MonthlyRevenue.create(persistence.previousMonthlyRevenue) + + return RevenueModification.create( + { + user, + subscription, + eventType: SubscriptionEventType.create(persistence.eventType).getValue(), + previousMonthlyRevenue: previousMonthlyRevenueOrError.getValue(), + }, + new UniqueEntityId(persistence.uuid), + ) + } + + toPersistence(domain: RevenueModification): TypeORMRevenueModification { + const { subscription, user } = domain.props + const persistence = new TypeORMRevenueModification() + persistence.uuid = domain.id.toString() + persistence.billingFrequency = subscription.props.billingFrequency + persistence.eventType = domain.props.eventType.value + persistence.isNewCustomer = subscription.props.isFirstSubscriptionForUser + persistence.newMonthlyRevenue = domain.newMonthlyRevenue.value + persistence.previousMonthlyRevenue = domain.props.previousMonthlyRevenue.value + persistence.subscriptionId = subscription.id.toValue() as number + persistence.subscriptionPlan = subscription.props.planName.value + persistence.userEmail = user.props.email.value + persistence.userUuid = user.id.toString() + + return persistence + } +} diff --git a/packages/analytics/src/Domain/Revenue/MonthlyRevenue.spec.ts b/packages/analytics/src/Domain/Revenue/MonthlyRevenue.spec.ts new file mode 100644 index 000000000..fdc773834 --- /dev/null +++ b/packages/analytics/src/Domain/Revenue/MonthlyRevenue.spec.ts @@ -0,0 +1,16 @@ +import { MonthlyRevenue } from './MonthlyRevenue' + +describe('MonthlyRevenue', () => { + it('should create a value object', () => { + const valueOrError = MonthlyRevenue.create(123) + + expect(valueOrError.isFailed()).toBeFalsy() + expect(valueOrError.getValue().value).toEqual(123) + }) + + it('should not create an invalid value object', () => { + const valueOrError = MonthlyRevenue.create(-3) + + expect(valueOrError.isFailed()).toBeTruthy() + }) +}) diff --git a/packages/analytics/src/Domain/Revenue/MonthlyRevenue.ts b/packages/analytics/src/Domain/Revenue/MonthlyRevenue.ts new file mode 100644 index 000000000..6dd7f2037 --- /dev/null +++ b/packages/analytics/src/Domain/Revenue/MonthlyRevenue.ts @@ -0,0 +1,21 @@ +import { ValueObject } from '../Core/ValueObject' +import { Result } from '../Core/Result' +import { MonthlyRevenueProps } from './MonthlyRevenueProps' + +export class MonthlyRevenue extends ValueObject { + get value(): number { + return this.props.value + } + + private constructor(props: MonthlyRevenueProps) { + super(props) + } + + static create(revenue: number): Result { + if (isNaN(revenue) || revenue < 0) { + return Result.fail('Monthly revenue must be a non-negative number') + } else { + return Result.ok(new MonthlyRevenue({ value: revenue })) + } + } +} diff --git a/packages/analytics/src/Domain/Revenue/MonthlyRevenueProps.ts b/packages/analytics/src/Domain/Revenue/MonthlyRevenueProps.ts new file mode 100644 index 000000000..1d986653e --- /dev/null +++ b/packages/analytics/src/Domain/Revenue/MonthlyRevenueProps.ts @@ -0,0 +1,3 @@ +export interface MonthlyRevenueProps { + value: number +} diff --git a/packages/analytics/src/Domain/Revenue/RevenueModification.spec.ts b/packages/analytics/src/Domain/Revenue/RevenueModification.spec.ts new file mode 100644 index 000000000..4da6ae4c6 --- /dev/null +++ b/packages/analytics/src/Domain/Revenue/RevenueModification.spec.ts @@ -0,0 +1,61 @@ +import { Email } from '../Common/Email' +import { Subscription } from '../Subscription/Subscription' +import { SubscriptionEventType } from '../Subscription/SubscriptionEventType' +import { SubscriptionPlanName } from '../Subscription/SubscriptionPlanName' +import { User } from '../User/User' +import { MonthlyRevenue } from './MonthlyRevenue' +import { RevenueModification } from './RevenueModification' + +describe('RevenueModification', () => { + let user: User + let subscription: Subscription + + beforeEach(() => { + subscription = Subscription.create({ + billingFrequency: 12, + isFirstSubscriptionForUser: true, + payedAmount: 123, + planName: SubscriptionPlanName.create('PRO_PLAN').getValue(), + }) + user = User.create({ + email: Email.create('test@test.te').getValue(), + }) + }) + + it('should create an aggregate for purchased subscription', () => { + const revenueModification = RevenueModification.create({ + eventType: SubscriptionEventType.create('SUBSCRIPTION_PURCHASED').getValue(), + previousMonthlyRevenue: MonthlyRevenue.create(123).getValue(), + subscription, + user, + }) + + expect(revenueModification.id.toString()).toHaveLength(36) + expect(revenueModification.newMonthlyRevenue.value).toEqual(123 / 12) + }) + + it('should create an aggregate for subscription expired', () => { + const revenueModification = RevenueModification.create({ + createdAt: new Date(1), + eventType: SubscriptionEventType.create('SUBSCRIPTION_EXPIRED').getValue(), + previousMonthlyRevenue: MonthlyRevenue.create(123).getValue(), + subscription, + user, + }) + + expect(revenueModification.id.toString()).toHaveLength(36) + expect(revenueModification.newMonthlyRevenue.value).toEqual(0) + }) + + it('should create an aggregate for subscription cancelled', () => { + const revenueModification = RevenueModification.create({ + eventType: SubscriptionEventType.create('SUBSCRIPTION_CANCELLED').getValue(), + previousMonthlyRevenue: MonthlyRevenue.create(123).getValue(), + subscription, + user, + }) + + expect(revenueModification.id.toString()).toHaveLength(36) + expect(revenueModification.newMonthlyRevenue.value).toEqual(123) + }) +}) diff --git a/packages/analytics/src/Domain/Revenue/RevenueModification.ts b/packages/analytics/src/Domain/Revenue/RevenueModification.ts new file mode 100644 index 000000000..a666c0ba3 --- /dev/null +++ b/packages/analytics/src/Domain/Revenue/RevenueModification.ts @@ -0,0 +1,45 @@ +import { Aggregate } from '../Core/Aggregate' +import { UniqueEntityId } from '../Core/UniqueEntityId' +import { MonthlyRevenue } from './MonthlyRevenue' +import { RevenueModificationProps } from './RevenueModificationProps' + +export class RevenueModification extends Aggregate { + private constructor(props: RevenueModificationProps, id?: UniqueEntityId) { + super(props, id) + } + + static create(props: RevenueModificationProps, id?: UniqueEntityId): RevenueModification { + const revenueModification = new RevenueModification( + { + ...props, + createdAt: props.createdAt ? props.createdAt : new Date(), + }, + id, + ) + + return revenueModification + } + + get newMonthlyRevenue(): MonthlyRevenue { + const { subscription } = this.props + + let revenue = 0 + switch (this.props.eventType.value) { + case 'SUBSCRIPTION_PURCHASED': + case 'SUBSCRIPTION_RENEWED': + revenue = subscription.props.payedAmount / subscription.props.billingFrequency + break + case 'SUBSCRIPTION_EXPIRED': + case 'SUBSCRIPTION_REFUNDED': + revenue = 0 + break + case 'SUBSCRIPTION_CANCELLED': + revenue = this.props.previousMonthlyRevenue.value + break + } + + const monthlyRevenueOrError = MonthlyRevenue.create(revenue) + + return monthlyRevenueOrError.getValue() + } +} diff --git a/packages/analytics/src/Domain/Revenue/RevenueModificationProps.ts b/packages/analytics/src/Domain/Revenue/RevenueModificationProps.ts new file mode 100644 index 000000000..9d2574e61 --- /dev/null +++ b/packages/analytics/src/Domain/Revenue/RevenueModificationProps.ts @@ -0,0 +1,12 @@ +import { MonthlyRevenue } from './MonthlyRevenue' +import { Subscription } from '../Subscription/Subscription' +import { User } from '../User/User' +import { SubscriptionEventType } from '../Subscription/SubscriptionEventType' + +export interface RevenueModificationProps { + user: User + subscription: Subscription + eventType: SubscriptionEventType + previousMonthlyRevenue: MonthlyRevenue + createdAt?: Date +} diff --git a/packages/analytics/src/Domain/Revenue/RevenueModificationRepositoryInterface.ts b/packages/analytics/src/Domain/Revenue/RevenueModificationRepositoryInterface.ts new file mode 100644 index 000000000..02020efdd --- /dev/null +++ b/packages/analytics/src/Domain/Revenue/RevenueModificationRepositoryInterface.ts @@ -0,0 +1,7 @@ +import { Uuid } from '../Common/Uuid' +import { RevenueModification } from './RevenueModification' + +export interface RevenueModificationRepositoryInterface { + findLastByUserUuid(userUuid: Uuid): Promise + save(revenueModification: RevenueModification): Promise +} diff --git a/packages/analytics/src/Domain/Subscription/Subscription.spec.ts b/packages/analytics/src/Domain/Subscription/Subscription.spec.ts new file mode 100644 index 000000000..648f9d5de --- /dev/null +++ b/packages/analytics/src/Domain/Subscription/Subscription.spec.ts @@ -0,0 +1,15 @@ +import { Subscription } from './Subscription' +import { SubscriptionPlanName } from './SubscriptionPlanName' + +describe('Subscription', () => { + it('should create an entity', () => { + const subscription = Subscription.create({ + billingFrequency: 1, + isFirstSubscriptionForUser: true, + payedAmount: 12.99, + planName: SubscriptionPlanName.create('PRO_PLAN').getValue(), + }) + + expect(subscription.id.toString()).toHaveLength(36) + }) +}) diff --git a/packages/analytics/src/Domain/Subscription/Subscription.ts b/packages/analytics/src/Domain/Subscription/Subscription.ts new file mode 100644 index 000000000..85dce593d --- /dev/null +++ b/packages/analytics/src/Domain/Subscription/Subscription.ts @@ -0,0 +1,17 @@ +import { Entity } from '../Core/Entity' +import { UniqueEntityId } from '../Core/UniqueEntityId' +import { SubscriptionProps } from './SubscriptionProps' + +export class Subscription extends Entity { + get id(): UniqueEntityId { + return this._id + } + + private constructor(props: SubscriptionProps, id?: UniqueEntityId) { + super(props, id) + } + + static create(props: SubscriptionProps, id?: UniqueEntityId): Subscription { + return new Subscription(props, id) + } +} diff --git a/packages/analytics/src/Domain/Subscription/SubscriptionEventType.spec.ts b/packages/analytics/src/Domain/Subscription/SubscriptionEventType.spec.ts new file mode 100644 index 000000000..e887674aa --- /dev/null +++ b/packages/analytics/src/Domain/Subscription/SubscriptionEventType.spec.ts @@ -0,0 +1,16 @@ +import { SubscriptionEventType } from './SubscriptionEventType' + +describe('SubscriptionEventType', () => { + it('should create a value object', () => { + const valueOrError = SubscriptionEventType.create('SUBSCRIPTION_PURCHASED') + + expect(valueOrError.isFailed()).toBeFalsy() + expect(valueOrError.getValue().value).toEqual('SUBSCRIPTION_PURCHASED') + }) + + it('should not create an invalid value object', () => { + const valueOrError = SubscriptionEventType.create('SUBSCRIPTION_REACTIVATED') + + expect(valueOrError.isFailed()).toBeTruthy() + }) +}) diff --git a/packages/analytics/src/Domain/Subscription/SubscriptionEventType.ts b/packages/analytics/src/Domain/Subscription/SubscriptionEventType.ts new file mode 100644 index 000000000..aae1f332e --- /dev/null +++ b/packages/analytics/src/Domain/Subscription/SubscriptionEventType.ts @@ -0,0 +1,29 @@ +import { ValueObject } from '../Core/ValueObject' +import { Result } from '../Core/Result' +import { SubscriptionEventTypeProps } from './SubscriptionEventTypeProps' + +export class SubscriptionEventType extends ValueObject { + get value(): string { + return this.props.value + } + + private constructor(props: SubscriptionEventTypeProps) { + super(props) + } + + static create(subscriptionEventType: string): Result { + if ( + ![ + 'SUBSCRIPTION_PURCHASED', + 'SUBSCRIPTION_RENEWED', + 'SUBSCRIPTION_EXPIRED', + 'SUBSCRIPTION_REFUNDED', + 'SUBSCRIPTION_CANCELLED', + ].includes(subscriptionEventType) + ) { + return Result.fail(`Invalid subscription event type ${subscriptionEventType}`) + } else { + return Result.ok(new SubscriptionEventType({ value: subscriptionEventType })) + } + } +} diff --git a/packages/analytics/src/Domain/Subscription/SubscriptionEventTypeProps.ts b/packages/analytics/src/Domain/Subscription/SubscriptionEventTypeProps.ts new file mode 100644 index 000000000..d54c5247d --- /dev/null +++ b/packages/analytics/src/Domain/Subscription/SubscriptionEventTypeProps.ts @@ -0,0 +1,3 @@ +export interface SubscriptionEventTypeProps { + value: string +} diff --git a/packages/analytics/src/Domain/Subscription/SubscriptionPlanName.spec.ts b/packages/analytics/src/Domain/Subscription/SubscriptionPlanName.spec.ts new file mode 100644 index 000000000..7cf2fc757 --- /dev/null +++ b/packages/analytics/src/Domain/Subscription/SubscriptionPlanName.spec.ts @@ -0,0 +1,16 @@ +import { SubscriptionPlanName } from './SubscriptionPlanName' + +describe('SubscriptionPlanName', () => { + it('should create a value object', () => { + const valueOrError = SubscriptionPlanName.create('PRO_PLAN') + + expect(valueOrError.isFailed()).toBeFalsy() + expect(valueOrError.getValue().value).toEqual('PRO_PLAN') + }) + + it('should not create an invalid value object', () => { + const valueOrError = SubscriptionPlanName.create('TEST') + + expect(valueOrError.isFailed()).toBeTruthy() + }) +}) diff --git a/packages/analytics/src/Domain/Subscription/SubscriptionPlanName.ts b/packages/analytics/src/Domain/Subscription/SubscriptionPlanName.ts new file mode 100644 index 000000000..55665a40f --- /dev/null +++ b/packages/analytics/src/Domain/Subscription/SubscriptionPlanName.ts @@ -0,0 +1,21 @@ +import { ValueObject } from '../Core/ValueObject' +import { Result } from '../Core/Result' +import { SubscriptionPlanNameProps } from './SubscriptionPlanNameProps' + +export class SubscriptionPlanName extends ValueObject { + get value(): string { + return this.props.value + } + + private constructor(props: SubscriptionPlanNameProps) { + super(props) + } + + static create(subscriptionPlanName: string): Result { + if (!['PRO_PLAN', 'PLUS_PLAN'].includes(subscriptionPlanName)) { + return Result.fail(`Invalid subscription plan name ${subscriptionPlanName}`) + } else { + return Result.ok(new SubscriptionPlanName({ value: subscriptionPlanName })) + } + } +} diff --git a/packages/analytics/src/Domain/Subscription/SubscriptionPlanNameProps.ts b/packages/analytics/src/Domain/Subscription/SubscriptionPlanNameProps.ts new file mode 100644 index 000000000..7478e3354 --- /dev/null +++ b/packages/analytics/src/Domain/Subscription/SubscriptionPlanNameProps.ts @@ -0,0 +1,3 @@ +export interface SubscriptionPlanNameProps { + value: string +} diff --git a/packages/analytics/src/Domain/Subscription/SubscriptionProps.ts b/packages/analytics/src/Domain/Subscription/SubscriptionProps.ts new file mode 100644 index 000000000..cb5603553 --- /dev/null +++ b/packages/analytics/src/Domain/Subscription/SubscriptionProps.ts @@ -0,0 +1,8 @@ +import { SubscriptionPlanName } from '../Subscription/SubscriptionPlanName' + +export interface SubscriptionProps { + planName: SubscriptionPlanName + isFirstSubscriptionForUser: boolean + payedAmount: number + billingFrequency: number +} diff --git a/packages/analytics/src/Domain/UseCase/DomainUseCaseInterface.ts b/packages/analytics/src/Domain/UseCase/DomainUseCaseInterface.ts new file mode 100644 index 000000000..e97e8a7ae --- /dev/null +++ b/packages/analytics/src/Domain/UseCase/DomainUseCaseInterface.ts @@ -0,0 +1,5 @@ +import { Result } from '../Core/Result' + +export interface DomainUseCaseInterface { + execute(...args: any[]): Promise> +} diff --git a/packages/analytics/src/Domain/UseCase/SaveRevenueModification/SaveRevenueModification.spec.ts b/packages/analytics/src/Domain/UseCase/SaveRevenueModification/SaveRevenueModification.spec.ts new file mode 100644 index 000000000..8533e5298 --- /dev/null +++ b/packages/analytics/src/Domain/UseCase/SaveRevenueModification/SaveRevenueModification.spec.ts @@ -0,0 +1,44 @@ +import 'reflect-metadata' + +import { Email } from '../../Common/Email' +import { Uuid } from '../../Common/Uuid' +import { MonthlyRevenue } from '../../Revenue/MonthlyRevenue' + +import { RevenueModification } from '../../Revenue/RevenueModification' +import { RevenueModificationRepositoryInterface } from '../../Revenue/RevenueModificationRepositoryInterface' +import { SubscriptionEventType } from '../../Subscription/SubscriptionEventType' +import { SubscriptionPlanName } from '../../Subscription/SubscriptionPlanName' +import { SaveRevenueModification } from './SaveRevenueModification' + +describe('SaveRevenueModification', () => { + let revenueModificationRepository: RevenueModificationRepositoryInterface + let previousMonthlyRevenue: RevenueModification + + const createUseCase = () => new SaveRevenueModification(revenueModificationRepository) + + beforeEach(() => { + previousMonthlyRevenue = { + newMonthlyRevenue: MonthlyRevenue.create(2).getValue(), + } as jest.Mocked + + revenueModificationRepository = {} as jest.Mocked + revenueModificationRepository.findLastByUserUuid = jest.fn().mockReturnValue(previousMonthlyRevenue) + revenueModificationRepository.save = jest.fn() + }) + + it('should persist a revenue modification', async () => { + const revenueOrError = await createUseCase().execute({ + billingFrequency: 1, + eventType: SubscriptionEventType.create('SUBSCRIPTION_PURCHASED').getValue(), + newSubscriber: true, + payedAmount: 12.99, + planName: SubscriptionPlanName.create('PRO_PLAN').getValue(), + subscriptionId: 1234, + userEmail: Email.create('test@test.te').getValue(), + userUuid: Uuid.create('1-2-3').getValue(), + }) + expect(revenueOrError.isFailed()).toBeFalsy() + const revenue = revenueOrError.getValue() + expect(revenue.newMonthlyRevenue.value).toEqual(12.99) + }) +}) diff --git a/packages/analytics/src/Domain/UseCase/SaveRevenueModification/SaveRevenueModification.ts b/packages/analytics/src/Domain/UseCase/SaveRevenueModification/SaveRevenueModification.ts new file mode 100644 index 000000000..1a5c93343 --- /dev/null +++ b/packages/analytics/src/Domain/UseCase/SaveRevenueModification/SaveRevenueModification.ts @@ -0,0 +1,54 @@ +import { inject, injectable } from 'inversify' +import TYPES from '../../../Bootstrap/Types' +import { UniqueEntityId } from '../../Core/UniqueEntityId' +import { MonthlyRevenue } from '../../Revenue/MonthlyRevenue' +import { RevenueModification } from '../../Revenue/RevenueModification' +import { RevenueModificationRepositoryInterface } from '../../Revenue/RevenueModificationRepositoryInterface' +import { Subscription } from '../../Subscription/Subscription' +import { User } from '../../User/User' +import { Result } from '../../Core/Result' +import { DomainUseCaseInterface } from '../DomainUseCaseInterface' +import { SaveRevenueModificationDTO } from './SaveRevenueModificationDTO' + +@injectable() +export class SaveRevenueModification implements DomainUseCaseInterface { + constructor( + @inject(TYPES.RevenueModificationRepository) + private revenueModificationRepository: RevenueModificationRepositoryInterface, + ) {} + + async execute(dto: SaveRevenueModificationDTO): Promise> { + const user = User.create( + { + email: dto.userEmail, + }, + new UniqueEntityId(dto.userUuid.value), + ) + const subscription = Subscription.create( + { + isFirstSubscriptionForUser: dto.newSubscriber, + payedAmount: dto.payedAmount, + planName: dto.planName, + billingFrequency: dto.billingFrequency, + }, + new UniqueEntityId(dto.subscriptionId), + ) + + let previousMonthlyRevenue = MonthlyRevenue.create(0).getValue() + const previousRevenueModification = await this.revenueModificationRepository.findLastByUserUuid(dto.userUuid) + if (previousRevenueModification !== null) { + previousMonthlyRevenue = previousRevenueModification.newMonthlyRevenue + } + + const revenueModification = RevenueModification.create({ + eventType: dto.eventType, + subscription, + user, + previousMonthlyRevenue, + }) + + await this.revenueModificationRepository.save(revenueModification) + + return Result.ok(revenueModification) + } +} diff --git a/packages/analytics/src/Domain/UseCase/SaveRevenueModification/SaveRevenueModificationDTO.ts b/packages/analytics/src/Domain/UseCase/SaveRevenueModification/SaveRevenueModificationDTO.ts new file mode 100644 index 000000000..96f01cb2e --- /dev/null +++ b/packages/analytics/src/Domain/UseCase/SaveRevenueModification/SaveRevenueModificationDTO.ts @@ -0,0 +1,15 @@ +import { Email } from '../../Common/Email' +import { Uuid } from '../../Common/Uuid' +import { SubscriptionEventType } from '../../Subscription/SubscriptionEventType' +import { SubscriptionPlanName } from '../../Subscription/SubscriptionPlanName' + +export interface SaveRevenueModificationDTO { + eventType: SubscriptionEventType + payedAmount: number + planName: SubscriptionPlanName + newSubscriber: boolean + userUuid: Uuid + userEmail: Email + subscriptionId: number + billingFrequency: number +} diff --git a/packages/analytics/src/Domain/User/User.spec.ts b/packages/analytics/src/Domain/User/User.spec.ts new file mode 100644 index 000000000..a9ac93733 --- /dev/null +++ b/packages/analytics/src/Domain/User/User.spec.ts @@ -0,0 +1,12 @@ +import { Email } from '../Common/Email' +import { User } from './User' + +describe('User', () => { + it('should create an entity', () => { + const user = User.create({ + email: Email.create('test@test.te').getValue(), + }) + + expect(user.id.toString()).toHaveLength(36) + }) +}) diff --git a/packages/analytics/src/Domain/User/User.ts b/packages/analytics/src/Domain/User/User.ts new file mode 100644 index 000000000..e18fafd18 --- /dev/null +++ b/packages/analytics/src/Domain/User/User.ts @@ -0,0 +1,17 @@ +import { Entity } from '../Core/Entity' +import { UniqueEntityId } from '../Core/UniqueEntityId' +import { UserProps } from './UserProps' + +export class User extends Entity { + get id(): UniqueEntityId { + return this._id + } + + private constructor(props: UserProps, id?: UniqueEntityId) { + super(props, id) + } + + public static create(props: UserProps, id?: UniqueEntityId): User { + return new User(props, id) + } +} diff --git a/packages/analytics/src/Domain/User/UserProps.ts b/packages/analytics/src/Domain/User/UserProps.ts new file mode 100644 index 000000000..cc51d0314 --- /dev/null +++ b/packages/analytics/src/Domain/User/UserProps.ts @@ -0,0 +1,5 @@ +import { Email } from '../Common/Email' + +export interface UserProps { + email: Email +} diff --git a/packages/analytics/src/Infra/MySQL/MySQLAnalyticsEntityRepository.spec.ts b/packages/analytics/src/Infra/MySQL/MySQLAnalyticsEntityRepository.spec.ts deleted file mode 100644 index 43546abfa..000000000 --- a/packages/analytics/src/Infra/MySQL/MySQLAnalyticsEntityRepository.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -import 'reflect-metadata' - -import { Repository, SelectQueryBuilder } from 'typeorm' - -import { AnalyticsEntity } from '../../Domain/Entity/AnalyticsEntity' - -import { MySQLAnalyticsEntityRepository } from './MySQLAnalyticsEntityRepository' - -describe('MySQLAnalyticsEntityRepository', () => { - let ormRepository: Repository - let analyticsEntity: AnalyticsEntity - let queryBuilder: SelectQueryBuilder - - const createRepository = () => new MySQLAnalyticsEntityRepository(ormRepository) - - beforeEach(() => { - analyticsEntity = {} as jest.Mocked - - queryBuilder = {} as jest.Mocked> - - ormRepository = {} as jest.Mocked> - ormRepository.save = jest.fn() - ormRepository.remove = jest.fn() - ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => queryBuilder) - }) - - it('should save', async () => { - await createRepository().save(analyticsEntity) - - expect(ormRepository.save).toHaveBeenCalledWith(analyticsEntity) - }) - - it('should remove', async () => { - await createRepository().remove(analyticsEntity) - - expect(ormRepository.remove).toHaveBeenCalledWith(analyticsEntity) - }) - - it('should find one by user uuid', async () => { - queryBuilder.where = jest.fn().mockReturnThis() - queryBuilder.getOne = jest.fn().mockReturnValue(analyticsEntity) - - const result = await createRepository().findOneByUserUuid('123') - - expect(queryBuilder.where).toHaveBeenCalledWith('analytics_entity.user_uuid = :userUuid', { userUuid: '123' }) - - expect(result).toEqual(analyticsEntity) - }) - - it('should find one by user email', async () => { - queryBuilder.where = jest.fn().mockReturnThis() - queryBuilder.getOne = jest.fn().mockReturnValue(analyticsEntity) - - const result = await createRepository().findOneByUserEmail('test@test.te') - - expect(queryBuilder.where).toHaveBeenCalledWith('analytics_entity.user_email = :email', { email: 'test@test.te' }) - - expect(result).toEqual(analyticsEntity) - }) -}) diff --git a/packages/analytics/src/Infra/MySQL/MySQLRevenueModificationRepository.ts b/packages/analytics/src/Infra/MySQL/MySQLRevenueModificationRepository.ts new file mode 100644 index 000000000..4c2d3f422 --- /dev/null +++ b/packages/analytics/src/Infra/MySQL/MySQLRevenueModificationRepository.ts @@ -0,0 +1,42 @@ +import { inject, injectable } from 'inversify' +import { Repository } from 'typeorm' + +import TYPES from '../../Bootstrap/Types' +import { Uuid } from '../../Domain/Common/Uuid' +import { MapInterface } from '../../Domain/Map/MapInterface' +import { RevenueModification } from '../../Domain/Revenue/RevenueModification' +import { RevenueModificationRepositoryInterface } from '../../Domain/Revenue/RevenueModificationRepositoryInterface' +import { TypeORMRevenueModification } from '../TypeORM/TypeORMRevenueModification' + +@injectable() +export class MySQLRevenueModificationRepository implements RevenueModificationRepositoryInterface { + constructor( + @inject(TYPES.ORMRevenueModificationRepository) + private ormRepository: Repository, + @inject(TYPES.RevenueModificationMap) + private revenueModificationMap: MapInterface, + ) {} + + async findLastByUserUuid(userUuid: Uuid): Promise { + const persistence = await this.ormRepository + .createQueryBuilder() + .where('user_uuid = :userUuid', { userUuid: userUuid.value }) + .orderBy('created_at', 'DESC') + .limit(1) + .getOne() + + if (persistence === null) { + return null + } + + return this.revenueModificationMap.toDomain(persistence) + } + + async save(revenueModification: RevenueModification): Promise { + let persistence = this.revenueModificationMap.toPersistence(revenueModification) + + persistence = await this.ormRepository.save(persistence) + + return this.revenueModificationMap.toDomain(persistence) + } +} diff --git a/packages/analytics/src/Infra/TypeORM/TypeORMRevenueModification.ts b/packages/analytics/src/Infra/TypeORM/TypeORMRevenueModification.ts new file mode 100644 index 000000000..facf2742e --- /dev/null +++ b/packages/analytics/src/Infra/TypeORM/TypeORMRevenueModification.ts @@ -0,0 +1,56 @@ +import { Column, Entity, Index, PrimaryColumn } from 'typeorm' + +@Entity({ name: 'revenue_modifications' }) +export class TypeORMRevenueModification { + @PrimaryColumn() + declare uuid: string + + @Column({ + name: 'subscription_id', + }) + declare subscriptionId: number + + @Column({ + name: 'user_email', + length: 255, + }) + @Index('email') + declare userEmail: string + + @Column({ + name: 'user_uuid', + length: 36, + }) + @Index('user_uuid') + declare userUuid: string + + @Column({ + name: 'event_type', + }) + declare eventType: string + + @Column({ + name: 'subscription_plan', + }) + declare subscriptionPlan: string + + @Column({ + name: 'billing_frequency', + }) + declare billingFrequency: number + + @Column({ + name: 'new_customer', + }) + declare isNewCustomer: boolean + + @Column({ + name: 'previous_mrr', + }) + declare previousMonthlyRevenue: number + + @Column({ + name: 'new_mrr', + }) + declare newMonthlyRevenue: number +} diff --git a/yarn.lock b/yarn.lock index 9afbc350c..7309903a6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1815,6 +1815,7 @@ __metadata: "@types/jest": "npm:^29.1.1" "@types/newrelic": "npm:^7.0.3" "@types/node": "npm:^18.0.0" + "@types/uuid": "npm:^8.3.0" "@typescript-eslint/eslint-plugin": "npm:^5.30.0" aws-sdk: "npm:^2.1158.0" dayjs: "npm:^1.11.6" @@ -1827,9 +1828,11 @@ __metadata: mysql2: "npm:^2.3.3" newrelic: "npm:^9.0.0" reflect-metadata: "npm:^0.1.13" + shallow-equal-object: "npm:^1.1.1" ts-jest: "npm:^29.0.3" typeorm: "npm:^0.3.6" typescript: "npm:^4.8.4" + uuid: "npm:^9.0.0" winston: "npm:^3.8.1" languageName: unknown linkType: soft @@ -9969,6 +9972,13 @@ __metadata: languageName: node linkType: hard +"shallow-equal-object@npm:^1.1.1": + version: 1.1.1 + resolution: "shallow-equal-object@npm:1.1.1" + checksum: 9e5e0cd10ba5447f85038d7b104e66c15603e164b2112366f044f9447512bfb6f0b71bd9869e76824e76fae76568e94df3d9871bf5af8ab2ff78eee9487baecf + languageName: node + linkType: hard + "shebang-command@npm:^2.0.0": version: 2.0.0 resolution: "shebang-command@npm:2.0.0"