diff --git a/packages/analytics/src/Domain/Statistics/StatisticsMeasure.ts b/packages/analytics/src/Domain/Statistics/StatisticsMeasure.ts index 654bd806a..ecdc2dadc 100644 --- a/packages/analytics/src/Domain/Statistics/StatisticsMeasure.ts +++ b/packages/analytics/src/Domain/Statistics/StatisticsMeasure.ts @@ -9,4 +9,5 @@ export enum StatisticsMeasure { NotesCountPaidUsers = 'notes-count-paid-users', FilesCount = 'files-count', NewCustomers = 'new-customers', + TotalCustomers = 'total-customers', } diff --git a/packages/analytics/src/Domain/Statistics/StatisticsStoreInterface.ts b/packages/analytics/src/Domain/Statistics/StatisticsStoreInterface.ts index 841962505..a1132e82d 100644 --- a/packages/analytics/src/Domain/Statistics/StatisticsStoreInterface.ts +++ b/packages/analytics/src/Domain/Statistics/StatisticsStoreInterface.ts @@ -9,6 +9,7 @@ export interface StatisticsStoreInterface { getYesterdayApplicationUsage(): Promise> getYesterdayOutOfSyncIncidents(): Promise incrementMeasure(measure: StatisticsMeasure, value: number, periods: Period[]): Promise + setMeasure(measure: StatisticsMeasure, value: number, periods: Period[]): Promise getMeasureAverage(measure: StatisticsMeasure, period: Period): Promise getMeasureTotal(measure: StatisticsMeasure, period: Period): Promise } diff --git a/packages/analytics/src/Domain/Time/Period.ts b/packages/analytics/src/Domain/Time/Period.ts index ec37f602e..5742b8b82 100644 --- a/packages/analytics/src/Domain/Time/Period.ts +++ b/packages/analytics/src/Domain/Time/Period.ts @@ -7,6 +7,7 @@ export enum Period { WeekBeforeLastWeek, ThisMonth, LastMonth, + ThisYear, Last30Days, Last7Days, Q1ThisYear, diff --git a/packages/analytics/src/Domain/Time/PeriodKeyGenerator.ts b/packages/analytics/src/Domain/Time/PeriodKeyGenerator.ts index 01fe3bbc5..584de935f 100644 --- a/packages/analytics/src/Domain/Time/PeriodKeyGenerator.ts +++ b/packages/analytics/src/Domain/Time/PeriodKeyGenerator.ts @@ -49,11 +49,19 @@ export class PeriodKeyGenerator implements PeriodKeyGeneratorInterface { return this.getMonthlyKey() case Period.LastMonth: return this.getMonthlyKey(this.getLastMonthDate()) + case Period.ThisYear: + return this.getYearlyKey() default: throw new Error(`Unsuporrted period: ${period}`) } } + private getYearlyKey(date?: Date): string { + date = date ?? new Date() + + return this.getYear(date) + } + private getMonthlyKey(date?: Date): string { date = date ?? new Date() diff --git a/packages/analytics/src/Infra/Redis/RedisStatisticsStore.spec.ts b/packages/analytics/src/Infra/Redis/RedisStatisticsStore.spec.ts index e8b9832d1..f5456529c 100644 --- a/packages/analytics/src/Infra/Redis/RedisStatisticsStore.spec.ts +++ b/packages/analytics/src/Infra/Redis/RedisStatisticsStore.spec.ts @@ -16,6 +16,7 @@ describe('RedisStatisticsStore', () => { pipeline = {} as jest.Mocked pipeline.incr = jest.fn() pipeline.incrbyfloat = jest.fn() + pipeline.set = jest.fn() pipeline.setbit = jest.fn() pipeline.exec = jest.fn() @@ -92,6 +93,13 @@ describe('RedisStatisticsStore', () => { expect(pipeline.exec).toHaveBeenCalled() }) + it('should set a value to a measure', async () => { + await createStore().setMeasure(StatisticsMeasure.Income, 2, [Period.Today, Period.ThisMonth]) + + expect(pipeline.set).toHaveBeenCalledTimes(2) + expect(pipeline.exec).toHaveBeenCalled() + }) + it('should increment measure by a value', async () => { await createStore().incrementMeasure(StatisticsMeasure.Income, 2, [Period.Today, Period.ThisMonth]) diff --git a/packages/analytics/src/Infra/Redis/RedisStatisticsStore.ts b/packages/analytics/src/Infra/Redis/RedisStatisticsStore.ts index 9777f8d78..4f5d85da7 100644 --- a/packages/analytics/src/Infra/Redis/RedisStatisticsStore.ts +++ b/packages/analytics/src/Infra/Redis/RedisStatisticsStore.ts @@ -8,6 +8,16 @@ import { StatisticsStoreInterface } from '../../Domain/Statistics/StatisticsStor export class RedisStatisticsStore implements StatisticsStoreInterface { constructor(private periodKeyGenerator: PeriodKeyGeneratorInterface, private redisClient: IORedis.Redis) {} + async setMeasure(measure: StatisticsMeasure, value: number, periods: Period[]): Promise { + const pipeline = this.redisClient.pipeline() + + for (const period of periods) { + pipeline.set(`count:measure:${measure}:timespan:${this.periodKeyGenerator.getPeriodKey(period)}`, value) + } + + await pipeline.exec() + } + async getMeasureTotal(measure: StatisticsMeasure, period: Period): Promise { const totalValue = await this.redisClient.get( `count:measure:${measure}:timespan:${this.periodKeyGenerator.getPeriodKey(period)}`, diff --git a/packages/auth/src/Domain/Handler/SubscriptionExpiredEventHandler.spec.ts b/packages/auth/src/Domain/Handler/SubscriptionExpiredEventHandler.spec.ts index b08e89248..5b08a066d 100644 --- a/packages/auth/src/Domain/Handler/SubscriptionExpiredEventHandler.spec.ts +++ b/packages/auth/src/Domain/Handler/SubscriptionExpiredEventHandler.spec.ts @@ -13,7 +13,7 @@ import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscri import { RoleServiceInterface } from '../Role/RoleServiceInterface' import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface' import { UserSubscription } from '../Subscription/UserSubscription' -import { AnalyticsStoreInterface } from '@standardnotes/analytics' +import { AnalyticsStoreInterface, StatisticsStoreInterface } from '@standardnotes/analytics' import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId' describe('SubscriptionExpiredEventHandler', () => { @@ -27,6 +27,7 @@ describe('SubscriptionExpiredEventHandler', () => { let timestamp: number let getUserAnalyticsId: GetUserAnalyticsId let analyticsStore: AnalyticsStoreInterface + let statisticsStore: StatisticsStoreInterface const createHandler = () => new SubscriptionExpiredEventHandler( @@ -36,6 +37,7 @@ describe('SubscriptionExpiredEventHandler', () => { roleService, getUserAnalyticsId, analyticsStore, + statisticsStore, logger, ) @@ -56,6 +58,7 @@ describe('SubscriptionExpiredEventHandler', () => { userSubscriptionRepository = {} as jest.Mocked userSubscriptionRepository.updateEndsAt = jest.fn() + userSubscriptionRepository.countActiveSubscriptions = jest.fn().mockReturnValue(13) userSubscriptionRepository.findBySubscriptionId = jest .fn() .mockReturnValue([{ user: Promise.resolve(user) } as jest.Mocked]) @@ -84,6 +87,9 @@ describe('SubscriptionExpiredEventHandler', () => { analyticsStore = {} as jest.Mocked analyticsStore.markActivity = jest.fn() + statisticsStore = {} as jest.Mocked + statisticsStore.setMeasure = jest.fn() + logger = {} as jest.Mocked logger.info = jest.fn() logger.warn = jest.fn() diff --git a/packages/auth/src/Domain/Handler/SubscriptionExpiredEventHandler.ts b/packages/auth/src/Domain/Handler/SubscriptionExpiredEventHandler.ts index 416a9e4c8..0be74234c 100644 --- a/packages/auth/src/Domain/Handler/SubscriptionExpiredEventHandler.ts +++ b/packages/auth/src/Domain/Handler/SubscriptionExpiredEventHandler.ts @@ -8,7 +8,13 @@ import { RoleServiceInterface } from '../Role/RoleServiceInterface' import { UserRepositoryInterface } from '../User/UserRepositoryInterface' import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscriptionRepositoryInterface' import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface' -import { AnalyticsStoreInterface, AnalyticsActivity, Period } from '@standardnotes/analytics' +import { + AnalyticsStoreInterface, + AnalyticsActivity, + Period, + StatisticsMeasure, + StatisticsStoreInterface, +} from '@standardnotes/analytics' import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId' @injectable() @@ -21,6 +27,7 @@ export class SubscriptionExpiredEventHandler implements DomainEventHandlerInterf @inject(TYPES.RoleService) private roleService: RoleServiceInterface, @inject(TYPES.GetUserAnalyticsId) private getUserAnalyticsId: GetUserAnalyticsId, @inject(TYPES.AnalyticsStore) private analyticsStore: AnalyticsStoreInterface, + @inject(TYPES.StatisticsStore) private statisticsStore: StatisticsStoreInterface, @inject(TYPES.Logger) private logger: Logger, ) {} @@ -47,6 +54,14 @@ export class SubscriptionExpiredEventHandler implements DomainEventHandlerInterf analyticsId, [Period.Today, Period.ThisWeek, Period.ThisMonth], ) + + const activeSubscriptions = await this.userSubscriptionRepository.countActiveSubscriptions() + await this.statisticsStore.setMeasure(StatisticsMeasure.TotalCustomers, activeSubscriptions, [ + Period.Today, + Period.ThisWeek, + Period.ThisMonth, + Period.ThisYear, + ]) } private async removeRoleFromSubscriptionUsers( diff --git a/packages/auth/src/Domain/Handler/SubscriptionPurchasedEventHandler.spec.ts b/packages/auth/src/Domain/Handler/SubscriptionPurchasedEventHandler.spec.ts index 4350c6be4..bee4cd399 100644 --- a/packages/auth/src/Domain/Handler/SubscriptionPurchasedEventHandler.spec.ts +++ b/packages/auth/src/Domain/Handler/SubscriptionPurchasedEventHandler.spec.ts @@ -73,12 +73,14 @@ describe('SubscriptionPurchasedEventHandler', () => { statisticsStore = {} as jest.Mocked statisticsStore.incrementMeasure = jest.fn() + statisticsStore.setMeasure = jest.fn() timer = {} as jest.Mocked timer.convertDateToMicroseconds = jest.fn().mockReturnValue(1) userSubscriptionRepository = {} as jest.Mocked userSubscriptionRepository.countByUserUuid = jest.fn().mockReturnValue(0) + userSubscriptionRepository.countActiveSubscriptions = jest.fn().mockReturnValue(13) userSubscriptionRepository.save = jest.fn().mockReturnValue(subscription) offlineUserSubscription = {} as jest.Mocked diff --git a/packages/auth/src/Domain/Handler/SubscriptionPurchasedEventHandler.ts b/packages/auth/src/Domain/Handler/SubscriptionPurchasedEventHandler.ts index a43eba632..02cb9df4d 100644 --- a/packages/auth/src/Domain/Handler/SubscriptionPurchasedEventHandler.ts +++ b/packages/auth/src/Domain/Handler/SubscriptionPurchasedEventHandler.ts @@ -108,6 +108,14 @@ export class SubscriptionPurchasedEventHandler implements DomainEventHandlerInte Period.Today, Period.ThisWeek, Period.ThisMonth, + Period.ThisYear, + ]) + const activeSubscriptions = await this.userSubscriptionRepository.countActiveSubscriptions() + await this.statisticsStore.setMeasure(StatisticsMeasure.TotalCustomers, activeSubscriptions, [ + Period.Today, + Period.ThisWeek, + Period.ThisMonth, + Period.ThisYear, ]) } } diff --git a/packages/auth/src/Domain/Handler/SubscriptionRefundedEventHandler.spec.ts b/packages/auth/src/Domain/Handler/SubscriptionRefundedEventHandler.spec.ts index 374548d25..f9e1abd8d 100644 --- a/packages/auth/src/Domain/Handler/SubscriptionRefundedEventHandler.spec.ts +++ b/packages/auth/src/Domain/Handler/SubscriptionRefundedEventHandler.spec.ts @@ -14,7 +14,7 @@ import { RoleServiceInterface } from '../Role/RoleServiceInterface' import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface' import { UserSubscription } from '../Subscription/UserSubscription' import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId' -import { AnalyticsActivity, AnalyticsStoreInterface, Period } from '@standardnotes/analytics' +import { AnalyticsActivity, AnalyticsStoreInterface, Period, StatisticsStoreInterface } from '@standardnotes/analytics' describe('SubscriptionRefundedEventHandler', () => { let userRepository: UserRepositoryInterface @@ -27,6 +27,7 @@ describe('SubscriptionRefundedEventHandler', () => { let timestamp: number let getUserAnalyticsId: GetUserAnalyticsId let analyticsStore: AnalyticsStoreInterface + let statisticsStore: StatisticsStoreInterface const createHandler = () => new SubscriptionRefundedEventHandler( @@ -36,6 +37,7 @@ describe('SubscriptionRefundedEventHandler', () => { roleService, getUserAnalyticsId, analyticsStore, + statisticsStore, logger, ) @@ -57,6 +59,7 @@ describe('SubscriptionRefundedEventHandler', () => { userSubscriptionRepository = {} as jest.Mocked userSubscriptionRepository.updateEndsAt = jest.fn() userSubscriptionRepository.countByUserUuid = jest.fn().mockReturnValue(1) + userSubscriptionRepository.countActiveSubscriptions = jest.fn().mockReturnValue(13) userSubscriptionRepository.findBySubscriptionId = jest .fn() .mockReturnValue([{ user: Promise.resolve(user) } as jest.Mocked]) @@ -86,6 +89,9 @@ describe('SubscriptionRefundedEventHandler', () => { analyticsStore.markActivity = jest.fn() analyticsStore.wasActivityDone = jest.fn().mockReturnValue(true) + statisticsStore = {} as jest.Mocked + statisticsStore.setMeasure = jest.fn() + logger = {} as jest.Mocked logger.info = jest.fn() logger.warn = jest.fn() diff --git a/packages/auth/src/Domain/Handler/SubscriptionRefundedEventHandler.ts b/packages/auth/src/Domain/Handler/SubscriptionRefundedEventHandler.ts index 74819b9d2..4c93cdb3b 100644 --- a/packages/auth/src/Domain/Handler/SubscriptionRefundedEventHandler.ts +++ b/packages/auth/src/Domain/Handler/SubscriptionRefundedEventHandler.ts @@ -8,7 +8,13 @@ import { RoleServiceInterface } from '../Role/RoleServiceInterface' import { UserRepositoryInterface } from '../User/UserRepositoryInterface' import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscriptionRepositoryInterface' import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface' -import { AnalyticsActivity, AnalyticsStoreInterface, Period } from '@standardnotes/analytics' +import { + AnalyticsActivity, + AnalyticsStoreInterface, + Period, + StatisticsMeasure, + StatisticsStoreInterface, +} from '@standardnotes/analytics' import { GetUserAnalyticsId } from '../UseCase/GetUserAnalyticsId/GetUserAnalyticsId' @injectable() @@ -21,6 +27,7 @@ export class SubscriptionRefundedEventHandler implements DomainEventHandlerInter @inject(TYPES.RoleService) private roleService: RoleServiceInterface, @inject(TYPES.GetUserAnalyticsId) private getUserAnalyticsId: GetUserAnalyticsId, @inject(TYPES.AnalyticsStore) private analyticsStore: AnalyticsStoreInterface, + @inject(TYPES.StatisticsStore) private statisticsStore: StatisticsStoreInterface, @inject(TYPES.Logger) private logger: Logger, ) {} @@ -85,5 +92,13 @@ export class SubscriptionRefundedEventHandler implements DomainEventHandlerInter await this.analyticsStore.markActivity([churnActivity], analyticsId, [period]) } } + + const activeSubscriptions = await this.userSubscriptionRepository.countActiveSubscriptions() + await this.statisticsStore.setMeasure(StatisticsMeasure.TotalCustomers, activeSubscriptions, [ + Period.Today, + Period.ThisWeek, + Period.ThisMonth, + Period.ThisYear, + ]) } } diff --git a/packages/auth/src/Domain/Subscription/UserSubscriptionRepositoryInterface.ts b/packages/auth/src/Domain/Subscription/UserSubscriptionRepositoryInterface.ts index f0996dd0c..5a9e7bbd3 100644 --- a/packages/auth/src/Domain/Subscription/UserSubscriptionRepositoryInterface.ts +++ b/packages/auth/src/Domain/Subscription/UserSubscriptionRepositoryInterface.ts @@ -12,5 +12,6 @@ export interface UserSubscriptionRepositoryInterface { findBySubscriptionId(subscriptionId: number): Promise updateEndsAt(subscriptionId: number, endsAt: number, updatedAt: number): Promise updateCancelled(subscriptionId: number, cancelled: boolean, updatedAt: number): Promise + countActiveSubscriptions(): Promise save(subscription: UserSubscription): Promise } diff --git a/packages/auth/src/Infra/MySQL/MySQLUserSubscriptionRepository.spec.ts b/packages/auth/src/Infra/MySQL/MySQLUserSubscriptionRepository.spec.ts index d85ad24c1..ec2d8415c 100644 --- a/packages/auth/src/Infra/MySQL/MySQLUserSubscriptionRepository.spec.ts +++ b/packages/auth/src/Infra/MySQL/MySQLUserSubscriptionRepository.spec.ts @@ -7,14 +7,16 @@ import { UserSubscription } from '../../Domain/Subscription/UserSubscription' import { MySQLUserSubscriptionRepository } from './MySQLUserSubscriptionRepository' import { UserSubscriptionType } from '../../Domain/Subscription/UserSubscriptionType' +import { TimerInterface } from '@standardnotes/time' describe('MySQLUserSubscriptionRepository', () => { let ormRepository: Repository let selectQueryBuilder: SelectQueryBuilder let updateQueryBuilder: UpdateQueryBuilder let subscription: UserSubscription + let timer: TimerInterface - const createRepository = () => new MySQLUserSubscriptionRepository(ormRepository) + const createRepository = () => new MySQLUserSubscriptionRepository(ormRepository, timer) beforeEach(() => { selectQueryBuilder = {} as jest.Mocked> @@ -28,6 +30,9 @@ describe('MySQLUserSubscriptionRepository', () => { ormRepository = {} as jest.Mocked> ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => selectQueryBuilder) ormRepository.save = jest.fn() + + timer = {} as jest.Mocked + timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(123) }) it('should save', async () => { @@ -58,6 +63,25 @@ describe('MySQLUserSubscriptionRepository', () => { expect(result).toEqual([canceledSubscription, subscription]) }) + it('should count all active subscriptions', async () => { + ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => selectQueryBuilder) + + selectQueryBuilder.select = jest.fn().mockReturnThis() + selectQueryBuilder.distinct = jest.fn().mockReturnThis() + selectQueryBuilder.where = jest.fn().mockReturnThis() + selectQueryBuilder.getCount = jest.fn().mockReturnValue(2) + + const result = await createRepository().countActiveSubscriptions() + + expect(selectQueryBuilder.select).toHaveBeenCalledWith('user_uuid') + expect(selectQueryBuilder.distinct).toHaveBeenCalled() + expect(selectQueryBuilder.where).toHaveBeenCalledWith('ends_at > :timestamp', { + timestamp: 123, + }) + expect(selectQueryBuilder.getCount).toHaveBeenCalled() + expect(result).toEqual(2) + }) + it('should find one longest lasting uncanceled subscription by user uuid if there are canceled ones', async () => { const canceledSubscription = { planName: SubscriptionName.ProPlan, diff --git a/packages/auth/src/Infra/MySQL/MySQLUserSubscriptionRepository.ts b/packages/auth/src/Infra/MySQL/MySQLUserSubscriptionRepository.ts index a5d845970..36e204cd6 100644 --- a/packages/auth/src/Infra/MySQL/MySQLUserSubscriptionRepository.ts +++ b/packages/auth/src/Infra/MySQL/MySQLUserSubscriptionRepository.ts @@ -1,4 +1,5 @@ import { Uuid } from '@standardnotes/common' +import { TimerInterface } from '@standardnotes/time' import { inject, injectable } from 'inversify' import { Repository } from 'typeorm' import TYPES from '../../Bootstrap/Types' @@ -12,8 +13,18 @@ export class MySQLUserSubscriptionRepository implements UserSubscriptionReposito constructor( @inject(TYPES.ORMUserSubscriptionRepository) private ormRepository: Repository, + @inject(TYPES.Timer) private timer: TimerInterface, ) {} + async countActiveSubscriptions(): Promise { + return await this.ormRepository + .createQueryBuilder() + .select('user_uuid') + .distinct() + .where('ends_at > :timestamp', { timestamp: this.timer.getTimestampInMicroseconds() }) + .getCount() + } + async findByUserUuid(userUuid: string): Promise { return await this.ormRepository .createQueryBuilder()