From 0103233d4a1e222e7c9b059475c1cdc3b2617455 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karol=20S=C3=B3jko?= Date: Tue, 8 Nov 2022 13:53:11 +0100 Subject: [PATCH] feat(analytics): create new ddd architecture for persisting revenue modifications --- .pnp.cjs | 12 ++++ ...object-npm-1.1.1-a41b289b2e-9e5e0cd10b.zip | Bin 0 -> 6910 bytes packages/analytics/jest.config.js | 1 + packages/analytics/package.json | 3 + packages/analytics/src/Bootstrap/Container.ts | 17 +++++ .../analytics/src/Bootstrap/DataSource.ts | 3 +- packages/analytics/src/Bootstrap/Types.ts | 4 ++ .../analytics/src/Domain/Common/Email.spec.ts | 16 +++++ packages/analytics/src/Domain/Common/Email.ts | 21 ++++++ .../analytics/src/Domain/Common/EmailProps.ts | 3 + .../analytics/src/Domain/Common/Uuid.spec.ts | 16 +++++ packages/analytics/src/Domain/Common/Uuid.ts | 21 ++++++ .../analytics/src/Domain/Common/UuidProps.ts | 3 + .../analytics/src/Domain/Core/Aggregate.ts | 10 +++ packages/analytics/src/Domain/Core/Entity.ts | 27 ++++++++ packages/analytics/src/Domain/Core/Id.ts | 24 +++++++ packages/analytics/src/Domain/Core/Result.ts | 35 ++++++++++ .../src/Domain/Core/UniqueEntityId.ts | 10 +++ .../analytics/src/Domain/Core/ValueObject.ts | 24 +++++++ .../src/Domain/Core/ValueObjectProps.ts | 4 ++ .../analytics/src/Domain/Map/MapInterface.ts | 4 ++ .../src/Domain/Map/RevenueModificationMap.ts | 58 +++++++++++++++++ .../src/Domain/Revenue/MonthlyRevenue.spec.ts | 16 +++++ .../src/Domain/Revenue/MonthlyRevenue.ts | 21 ++++++ .../src/Domain/Revenue/MonthlyRevenueProps.ts | 3 + .../Revenue/RevenueModification.spec.ts | 61 ++++++++++++++++++ .../src/Domain/Revenue/RevenueModification.ts | 45 +++++++++++++ .../Revenue/RevenueModificationProps.ts | 12 ++++ .../RevenueModificationRepositoryInterface.ts | 7 ++ .../Domain/Subscription/Subscription.spec.ts | 15 +++++ .../src/Domain/Subscription/Subscription.ts | 17 +++++ .../SubscriptionEventType.spec.ts | 16 +++++ .../Subscription/SubscriptionEventType.ts | 29 +++++++++ .../SubscriptionEventTypeProps.ts | 3 + .../Subscription/SubscriptionPlanName.spec.ts | 16 +++++ .../Subscription/SubscriptionPlanName.ts | 21 ++++++ .../Subscription/SubscriptionPlanNameProps.ts | 3 + .../Domain/Subscription/SubscriptionProps.ts | 8 +++ .../Domain/UseCase/DomainUseCaseInterface.ts | 5 ++ .../SaveRevenueModification.spec.ts | 44 +++++++++++++ .../SaveRevenueModification.ts | 54 ++++++++++++++++ .../SaveRevenueModificationDTO.ts | 15 +++++ .../analytics/src/Domain/User/User.spec.ts | 12 ++++ packages/analytics/src/Domain/User/User.ts | 17 +++++ .../analytics/src/Domain/User/UserProps.ts | 5 ++ .../MySQLAnalyticsEntityRepository.spec.ts | 60 ----------------- .../MySQLRevenueModificationRepository.ts | 42 ++++++++++++ .../TypeORM/TypeORMRevenueModification.ts | 56 ++++++++++++++++ yarn.lock | 10 +++ 49 files changed, 868 insertions(+), 61 deletions(-) create mode 100644 .yarn/cache/shallow-equal-object-npm-1.1.1-a41b289b2e-9e5e0cd10b.zip create mode 100644 packages/analytics/src/Domain/Common/Email.spec.ts create mode 100644 packages/analytics/src/Domain/Common/Email.ts create mode 100644 packages/analytics/src/Domain/Common/EmailProps.ts create mode 100644 packages/analytics/src/Domain/Common/Uuid.spec.ts create mode 100644 packages/analytics/src/Domain/Common/Uuid.ts create mode 100644 packages/analytics/src/Domain/Common/UuidProps.ts create mode 100644 packages/analytics/src/Domain/Core/Aggregate.ts create mode 100644 packages/analytics/src/Domain/Core/Entity.ts create mode 100644 packages/analytics/src/Domain/Core/Id.ts create mode 100644 packages/analytics/src/Domain/Core/Result.ts create mode 100644 packages/analytics/src/Domain/Core/UniqueEntityId.ts create mode 100644 packages/analytics/src/Domain/Core/ValueObject.ts create mode 100644 packages/analytics/src/Domain/Core/ValueObjectProps.ts create mode 100644 packages/analytics/src/Domain/Map/MapInterface.ts create mode 100644 packages/analytics/src/Domain/Map/RevenueModificationMap.ts create mode 100644 packages/analytics/src/Domain/Revenue/MonthlyRevenue.spec.ts create mode 100644 packages/analytics/src/Domain/Revenue/MonthlyRevenue.ts create mode 100644 packages/analytics/src/Domain/Revenue/MonthlyRevenueProps.ts create mode 100644 packages/analytics/src/Domain/Revenue/RevenueModification.spec.ts create mode 100644 packages/analytics/src/Domain/Revenue/RevenueModification.ts create mode 100644 packages/analytics/src/Domain/Revenue/RevenueModificationProps.ts create mode 100644 packages/analytics/src/Domain/Revenue/RevenueModificationRepositoryInterface.ts create mode 100644 packages/analytics/src/Domain/Subscription/Subscription.spec.ts create mode 100644 packages/analytics/src/Domain/Subscription/Subscription.ts create mode 100644 packages/analytics/src/Domain/Subscription/SubscriptionEventType.spec.ts create mode 100644 packages/analytics/src/Domain/Subscription/SubscriptionEventType.ts create mode 100644 packages/analytics/src/Domain/Subscription/SubscriptionEventTypeProps.ts create mode 100644 packages/analytics/src/Domain/Subscription/SubscriptionPlanName.spec.ts create mode 100644 packages/analytics/src/Domain/Subscription/SubscriptionPlanName.ts create mode 100644 packages/analytics/src/Domain/Subscription/SubscriptionPlanNameProps.ts create mode 100644 packages/analytics/src/Domain/Subscription/SubscriptionProps.ts create mode 100644 packages/analytics/src/Domain/UseCase/DomainUseCaseInterface.ts create mode 100644 packages/analytics/src/Domain/UseCase/SaveRevenueModification/SaveRevenueModification.spec.ts create mode 100644 packages/analytics/src/Domain/UseCase/SaveRevenueModification/SaveRevenueModification.ts create mode 100644 packages/analytics/src/Domain/UseCase/SaveRevenueModification/SaveRevenueModificationDTO.ts create mode 100644 packages/analytics/src/Domain/User/User.spec.ts create mode 100644 packages/analytics/src/Domain/User/User.ts create mode 100644 packages/analytics/src/Domain/User/UserProps.ts delete mode 100644 packages/analytics/src/Infra/MySQL/MySQLAnalyticsEntityRepository.spec.ts create mode 100644 packages/analytics/src/Infra/MySQL/MySQLRevenueModificationRepository.ts create mode 100644 packages/analytics/src/Infra/TypeORM/TypeORMRevenueModification.ts 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 0000000000000000000000000000000000000000..d9f9b27c8cd97b9cb27152bc5717c7932154731a GIT binary patch literal 6910 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 I{PF330I3a(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"