diff --git a/src/app.module.ts b/src/app.module.ts index aff3c273c..aac0fab6c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -41,6 +41,7 @@ import { TeamsModule } from './modules/teams/teams.module'; import { FlutterwaveModule } from './modules/flutterwave/flutterwave.module'; import { BlogModule } from './modules/blogs/blogs.module'; import { SubscriptionsModule } from './modules/subscriptions/subscriptions.module'; +import { RevenueModule } from './modules/revenue/revenue.module'; @Module({ providers: [ @@ -152,6 +153,7 @@ import { SubscriptionsModule } from './modules/subscriptions/subscriptions.modul FlutterwaveModule, BlogModule, SubscriptionsModule, + RevenueModule, ], controllers: [HealthController, ProbeController], }) diff --git a/src/database/seeding/seeding.controller.ts b/src/database/seeding/seeding.controller.ts index a89977e72..ea1164805 100644 --- a/src/database/seeding/seeding.controller.ts +++ b/src/database/seeding/seeding.controller.ts @@ -1,10 +1,11 @@ import { Body, Controller, Get, Post } from '@nestjs/common'; -import { SeedingService } from './seeding.service'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { skipAuth } from '../../helpers/skipAuth'; import { CreateAdminDto } from './dto/admin.dto'; -import { User } from '../../modules/user/entities/user.entity'; import { CreateAdminResponseDto } from './dto/create-admin-response.dto'; +import { SeedingService } from './seeding.service'; +@ApiTags('Seed') @skipAuth() @Controller('seed') export class SeedingController { @@ -25,4 +26,10 @@ export class SeedingController { async seedSuperAdmin(@Body() adminDetails: CreateAdminDto): Promise { return this.seedingService.createSuperAdmin(adminDetails); } + + @Post('transactions') + @ApiOperation({ summary: 'Seed transactions' }) + async seedTransactions() { + return this.seedingService.seedTransactions(); + } } diff --git a/src/database/seeding/seeding.service.ts b/src/database/seeding/seeding.service.ts index 5858f6134..0db595144 100644 --- a/src/database/seeding/seeding.service.ts +++ b/src/database/seeding/seeding.service.ts @@ -7,21 +7,25 @@ import { UnauthorizedException, } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import { User, UserType } from '../../modules/user/entities/user.entity'; -import { Organisation } from '../../modules/organisations/entities/organisations.entity'; +import { v4 as uuidv4 } from 'uuid'; +import { ADMIN_CREATED, INVALID_ADMIN_SECRET, SERVER_ERROR } from '../../helpers/SystemMessages'; import { Invite } from '../../modules/invite/entities/invite.entity'; -import { Product, ProductSizeType } from '../../modules/products/entities/product.entity'; -import { ProductCategory } from '../../modules/product-category/entities/product-category.entity'; +import { Notification } from '../../modules/notifications/entities/notifications.entity'; import { DefaultPermissions } from '../../modules/organisation-permissions/entities/default-permissions.entity'; import { PermissionCategory } from '../../modules/organisation-permissions/helpers/PermissionCategory'; +import { DefaultRole } from '../../modules/organisation-role/entities/role.entity'; +import { RoleCategory, RoleCategoryDescriptions } from '../../modules/organisation-role/helpers/RoleCategory'; +import { Organisation } from '../../modules/organisations/entities/organisations.entity'; +import { ProductCategory } from '../../modules/product-category/entities/product-category.entity'; +import { Product, ProductSizeType } from '../../modules/products/entities/product.entity'; import { Profile } from '../../modules/profile/entities/profile.entity'; -import { Notification } from '../../modules/notifications/entities/notifications.entity'; -import { v4 as uuidv4 } from 'uuid'; +import { Cart } from '../../modules/revenue/entities/cart.entity'; +import { OrderItem } from '../../modules/revenue/entities/order-items.entity'; +import { Order } from '../../modules/revenue/entities/order.entity'; +import { Transaction } from '../../modules/revenue/entities/transaction.entity'; +import { User, UserType } from '../../modules/user/entities/user.entity'; import { CreateAdminDto } from './dto/admin.dto'; -import { ADMIN_CREATED, INVALID_ADMIN_SECRET, SERVER_ERROR } from '../../helpers/SystemMessages'; import { CreateAdminResponseDto } from './dto/create-admin-response.dto'; -import { RoleCategory, RoleCategoryDescriptions } from '../../modules/organisation-role/helpers/RoleCategory'; -import { DefaultRole } from '../../modules/organisation-role/entities/role.entity'; @Injectable() export class SeedingService { @@ -37,6 +41,7 @@ export class SeedingService { const defaultPermissionRepository = this.dataSource.getRepository(DefaultPermissions); const notificationRepository = this.dataSource.getRepository(Notification); const defaultRoleRepository = this.dataSource.getRepository(DefaultRole); + try { const existingPermissions = await defaultPermissionRepository.count(); const existingRoles = await defaultRoleRepository.count(); @@ -93,6 +98,7 @@ export class SeedingService { await userRepository.save([u1, u2]); const savedUsers = await userRepository.find(); + if (savedUsers.length !== 2) { throw new Error('Failed to create all users'); } @@ -299,4 +305,148 @@ export class SeedingService { throw new InternalServerErrorException(SERVER_ERROR); } } + + async seedTransactions() { + const cartRepository = this.dataSource.getRepository(Cart); + const orderRepository = this.dataSource.getRepository(Order); + const orderItemRepository = this.dataSource.getRepository(OrderItem); + const transactionRepository = this.dataSource.getRepository(Transaction); + const userRepository = this.dataSource.getRepository(User); + const productRepository = this.dataSource.getRepository(Product); + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + const savedUsers = await userRepository.find(); + const savedProducts = await productRepository.find(); + + const orders = [ + orderRepository.create({ + user: savedUsers[0], + total_price: 1000, + }), + orderRepository.create({ + user: savedUsers[1], + total_price: 1500, + }), + orderRepository.create({ + user: savedUsers[0], + total_price: 750, + }), + orderRepository.create({ + user: savedUsers[1], + total_price: 1250, + }), + orderRepository.create({ + user: savedUsers[0], + total_price: 2000, + }), + ]; + + await orderRepository.save(orders); + + const orderItems = [ + orderItemRepository.create({ + order: orders[0], + product: savedProducts[0], + quantity: 2, + total_price: 500, + }), + orderItemRepository.create({ + order: orders[1], + product: savedProducts[1], + quantity: 3, + total_price: 1500, + }), + orderItemRepository.create({ + order: orders[2], + product: savedProducts[2], + quantity: 1, + total_price: 750, + }), + orderItemRepository.create({ + order: orders[3], + product: savedProducts[0], + quantity: 5, + total_price: 1250, + }), + orderItemRepository.create({ + order: orders[4], + product: savedProducts[1], + quantity: 4, + total_price: 2000, + }), + ]; + + await orderItemRepository.save(orderItems); + + const carts = [ + cartRepository.create({ + user: savedUsers[0], + product: savedProducts[0], + quantity: 1, + }), + cartRepository.create({ + user: savedUsers[1], + product: savedProducts[1], + quantity: 2, + }), + cartRepository.create({ + user: savedUsers[0], + product: savedProducts[2], + quantity: 1, + }), + cartRepository.create({ + user: savedUsers[1], + product: savedProducts[0], + quantity: 3, + }), + cartRepository.create({ + user: savedUsers[0], + product: savedProducts[1], + quantity: 2, + }), + ]; + + await cartRepository.save(carts); + + const currentMonth = new Date().getMonth(); + const currentYear = new Date().getFullYear(); + + const currentMonthDate = (day: number) => new Date(currentYear, currentMonth, day); + const previousMonthDate = (day: number) => new Date(currentYear, currentMonth - 1, day); + + const transactions = [ + transactionRepository.create({ + order: orders[0], + amount: 1000, + date: currentMonthDate(1), + }), + transactionRepository.create({ + order: orders[1], + amount: 1500, + date: currentMonthDate(10), + }), + transactionRepository.create({ + order: orders[2], + amount: 750, + date: currentMonthDate(20), + }), + transactionRepository.create({ + order: orders[3], + amount: 1250, + date: previousMonthDate(1), + }), + transactionRepository.create({ + order: orders[4], + amount: 2000, + date: previousMonthDate(15), + }), + ]; + + await transactionRepository.save(transactions); + + await queryRunner.commitTransaction(); + } } diff --git a/src/helpers/SystemMessages.ts b/src/helpers/SystemMessages.ts index c3bc7ed44..af84053d7 100644 --- a/src/helpers/SystemMessages.ts +++ b/src/helpers/SystemMessages.ts @@ -65,4 +65,5 @@ export const INVITE_NOT_FOUND = 'Invite link not found'; export const BLOG_DELETED = 'Blog post has been successfully deleted'; export const NO_USER_TESTIMONIALS = 'User has no testimonials'; export const USER_TESTIMONIALS_FETCHED = 'User testimonials retrieved successfully'; +export const REVENUE_FETCHED_SUCCESSFULLY = 'Revenue Fetched'; export const QUESTION_ALREADY_EXISTS = 'This question already exists.'; diff --git a/src/modules/invite/mocks/mockUser.ts b/src/modules/invite/mocks/mockUser.ts index b6a452713..f97cfb64e 100644 --- a/src/modules/invite/mocks/mockUser.ts +++ b/src/modules/invite/mocks/mockUser.ts @@ -27,4 +27,5 @@ export const mockUser: User = { notification_settings: [], notifications: [], blogs: [], + cart: [], }; diff --git a/src/modules/notifications/tests/mocks/notification-repo.mock.ts b/src/modules/notifications/tests/mocks/notification-repo.mock.ts index 2bbb027b7..0475eaa30 100644 --- a/src/modules/notifications/tests/mocks/notification-repo.mock.ts +++ b/src/modules/notifications/tests/mocks/notification-repo.mock.ts @@ -58,4 +58,5 @@ export const mockUser: User = { phone: '1234-887-09', jobs: [], blogs: [], + cart: [], }; diff --git a/src/modules/organisations/tests/mocks/organisation.mock.ts b/src/modules/organisations/tests/mocks/organisation.mock.ts index 1063af56d..782c269c8 100644 --- a/src/modules/organisations/tests/mocks/organisation.mock.ts +++ b/src/modules/organisations/tests/mocks/organisation.mock.ts @@ -1,9 +1,8 @@ import { v4 as uuidv4 } from 'uuid'; -import { Organisation } from '../../entities/organisations.entity'; -import { User } from '../../../user/entities/user.entity'; +import { OrganisationRole } from '../../../organisation-role/entities/organisation-role.entity'; import { Profile } from '../../../profile/entities/profile.entity'; import { OrganisationMember } from '../../entities/org-members.entity'; -import { OrganisationRole } from '../../../organisation-role/entities/organisation-role.entity'; +import { Organisation } from '../../entities/organisations.entity'; import { mockUser } from './user.mock'; export enum UserType { @@ -85,6 +84,7 @@ export const createMockOrganisation = (): Organisation => { profile: profileMock, organisationMembers: [orgMemberMock], blogs: [], + cart: [], }; return { diff --git a/src/modules/organisations/tests/mocks/user.mock.ts b/src/modules/organisations/tests/mocks/user.mock.ts index b2dbeb81f..3ceefac69 100644 --- a/src/modules/organisations/tests/mocks/user.mock.ts +++ b/src/modules/organisations/tests/mocks/user.mock.ts @@ -1,6 +1,6 @@ import { orgMemberMock } from './organisation-member.mock'; -import { profileMock } from './profile.mock'; import { UserType } from './organisation.mock'; +import { profileMock } from './profile.mock'; export const mockUser = { id: 'user123', @@ -33,4 +33,5 @@ export const mockUser = { profile: profileMock, organisationMembers: [orgMemberMock], blogs: null, + cart: [], }; diff --git a/src/modules/products/entities/product.entity.ts b/src/modules/products/entities/product.entity.ts index fbbeb05a2..456409634 100644 --- a/src/modules/products/entities/product.entity.ts +++ b/src/modules/products/entities/product.entity.ts @@ -1,7 +1,9 @@ -import { AbstractBaseEntity } from '../../../entities/base.entity'; import { Column, Entity, ManyToOne, OneToMany } from 'typeorm'; -import { Organisation } from '../../../modules/organisations/entities/organisations.entity'; +import { AbstractBaseEntity } from '../../../entities/base.entity'; import { Comment } from '../../../modules/comments/entities/comments.entity'; +import { Organisation } from '../../../modules/organisations/entities/organisations.entity'; +import { Cart } from '../../revenue/entities/cart.entity'; +import { OrderItem } from '../../revenue/entities/order-items.entity'; export enum StockStatusType { IN_STOCK = 'in stock', @@ -32,6 +34,9 @@ export class Product extends AbstractBaseEntity { @Column({ type: 'float', nullable: false, default: 0 }) price: number; + @Column({ type: 'float', nullable: false, default: 0 }) + cost_price: number; + @Column({ type: 'int', nullable: false, default: 0 }) quantity: number; @@ -57,4 +62,10 @@ export class Product extends AbstractBaseEntity { @OneToMany(() => Comment, comment => comment.product) comments?: Comment[]; + + @OneToMany(() => OrderItem, orderItem => orderItem.product) + orderItems: OrderItem[]; + + @OneToMany(() => Cart, cart => cart.product) + cart: Cart[]; } diff --git a/src/modules/products/products.module.ts b/src/modules/products/products.module.ts index 9c7554b71..32c3073e3 100644 --- a/src/modules/products/products.module.ts +++ b/src/modules/products/products.module.ts @@ -1,16 +1,22 @@ import { Module } from '@nestjs/common'; -import { ProductsService } from './products.service'; -import { ProductsController } from './products.controller'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { Product } from './entities/product.entity'; -import { Organisation } from '../organisations/entities/organisations.entity'; -import { ProductVariant } from './entities/product-variant.entity'; import { Comment } from '../comments/entities/comments.entity'; +import { Organisation } from '../organisations/entities/organisations.entity'; +import { Cart } from '../revenue/entities/cart.entity'; +import { OrderItem } from '../revenue/entities/order-items.entity'; +import { Order } from '../revenue/entities/order.entity'; import { User } from '../user/entities/user.entity'; import { UserModule } from '../user/user.module'; +import { ProductVariant } from './entities/product-variant.entity'; +import { Product } from './entities/product.entity'; +import { ProductsController } from './products.controller'; +import { ProductsService } from './products.service'; @Module({ - imports: [TypeOrmModule.forFeature([Product, Organisation, ProductVariant, Comment, User]), UserModule], + imports: [ + TypeOrmModule.forFeature([Product, Organisation, ProductVariant, Comment, User, Order, OrderItem, Cart]), + UserModule, + ], controllers: [ProductsController], providers: [ProductsService], }) diff --git a/src/modules/products/products.service.ts b/src/modules/products/products.service.ts index e1771ac72..7a155d826 100644 --- a/src/modules/products/products.service.ts +++ b/src/modules/products/products.service.ts @@ -1,24 +1,23 @@ import { - BadRequestException, ForbiddenException, HttpStatus, Injectable, InternalServerErrorException, - NotFoundException, Logger, + NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { endOfMonth, startOfMonth, subMonths } from 'date-fns'; import { Repository } from 'typeorm'; -import { Product, StockStatusType } from './entities/product.entity'; -import { Organisation } from '../organisations/entities/organisations.entity'; -import { CreateProductRequestDto } from './dto/create-product.dto'; -import { UpdateProductDTO } from './dto/update-product.dto'; +import { CustomHttpException } from '../../helpers/custom-http-filter'; +import * as systemMessages from '../../helpers/SystemMessages'; import { AddCommentDto } from '../comments/dto/add-comment.dto'; import { Comment } from '../comments/entities/comments.entity'; +import { Organisation } from '../organisations/entities/organisations.entity'; import { User } from '../user/entities/user.entity'; -import { CustomHttpException } from '../../helpers/custom-http-filter'; -import { endOfMonth, startOfMonth, subMonths } from 'date-fns'; -import * as systemMessages from '../../helpers/SystemMessages'; +import { CreateProductRequestDto } from './dto/create-product.dto'; +import { UpdateProductDTO } from './dto/update-product.dto'; +import { Product, StockStatusType } from './entities/product.entity'; interface SearchCriteria { name?: string; @@ -49,6 +48,7 @@ export class ProductsService { newProduct.org = org; const statusCal = await this.calculateProductStatus(dto.quantity); newProduct.stock_status = statusCal; + newProduct.cost_price = 0.2 * dto.price - dto.price; const product = await this.productRepository.save(newProduct); if (!product || !newProduct) throw new InternalServerErrorException({ @@ -144,6 +144,7 @@ export class ProductsService { try { await this.productRepository.update(productId, { ...updateProductDto, + cost_price: 0.2 * updateProductDto.price - updateProductDto.price, stock_status: await this.calculateProductStatus(updateProductDto.quantity), }); diff --git a/src/modules/products/tests/mocks/deleted-poruct.mock.ts b/src/modules/products/tests/mocks/deleted-product.mock.ts similarity index 80% rename from src/modules/products/tests/mocks/deleted-poruct.mock.ts rename to src/modules/products/tests/mocks/deleted-product.mock.ts index 09acc4934..8e13d9986 100644 --- a/src/modules/products/tests/mocks/deleted-poruct.mock.ts +++ b/src/modules/products/tests/mocks/deleted-product.mock.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'crypto'; -import { orgMock } from '../../../../modules/organisations/tests/mocks/organisation.mock'; +import { orgMock } from '../../../organisations/tests/mocks/organisation.mock'; import { Product, StockStatusType } from '../../entities/product.entity'; enum ProductSizeType { @@ -18,6 +18,9 @@ export const deletedProductMock: Product = { price: 12, category: 'Fashion', quantity: 7, + cost_price: 10, + orderItems: [], + cart: [], size: ProductSizeType.SMALL, org: orgMock, created_at: new Date(), diff --git a/src/modules/products/tests/mocks/product.mock.ts b/src/modules/products/tests/mocks/product.mock.ts index 91f99ab1d..a9070856d 100644 --- a/src/modules/products/tests/mocks/product.mock.ts +++ b/src/modules/products/tests/mocks/product.mock.ts @@ -22,4 +22,7 @@ export const productMock: Product = { org: orgMock, created_at: new Date(), updated_at: new Date(), + cost_price: 10, + cart: [], + orderItems: [], }; diff --git a/src/modules/products/tests/products.service.spec.ts b/src/modules/products/tests/products.service.spec.ts index 34e700b8c..5a867b961 100644 --- a/src/modules/products/tests/products.service.spec.ts +++ b/src/modules/products/tests/products.service.spec.ts @@ -1,22 +1,22 @@ +import { InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { ProductsService } from '../products.service'; -import { Product, StockStatusType } from '../entities/product.entity'; +import { CustomHttpException } from '../../../helpers/custom-http-filter'; +import { Comment } from '../../../modules/comments/entities/comments.entity'; import { Organisation } from '../../../modules/organisations/entities/organisations.entity'; -import { ProductVariant } from '../entities/product-variant.entity'; -import { NotFoundException, InternalServerErrorException, HttpStatus } from '@nestjs/common'; import { orgMock } from '../../../modules/organisations/tests/mocks/organisation.mock'; -import { createProductRequestDtoMock } from './mocks/product-request-dto.mock'; -import { productMock } from './mocks/product.mock'; -import { UpdateProductDTO } from '../dto/update-product.dto'; -import { deletedProductMock } from './mocks/deleted-poruct.mock'; import { User } from '../../../modules/user/entities/user.entity'; import { mockUser } from '../../../modules/user/tests/mocks/user.mock'; -import { Comment } from '../../../modules/comments/entities/comments.entity'; -import { mockComment } from './mocks/comment.mock'; import { AddCommentDto } from '../../comments/dto/add-comment.dto'; -import { CustomHttpException } from '../../../helpers/custom-http-filter'; +import { UpdateProductDTO } from '../dto/update-product.dto'; +import { ProductVariant } from '../entities/product-variant.entity'; +import { Product } from '../entities/product.entity'; +import { ProductsService } from '../products.service'; +import { mockComment } from './mocks/comment.mock'; +import { deletedProductMock } from './mocks/deleted-product.mock'; +import { createProductRequestDtoMock } from './mocks/product-request-dto.mock'; +import { productMock } from './mocks/product.mock'; describe('ProductsService', () => { let service: ProductsService; diff --git a/src/modules/revenue/dto/get-revenue-response.dto.ts b/src/modules/revenue/dto/get-revenue-response.dto.ts new file mode 100644 index 000000000..cf6e636d2 --- /dev/null +++ b/src/modules/revenue/dto/get-revenue-response.dto.ts @@ -0,0 +1,9 @@ +export class GetRevenueResponseDto { + message: string; + data: IResponseData; +} + +interface IResponseData { + totalRevenueCurrentMonth: number; + revenuePercentChange: string; +} diff --git a/src/modules/revenue/entities/cart.entity.ts b/src/modules/revenue/entities/cart.entity.ts new file mode 100644 index 000000000..cd06a2edf --- /dev/null +++ b/src/modules/revenue/entities/cart.entity.ts @@ -0,0 +1,15 @@ +import { Column, Entity, ManyToOne } from 'typeorm'; +import { AbstractBaseEntity } from '../../../entities/base.entity'; +import { Product } from '../../products/entities/product.entity'; +import { User } from '../../user/entities/user.entity'; +@Entity() +export class Cart extends AbstractBaseEntity { + @Column({ type: 'int', nullable: false, default: 0 }) + quantity: number; + + @ManyToOne(() => Product, product => product.cart) + product: Product; + + @ManyToOne(() => User, user => user.cart) + user: User; +} diff --git a/src/modules/revenue/entities/order-items.entity.ts b/src/modules/revenue/entities/order-items.entity.ts new file mode 100644 index 000000000..5ad949fd5 --- /dev/null +++ b/src/modules/revenue/entities/order-items.entity.ts @@ -0,0 +1,19 @@ +import { Column, Entity, ManyToOne } from 'typeorm'; +import { AbstractBaseEntity } from '../../../entities/base.entity'; +import { Product } from '../../products/entities/product.entity'; +import { Order } from './order.entity'; + +@Entity() +export class OrderItem extends AbstractBaseEntity { + @ManyToOne(() => Order, order => order.orderItems) + order: Order; + + @ManyToOne(() => Product, product => product.orderItems) + product: Product; + + @Column({ type: 'int', nullable: false, default: 0 }) + quantity: number; + + @Column({ type: 'float', nullable: false, default: 0 }) + total_price: number; +} diff --git a/src/modules/revenue/entities/order.entity.ts b/src/modules/revenue/entities/order.entity.ts new file mode 100644 index 000000000..592eef2e8 --- /dev/null +++ b/src/modules/revenue/entities/order.entity.ts @@ -0,0 +1,20 @@ +import { Column, Entity, ManyToOne, OneToMany } from 'typeorm'; +import { AbstractBaseEntity } from '../../../entities/base.entity'; +import { User } from '../../user/entities/user.entity'; +import { OrderItem } from './order-items.entity'; +import { Transaction } from './transaction.entity'; + +@Entity() +export class Order extends AbstractBaseEntity { + @Column({ type: 'float', nullable: false, default: 0 }) + total_price: number; + + @ManyToOne(() => User, user => user.orders) + user: User; + + @OneToMany(() => OrderItem, orderItem => orderItem.order) + orderItems: OrderItem[]; + + @OneToMany(() => Transaction, transaction => transaction.order) + transactions: Transaction[]; +} diff --git a/src/modules/revenue/entities/transaction.entity.ts b/src/modules/revenue/entities/transaction.entity.ts new file mode 100644 index 000000000..3d590d75f --- /dev/null +++ b/src/modules/revenue/entities/transaction.entity.ts @@ -0,0 +1,15 @@ +import { Column, Entity, ManyToOne } from 'typeorm'; +import { AbstractBaseEntity } from '../../../entities/base.entity'; +import { Order } from './order.entity'; + +@Entity() +export class Transaction extends AbstractBaseEntity { + @Column({ type: 'float', nullable: false, default: 0 }) + amount: number; + + @Column({ type: 'text', nullable: true }) + date: Date; + + @ManyToOne(() => Order, order => order.transactions) + order: Order; +} diff --git a/src/modules/revenue/revenue.controller.ts b/src/modules/revenue/revenue.controller.ts new file mode 100644 index 000000000..11c6cd32d --- /dev/null +++ b/src/modules/revenue/revenue.controller.ts @@ -0,0 +1,38 @@ +import { Controller, Get } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiInternalServerErrorResponse, + ApiOkResponse, + ApiTags, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; +import { GetRevenueResponseDto } from './dto/get-revenue-response.dto'; +import { RevenueService } from './revenue.service'; + +@ApiTags('Dashboard') +@Controller('revenue') +@ApiBearerAuth() +export class RevenueController { + constructor(private readonly revenueService: RevenueService) {} + + @Get() + @ApiOkResponse({ + description: 'Revenue Fetched', + schema: { + properties: { + message: { type: 'string' }, + data: { + properties: { + totalRevenueCurrentMonth: { type: 'number' }, + revenuePercentChange: { type: 'string' }, + }, + }, + }, + }, + }) + @ApiInternalServerErrorResponse({ description: 'Internal Server Error' }) + @ApiUnauthorizedResponse({ description: 'Unauthorized' }) + getRevenue(): Promise { + return this.revenueService.getRevenue(); + } +} diff --git a/src/modules/revenue/revenue.module.ts b/src/modules/revenue/revenue.module.ts new file mode 100644 index 000000000..5fa70dfb2 --- /dev/null +++ b/src/modules/revenue/revenue.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Cart } from './entities/cart.entity'; +import { OrderItem } from './entities/order-items.entity'; +import { Order } from './entities/order.entity'; +import { Transaction } from './entities/transaction.entity'; +import { RevenueController } from './revenue.controller'; +import { RevenueService } from './revenue.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Transaction, Order, OrderItem, Cart])], + controllers: [RevenueController], + providers: [RevenueService], +}) +export class RevenueModule {} diff --git a/src/modules/revenue/revenue.service.spec.ts b/src/modules/revenue/revenue.service.spec.ts new file mode 100644 index 000000000..17fe19b88 --- /dev/null +++ b/src/modules/revenue/revenue.service.spec.ts @@ -0,0 +1,72 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { GetRevenueResponseDto } from './dto/get-revenue-response.dto'; +import { Transaction } from './entities/transaction.entity'; +import { RevenueService } from './revenue.service'; + +describe('RevenueService', () => { + let service: RevenueService; + let repository: Repository; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RevenueService, + { + provide: getRepositoryToken(Transaction), + useClass: Repository, + }, + ], + }).compile(); + + service = module.get(RevenueService); + repository = module.get>(getRepositoryToken(Transaction)); + }); + + it('should return revenue data', async () => { + const currentMonthRevenue = { revenue: 1000 }; + const previousMonthRevenue = { revenue: 800 }; + + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValueOnce(currentMonthRevenue).mockResolvedValueOnce(previousMonthRevenue), + }; + + jest.spyOn(repository, 'createQueryBuilder').mockReturnValue(mockQueryBuilder as any); + + const result: GetRevenueResponseDto = await service.getRevenue(); + + expect(result).toEqual({ + message: 'Revenue Fetched', + data: { + totalRevenueCurrentMonth: currentMonthRevenue.revenue, + revenuePercentChange: '25.00%', + }, + }); + }); + + it('should handle zero previous month revenue', async () => { + const currentMonthRevenue = { revenue: 1000 }; + const previousMonthRevenue = { revenue: 0 }; + + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValueOnce(currentMonthRevenue).mockResolvedValueOnce(previousMonthRevenue), + }; + + jest.spyOn(repository, 'createQueryBuilder').mockReturnValue(mockQueryBuilder as any); + + const result: GetRevenueResponseDto = await service.getRevenue(); + + expect(result).toEqual({ + message: 'Revenue Fetched', + data: { + totalRevenueCurrentMonth: currentMonthRevenue.revenue, + revenuePercentChange: '100.00%', + }, + }); + }); +}); diff --git a/src/modules/revenue/revenue.service.ts b/src/modules/revenue/revenue.service.ts new file mode 100644 index 000000000..0478c34b5 --- /dev/null +++ b/src/modules/revenue/revenue.service.ts @@ -0,0 +1,61 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import * as SYS_MSG from '../../helpers/SystemMessages'; +import { GetRevenueResponseDto } from './dto/get-revenue-response.dto'; +import { Transaction } from './entities/transaction.entity'; + +@Injectable() +export class RevenueService { + constructor( + @InjectRepository(Transaction) + private readonly transactionRepository: Repository + ) {} + + async getRevenue(): Promise { + const currentMonth = new Date(); + const previousMonth = new Date(); + + previousMonth.setMonth(previousMonth.getMonth() - 1); + + const currentMonthRevenue = await this.transactionRepository + .createQueryBuilder('transaction') + .select('SUM(transaction.amount)', 'revenue') + .where( + 'EXTRACT(MONTH FROM transaction.date::timestamp)= :month AND EXTRACT(YEAR FROM transaction.date::timestamp)= :year', + { + month: currentMonth.getMonth() + 1, + year: currentMonth.getFullYear(), + } + ) + .getRawOne(); + + const previousMonthRevenue = await this.transactionRepository + .createQueryBuilder('transaction') + .select('SUM(transaction.amount)', 'revenue') + .where( + 'EXTRACT(MONTH FROM transaction.date::timestamp)= :month AND EXTRACT(YEAR FROM transaction.date::timestamp)= :year', + { + month: previousMonth.getMonth() + 1, + year: previousMonth.getFullYear(), + } + ) + .getRawOne(); + + const previousRevenue = previousMonthRevenue.revenue || 0; + const currentRevenue = currentMonthRevenue.revenue || 0; + + const revenuePercentChange = + previousRevenue === 0 + ? '100.00%' + : (((currentRevenue - previousRevenue) / previousRevenue) * 100).toFixed(2) + '%'; + + return { + message: SYS_MSG.REVENUE_FETCHED_SUCCESSFULLY, + data: { + totalRevenueCurrentMonth: currentMonthRevenue.revenue, + revenuePercentChange, + }, + }; + } +} diff --git a/src/modules/subscriptions/subscriptions.controller.ts b/src/modules/subscriptions/subscriptions.controller.ts index 67fe648d8..b8cdb9f06 100644 --- a/src/modules/subscriptions/subscriptions.controller.ts +++ b/src/modules/subscriptions/subscriptions.controller.ts @@ -6,7 +6,7 @@ import { SubscriptionsService } from './subscriptions.service'; @ApiBearerAuth() @Controller('subscriptions') -@ApiTags('Subscriptions') +@ApiTags('Dashboard') export class SubscriptionsController { constructor(private readonly subscriptionsService: SubscriptionsService) {} diff --git a/src/modules/user/entities/user.entity.ts b/src/modules/user/entities/user.entity.ts index 771b32359..700f2d14d 100644 --- a/src/modules/user/entities/user.entity.ts +++ b/src/modules/user/entities/user.entity.ts @@ -5,11 +5,13 @@ import { Job } from '../../../modules/jobs/entities/job.entity'; import { NotificationSettings } from '../../../modules/notification-settings/entities/notification-setting.entity'; import { Notification } from '../../../modules/notifications/entities/notifications.entity'; import { Testimonial } from '../../../modules/testimonials/entities/testimonials.entity'; +import { Blog } from '../../blogs/entities/blog.entity'; +import { Comment } from '../../comments/entities/comments.entity'; import { OrganisationMember } from '../../organisations/entities/org-members.entity'; import { Organisation } from '../../organisations/entities/organisations.entity'; import { Profile } from '../../profile/entities/profile.entity'; -import { Comment } from '../../comments/entities/comments.entity'; -import { Blog } from '../../blogs/entities/blog.entity'; +import { Cart } from '../../revenue/entities/cart.entity'; +import { Order } from '../../revenue/entities/order.entity'; export enum UserType { SUPER_ADMIN = 'super-admin', @@ -98,4 +100,10 @@ export class User extends AbstractBaseEntity { @OneToMany(() => Comment, comment => comment.user) comments?: Comment[]; + + @OneToMany(() => Order, order => order.user) + orders?: Order[]; + + @OneToMany(() => Cart, cart => cart.user) + cart: Cart[]; } diff --git a/src/modules/user/tests/mocks/user.mock.ts b/src/modules/user/tests/mocks/user.mock.ts index d1b4bc4db..ff0f8694a 100644 --- a/src/modules/user/tests/mocks/user.mock.ts +++ b/src/modules/user/tests/mocks/user.mock.ts @@ -26,4 +26,5 @@ export const mockUser: User = { notification_settings: [], notifications: [], hashPassword: () => null, + cart: [], };