diff --git a/src/lib/time.ts b/src/lib/time.ts index fa8774429..6d781c46f 100644 --- a/src/lib/time.ts +++ b/src/lib/time.ts @@ -18,10 +18,9 @@ export class Time { return new Date(); } - public static dateWithDaysAdded(days: number): Date { - const date = Time.now(); - date.setDate(date.getDate() + days); - return date; + public static dateWithDaysAdded(days: number, initialDate?: Date): Date { + const date = dayjs(initialDate ?? Time.now()).utc(); + return date.add(days, 'day').toDate(); } public static setTime(now: Date): void { diff --git a/src/modules/file/file.module.ts b/src/modules/file/file.module.ts index 4028e3797..40e8f0faa 100644 --- a/src/modules/file/file.module.ts +++ b/src/modules/file/file.module.ts @@ -13,6 +13,7 @@ import { SharingModule } from '../sharing/sharing.module'; import { WorkspacesModule } from '../workspaces/workspaces.module'; import { UserModule } from '../user/user.module'; import { NotificationModule } from '../../externals/notifications/notifications.module'; +import { UsageModule } from '../usage/usage.module'; @Module({ imports: [ @@ -25,6 +26,7 @@ import { NotificationModule } from '../../externals/notifications/notifications. CryptoModule, UserModule, NotificationModule, + UsageModule, ], controllers: [FileController], providers: [SequelizeFileRepository, FileUseCases], diff --git a/src/modules/file/file.repository.ts b/src/modules/file/file.repository.ts index 149138715..6c179c23f 100644 --- a/src/modules/file/file.repository.ts +++ b/src/modules/file/file.repository.ts @@ -125,6 +125,11 @@ export interface FileRepository { order?: [keyof FileModel, 'ASC' | 'DESC'][], ): Promise; deleteUserTrashedFilesBatch(userId: number, limit: number): Promise; + sumFileSizeDeltaBetweenDates( + userId: FileAttributes['userId'], + sinceDate: Date, + untilDate?: Date, + ): Promise; } @Injectable() @@ -893,4 +898,50 @@ export class SequelizeFileRepository implements FileRepository { return Number(result[0]['total']) as unknown as number; } + + async sumFileSizeDeltaBetweenDates( + userId: FileAttributes['userId'], + sinceDate: Date, + untilDate?: Date, + ): Promise { + const timeCondition = { + [Op.gte]: sinceDate, + ...(untilDate ? { [Op.lte]: untilDate } : null), + }; + + const result = await this.fileModel.findAll({ + attributes: [ + [ + Sequelize.literal(` + SUM( + CASE + WHEN status = 'DELETED' AND date_trunc('day', created_at) = date_trunc('day', updated_at) THEN 0 + WHEN status = 'DELETED' THEN -size + ELSE size + END + ) + `), + 'total', + ], + ], + where: { + userId, + [Op.or]: [ + { + status: { + [Op.ne]: 'DELETED', + }, + createdAt: timeCondition, + }, + { + status: 'DELETED', + updatedAt: timeCondition, + }, + ], + }, + raw: true, + }); + + return Number(result[0]['total']) as unknown as number; + } } diff --git a/src/modules/file/file.usecase.spec.ts b/src/modules/file/file.usecase.spec.ts index 6070a7a5e..882aab82e 100644 --- a/src/modules/file/file.usecase.spec.ts +++ b/src/modules/file/file.usecase.spec.ts @@ -23,6 +23,7 @@ import { newFolder, newUser, newWorkspace, + newUsage, } from '../../../test/fixtures'; import { FolderUseCases } from '../folder/folder.usecase'; import { v4 } from 'uuid'; @@ -31,6 +32,8 @@ import { SharingItemType } from '../sharing/sharing.domain'; import { CreateFileDto } from './dto/create-file.dto'; import { UpdateFileMetaDto } from './dto/update-file-meta.dto'; import { ThumbnailUseCases } from '../thumbnail/thumbnail.usecase'; +import { UsageService } from '../usage/usage.service'; +import { Time } from '../../lib/time'; const fileId = '6295c99a241bb000083f1c6a'; const userId = 1; @@ -43,6 +46,7 @@ describe('FileUseCases', () => { let bridgeService: BridgeService; let cryptoService: CryptoService; let thumbnailUseCases: ThumbnailUseCases; + let usageService: UsageService; const userMocked = newUser(); @@ -61,6 +65,7 @@ describe('FileUseCases', () => { cryptoService = module.get(CryptoService); sharingService = module.get(SharingService); thumbnailUseCases = module.get(ThumbnailUseCases); + usageService = module.get(UsageService); }); afterEach(() => { @@ -1443,4 +1448,106 @@ describe('FileUseCases', () => { }); }); }); + + describe('getUserUsedStorageIncrementally', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + it('When user has no existing usage, then it should create first usage calculation', async () => { + const mockUser = newUser(); + const today = new Date('2024-01-02T10:00:00Z'); + const mockFirstUsage = newUsage({ + attributes: { period: new Date('2024-01-01T00:00:00Z') }, + }); + + // Set today to the next period start date according to mockUsage + jest.setSystemTime(today); + + jest + .spyOn(usageService, 'getUserMostRecentUsage') + .mockResolvedValue(null); + jest + .spyOn(usageService, 'createFirstUsageCalculation') + .mockResolvedValue(mockFirstUsage); + + await service.getUserUsedStorageIncrementally(mockUser); + + expect(usageService.getUserMostRecentUsage).toHaveBeenCalledWith( + mockUser.uuid, + ); + expect(usageService.createFirstUsageCalculation).toHaveBeenCalledWith( + mockUser.uuid, + ); + }); + + it('When user has recent usage and is up to date, then it should not create new usage', async () => { + const mockUser = newUser(); + const today = new Date('2024-01-02T00:00:00Z'); + + const mockUsage = newUsage({ + attributes: { period: new Date('2024-01-01T00:00:00Z') }, + }); + + // Set today to the next period start date according to mockUsage + jest.setSystemTime(today); + + jest + .spyOn(usageService, 'getUserMostRecentUsage') + .mockResolvedValue(mockUsage); + + await service.getUserUsedStorageIncrementally(mockUser); + + expect(usageService.getUserMostRecentUsage).toHaveBeenCalledWith( + mockUser.uuid, + ); + expect(usageService.createMonthlyUsage).not.toHaveBeenCalled(); + expect( + fileRepository.sumFileSizeDeltaBetweenDates, + ).not.toHaveBeenCalled(); + }); + + it('When user has recent usage but needs update, then it should calculate gap delta and create monthly usage', async () => { + const mockUser = newUser(); + const today = new Date('2024-01-04T10:00:00Z'); + const yesterday = Time.dateWithDaysAdded(-1, today); + const mockUsage = newUsage({ + attributes: { period: new Date('2024-01-01T00:00:00Z') }, + }); + const mockGapDelta = 500; + + // Set today to a date after the next period start date according to mockUsage + jest.setSystemTime(today); + jest + .spyOn(usageService, 'getUserMostRecentUsage') + .mockResolvedValue(mockUsage); + jest + .spyOn(fileRepository, 'sumFileSizeDeltaBetweenDates') + .mockResolvedValue(mockGapDelta); + jest + .spyOn(usageService, 'createMonthlyUsage') + .mockResolvedValue(undefined); + + await service.getUserUsedStorageIncrementally(mockUser); + + expect(usageService.getUserMostRecentUsage).toHaveBeenCalledWith( + mockUser.uuid, + ); + expect(fileRepository.sumFileSizeDeltaBetweenDates).toHaveBeenCalledWith( + mockUser.id, + mockUsage.getNextPeriodStartDate(), + Time.endOfDay(yesterday), + ); + expect(usageService.createMonthlyUsage).toHaveBeenCalledWith( + mockUser.uuid, + yesterday, + mockGapDelta, + ); + }); + }); }); diff --git a/src/modules/file/file.usecase.ts b/src/modules/file/file.usecase.ts index 7aa1bd629..4f8e804bd 100644 --- a/src/modules/file/file.usecase.ts +++ b/src/modules/file/file.usecase.ts @@ -39,6 +39,8 @@ import { getPathFileData } from '../../lib/path'; import { isStringEmpty } from '../../lib/validators'; import { FileModel } from './file.model'; import { ThumbnailUseCases } from '../thumbnail/thumbnail.usecase'; +import { UsageService } from '../usage/usage.service'; +import { Time } from '../../lib/time'; export type SortParamsFile = Array<[SortableFileAttributes, 'ASC' | 'DESC']>; @@ -53,6 +55,7 @@ export class FileUseCases { private readonly network: BridgeService, private readonly cryptoService: CryptoService, private readonly thumbnailUsecases: ThumbnailUseCases, + private readonly usageService: UsageService, ) {} getByUuid(uuid: FileAttributes['uuid']): Promise { @@ -73,10 +76,44 @@ export class FileUseCases { return this.fileRepository.findByUuids(uuids, { userId: user.id }); } - getUserUsedStorage(user: User) { + async getUserUsedStorage(user: User): Promise { + await this.getUserUsedStorageIncrementally(user); return this.fileRepository.sumExistentFileSizes(user.id); } + async getUserUsedStorageIncrementally(user: User) { + let mostRecentUsage = await this.usageService.getUserMostRecentUsage( + user.uuid, + ); + + if (!mostRecentUsage) { + mostRecentUsage = await this.usageService.createFirstUsageCalculation( + user.uuid, + ); + } + + const nextPeriodStart = mostRecentUsage.getNextPeriodStartDate(); + const isUpToDate = Time.isToday(nextPeriodStart); + + if (!isUpToDate) { + const yesterday = Time.dateWithDaysAdded(-1); + const yesterdayEndOfDay = Time.endOfDay(yesterday); + + const gapDelta = await this.fileRepository.sumFileSizeDeltaBetweenDates( + user.id, + nextPeriodStart, + yesterdayEndOfDay, + ); + await this.usageService.createMonthlyUsage( + user.uuid, + yesterday, + gapDelta, + ); + } + + // TODO: add calculation of the current day and sum of all the usages + } + async deleteFilePermanently( user: User, where: Partial, diff --git a/src/modules/usage/usage.repository.spec.ts b/src/modules/usage/usage.repository.spec.ts new file mode 100644 index 000000000..207f31586 --- /dev/null +++ b/src/modules/usage/usage.repository.spec.ts @@ -0,0 +1,113 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getModelToken } from '@nestjs/sequelize'; +import { v4 } from 'uuid'; +import { createMock } from '@golevelup/ts-jest'; +import { Op } from 'sequelize'; +import { SequelizeUsageRepository } from './usage.repository'; +import { UsageModel } from './usage.model'; +import { newUsage } from '../../../test/fixtures'; +import { UsageType } from './usage.domain'; + +describe('SequelizeUsageRepository', () => { + let repository: SequelizeUsageRepository; + let usageModel: typeof UsageModel; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [SequelizeUsageRepository], + }) + .useMocker(() => createMock()) + .compile(); + + repository = module.get(SequelizeUsageRepository); + usageModel = module.get(getModelToken(UsageModel)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(repository).toBeDefined(); + }); + + describe('create', () => { + it('When called, then should create usage with expected arguments', async () => { + const userId = v4(); + const delta = 1000; + const period = new Date(); + const type = UsageType.Daily; + const usageData = newUsage({ + attributes: { userId, delta, period, type }, + }); + const mockUsageModel = { + toJSON: () => ({ id: v4(), ...usageData }), + }; + + jest.spyOn(usageModel, 'create').mockResolvedValue(mockUsageModel as any); + + await repository.create(usageData); + + expect(usageModel.create).toHaveBeenCalledWith(usageData); + }); + }); + + describe('getMostRecentMonthlyOrYearlyUsage', () => { + it('When called, then should query with expected arguments', async () => { + const userUuid = v4(); + const mockUsageModel = { + toJSON: () => newUsage(), + }; + + jest + .spyOn(usageModel, 'findOne') + .mockResolvedValue(mockUsageModel as any); + + await repository.getMostRecentMonthlyOrYearlyUsage(userUuid); + + expect(usageModel.findOne).toHaveBeenCalledWith({ + where: { + userId: userUuid, + [Op.or]: [{ type: UsageType.Monthly }, { type: UsageType.Yearly }], + }, + order: [['period', 'DESC']], + }); + }); + + it('When no usage found, then should return null', async () => { + const userUuid = v4(); + + jest.spyOn(usageModel, 'findOne').mockResolvedValue(null); + + const result = + await repository.getMostRecentMonthlyOrYearlyUsage(userUuid); + + expect(result).toBeNull(); + }); + }); + + describe('createFirstUsageCalculation', () => { + it('When called, then should execute query with expected arguments', async () => { + const userUuid = v4(); + const mockSequelize = { + query: jest.fn().mockResolvedValue([{ toJSON: () => newUsage() }]), + }; + + Object.defineProperty(usageModel, 'sequelize', { + value: mockSequelize, + }); + + await repository.createFirstUsageCalculation(userUuid); + + expect(mockSequelize.query).toHaveBeenCalledWith( + expect.stringContaining('INSERT INTO public.usages'), + expect.objectContaining({ + replacements: expect.objectContaining({ + userUuid, + }), + model: UsageModel, + }), + ); + }); + }); +}); diff --git a/src/modules/usage/usage.repository.ts b/src/modules/usage/usage.repository.ts index e86d3cf84..faa536b4d 100644 --- a/src/modules/usage/usage.repository.ts +++ b/src/modules/usage/usage.repository.ts @@ -1,7 +1,8 @@ import { InjectModel } from '@nestjs/sequelize'; import { Injectable } from '@nestjs/common'; import { UsageModel } from './usage.model'; -import { Usage } from './usage.domain'; +import { Usage, UsageType } from './usage.domain'; +import { Op } from 'sequelize'; @Injectable() export class SequelizeUsageRepository { @@ -10,6 +11,60 @@ export class SequelizeUsageRepository { private readonly usageModel: typeof UsageModel, ) {} + public async create(usage: Omit) { + const newUsage = await this.usageModel.create(usage); + + return this.toDomain(newUsage); + } + + public async getMostRecentMonthlyOrYearlyUsage( + userUuid: string, + ): Promise { + const mostRecentUsage = await this.usageModel.findOne({ + where: { + userId: userUuid, + [Op.or]: [{ type: UsageType.Monthly }, { type: UsageType.Yearly }], + }, + order: [['period', 'DESC']], + }); + + return mostRecentUsage ? this.toDomain(mostRecentUsage) : null; + } + + public async createFirstUsageCalculation(userUuid: string): Promise { + const query = ` + INSERT INTO public.usages (id, user_id, delta, period, type, created_at, updated_at) + SELECT + uuid_generate_v4(), + u.uuid::uuid AS user_id, + COALESCE(SUM(f.size), 0) AS delta, + (CURRENT_DATE - INTERVAL '1 day')::DATE AS period, + 'monthly' AS type, + NOW() AS created_at, + NOW() AS updated_at + FROM + users u + LEFT JOIN public.files f ON u.id = f.user_id + AND f.status != 'DELETED' + -- Ensure we only consider files created before today + AND f.created_at < CURRENT_DATE + WHERE + u.uuid = :userUuid + GROUP BY + u.uuid + RETURNING *; + `; + + const result = await this.usageModel.sequelize.query(query, { + replacements: { + userUuid, + }, + model: UsageModel, + }); + + return result.length > 0 ? this.toDomain(result[0]) : null; + } + toDomain(model: UsageModel): Usage { return Usage.build({ ...model.toJSON(), diff --git a/src/modules/usage/usage.service.spec.ts b/src/modules/usage/usage.service.spec.ts new file mode 100644 index 000000000..5713d380f --- /dev/null +++ b/src/modules/usage/usage.service.spec.ts @@ -0,0 +1,252 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { v4 } from 'uuid'; +import { createMock } from '@golevelup/ts-jest'; +import { UsageService } from './usage.service'; +import { SequelizeUsageRepository } from './usage.repository'; +import { newUser, newFile, newUsage } from '../../../test/fixtures'; +import { UsageType } from './usage.domain'; + +describe('UsageService', () => { + let service: UsageService; + let usageRepository: SequelizeUsageRepository; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [UsageService], + }) + .useMocker(() => createMock()) + .compile(); + + service = module.get(UsageService); + usageRepository = module.get( + SequelizeUsageRepository, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('addDailyUsageChangeOnFileSizeChange', () => { + const user = newUser(); + const oldFile = newFile({ attributes: { size: BigInt(100) } }); + + it('When no existing usage found, then should return null', async () => { + const newFileData = newFile({ attributes: { size: BigInt(200) } }); + + jest + .spyOn(usageRepository, 'getMostRecentMonthlyOrYearlyUsage') + .mockResolvedValue(null); + + const result = await service.addDailyUsageChangeOnFileSizeChange( + user, + oldFile, + newFileData, + ); + + expect(result).toBeNull(); + expect( + usageRepository.getMostRecentMonthlyOrYearlyUsage, + ).toHaveBeenCalledWith(user.uuid); + }); + + it('When file size delta is zero, then should return null', async () => { + const existingUsage = newUsage(); + const sameFile = newFile({ attributes: { size: BigInt(100) } }); + + jest + .spyOn(usageRepository, 'getMostRecentMonthlyOrYearlyUsage') + .mockResolvedValue(existingUsage); + + const result = await service.addDailyUsageChangeOnFileSizeChange( + user, + oldFile, + sameFile, + ); + + expect(result).toBeNull(); + }); + + it('When file is created today, then should return null', async () => { + const existingUsage = newUsage(); + const todayFile = newFile({ + attributes: { + size: BigInt(200), + createdAt: new Date(), + }, + }); + + jest + .spyOn(usageRepository, 'getMostRecentMonthlyOrYearlyUsage') + .mockResolvedValue(existingUsage); + + const result = await service.addDailyUsageChangeOnFileSizeChange( + user, + oldFile, + todayFile, + ); + + expect(result).toBeNull(); + }); + + it('When file size increased and not created today, then should create daily usage with positive delta', async () => { + const existingUsage = newUsage(); + const yesterdayFile = newFile({ + attributes: { + size: BigInt(200), + createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000), + }, + }); + const expectedUsage = newUsage({ + attributes: { + userId: user.uuid, + delta: 100, + type: UsageType.Daily, + }, + }); + + jest + .spyOn(usageRepository, 'getMostRecentMonthlyOrYearlyUsage') + .mockResolvedValue(existingUsage); + jest.spyOn(usageRepository, 'create').mockResolvedValue(expectedUsage); + + const result = await service.addDailyUsageChangeOnFileSizeChange( + user, + oldFile, + yesterdayFile, + ); + + expect(result).toEqual(expectedUsage); + expect(usageRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + userId: user.uuid, + delta: 100, + type: UsageType.Daily, + }), + ); + }); + + it('When file size decreased and not created today, then should create daily usage with negative delta', async () => { + const existingUsage = newUsage(); + const yesterdayFile = newFile({ + attributes: { + size: BigInt(50), + createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000), + }, + }); + const expectedUsage = newUsage({ + attributes: { + userId: user.uuid, + delta: -50, + type: UsageType.Daily, + }, + }); + + jest + .spyOn(usageRepository, 'getMostRecentMonthlyOrYearlyUsage') + .mockResolvedValue(existingUsage); + jest.spyOn(usageRepository, 'create').mockResolvedValue(expectedUsage); + + const result = await service.addDailyUsageChangeOnFileSizeChange( + user, + oldFile, + yesterdayFile, + ); + + expect(result).toEqual(expectedUsage); + expect(usageRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + userId: user.uuid, + delta: -50, + type: UsageType.Daily, + }), + ); + }); + }); + + describe('getUserMostRecentUsage', () => { + it('When called, then it should return the most recent usage', async () => { + const user = newUser(); + const usage = newUsage(); + + jest + .spyOn(usageRepository, 'getMostRecentMonthlyOrYearlyUsage') + .mockResolvedValue(usage); + + const result = await service.getUserMostRecentUsage(user.uuid); + + expect(result).toEqual(usage); + expect( + usageRepository.getMostRecentMonthlyOrYearlyUsage, + ).toHaveBeenCalledWith(user.uuid); + }); + }); + + describe('createFirstUsageCalculation', () => { + it('When called, then it should call the repository with expected arguments and return the first created usage', async () => { + const user = newUser(); + const usage = newUsage(); + + jest + .spyOn(usageRepository, 'createFirstUsageCalculation') + .mockResolvedValue(usage); + + const result = await service.createFirstUsageCalculation(user.uuid); + + expect(result).toEqual(usage); + expect(usageRepository.createFirstUsageCalculation).toHaveBeenCalledWith( + user.uuid, + ); + }); + }); + + describe('createMonthlyUsage', () => { + it('When called, then it should create the monthly usage with expected arguments', async () => { + const userId = v4(); + const period = new Date(); + const delta = 1000; + const usage = newUsage({ attributes: { type: UsageType.Monthly } }); + + jest.spyOn(usageRepository, 'create').mockResolvedValue(usage); + + const result = await service.createMonthlyUsage(userId, period, delta); + + expect(result).toEqual(usage); + expect(usageRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + userId, + period, + delta, + type: UsageType.Monthly, + }), + ); + }); + }); + + describe('createDailyUsage', () => { + it('When called, then it should create daily usage with expected arguments', async () => { + const userId = 'user-id'; + const period = new Date(); + const delta = 500; + const usage = newUsage({ attributes: { type: UsageType.Daily } }); + + jest.spyOn(usageRepository, 'create').mockResolvedValue(usage); + + const result = await service.createDailyUsage(userId, period, delta); + + expect(result).toEqual(usage); + expect(usageRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + userId, + period, + delta, + type: UsageType.Daily, + }), + ); + }); + }); +}); diff --git a/src/modules/usage/usage.service.ts b/src/modules/usage/usage.service.ts index 07ba6faee..52e4932ca 100644 --- a/src/modules/usage/usage.service.ts +++ b/src/modules/usage/usage.service.ts @@ -1,7 +1,78 @@ import { Injectable } from '@nestjs/common'; import { SequelizeUsageRepository } from './usage.repository'; +import { File } from '../file/file.domain'; +import { User } from '../user/user.domain'; +import { Usage, UsageType } from './usage.domain'; +import { v4 } from 'uuid'; +import { Time } from '../../lib/time'; @Injectable() export class UsageService { constructor(private readonly usageRepository: SequelizeUsageRepository) {} + + async getUserMostRecentUsage(userId: User['uuid']): Promise { + const mostRecentUsage = + await this.usageRepository.getMostRecentMonthlyOrYearlyUsage(userId); + return mostRecentUsage; + } + + async createFirstUsageCalculation(userUuid: User['uuid']) { + return this.usageRepository.createFirstUsageCalculation(userUuid); + } + + async createMonthlyUsage(userId: User['uuid'], period: Date, delta: number) { + const monthlyUsage = Usage.build({ + id: v4(), + userId: userId, + period, + delta, + type: UsageType.Monthly, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const createMonthlyUsage = await this.usageRepository.create(monthlyUsage); + + return createMonthlyUsage; + } + + async createDailyUsage(userUuid: User['uuid'], period: Date, delta: number) { + const dailyUsage = Usage.build({ + id: v4(), + userId: userUuid, + period, + delta, + type: UsageType.Daily, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const createdDailyUsage = await this.usageRepository.create(dailyUsage); + + return createdDailyUsage; + } + + async addDailyUsageChangeOnFileSizeChange( + user: User, + oldFileData: File, + newFileData: File, + ): Promise { + const delta = Number(newFileData.size) - Number(oldFileData.size); + + // Files created the same day do not need a daily usage entry, they will be included in the next monthly usage + const isFileCreatedToday = Time.isToday(newFileData.createdAt); + + if (delta === 0 || isFileCreatedToday) { + return null; + } + + const doesUserHasAnyUsageCalculation = + await this.usageRepository.getMostRecentMonthlyOrYearlyUsage(user.uuid); + + if (!doesUserHasAnyUsageCalculation) { + return null; + } + + return this.createDailyUsage(user.uuid, new Date(), delta); + } }