diff --git a/package.json b/package.json index 7b82529..04d1810 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "brainbox-backend", - "version": "1.6.0", + "version": "1.7.0", "description": "Backend for BrainBox", "author": "lzaycoe (Lazy Code)", "private": true, @@ -39,6 +39,7 @@ "cookie-parser": "1.4.7", "express": "4.21.2", "morgan": "1.10.0", + "nodemailer": "6.10.0", "passport": "0.7.0", "passport-custom": "1.1.1", "passport-jwt": "4.0.1", @@ -58,6 +59,7 @@ "@types/express": "5.0.0", "@types/morgan": "1.9.9", "@types/node": "20.17.0", + "@types/nodemailer": "^6.4.17", "@types/passport-jwt": "4.0.1", "@types/passport-local": "1.0.38", "@typescript-eslint/eslint-plugin": "8.24.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7cc5229..c4772ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: morgan: specifier: 1.10.0 version: 1.10.0 + nodemailer: + specifier: 6.10.0 + version: 6.10.0 passport: specifier: 0.7.0 version: 0.7.0 @@ -114,6 +117,9 @@ importers: '@types/node': specifier: 20.17.0 version: 20.17.0 + '@types/nodemailer': + specifier: ^6.4.17 + version: 6.4.17 '@types/passport-jwt': specifier: 4.0.1 version: 4.0.1 @@ -815,6 +821,9 @@ packages: '@types/node@22.13.5': resolution: {integrity: sha512-+lTU0PxZXn0Dr1NBtC7Y8cR21AJr87dLLU953CWA6pMxxv/UDc7jYAY90upcrie1nRcD6XNG5HOYEDtgW5TxAg==} + '@types/nodemailer@6.4.17': + resolution: {integrity: sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==} + '@types/passport-jwt@4.0.1': resolution: {integrity: sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==} @@ -2229,6 +2238,10 @@ packages: node-releases@2.0.19: resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + nodemailer@6.10.0: + resolution: {integrity: sha512-SQ3wZCExjeSatLE/HBaXS5vqUOQk6GtBdIIKxiFdmm01mOQZX/POJkO3SUX1wDiYcwUOJwT23scFSC9fY2H8IA==} + engines: {node: '>=6.0.0'} + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -3779,6 +3792,10 @@ snapshots: dependencies: undici-types: 6.20.0 + '@types/nodemailer@6.4.17': + dependencies: + '@types/node': 20.17.0 + '@types/passport-jwt@4.0.1': dependencies: '@types/jsonwebtoken': 9.0.5 @@ -5257,6 +5274,8 @@ snapshots: node-releases@2.0.19: {} + nodemailer@6.10.0: {} + normalize-path@3.0.0: {} npm-check-updates@17.1.14: {} diff --git a/prisma/migrations/20250310000201_remove_course_id_column_in_revenue_model/migration.sql b/prisma/migrations/20250310000201_remove_course_id_column_in_revenue_model/migration.sql new file mode 100644 index 0000000..eb2539d --- /dev/null +++ b/prisma/migrations/20250310000201_remove_course_id_column_in_revenue_model/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - You are about to drop the column `courseId` on the `Revenue` table. All the data in the column will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Revenue" DROP CONSTRAINT "Revenue_courseId_fkey"; + +-- AlterTable +ALTER TABLE "Revenue" DROP COLUMN "courseId"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2999b93..df17f33 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -58,7 +58,6 @@ model Course { sections Section[] payments Payment[] progress Progress[] - revenues Revenue[] } model Section { @@ -174,8 +173,6 @@ model Revenue { id Int @id @default(autoincrement()) teacherId Int teacher User @relation(fields: [teacherId], references: [id]) - courseId Int - course Course @relation(fields: [courseId], references: [id]) totalRevenue Decimal @default(0.0) totalWithdrawn Decimal @default(0.0) serviceFee Decimal @default(0.0) diff --git a/src/domains/domains.module.ts b/src/domains/domains.module.ts index 53e87d2..3a320e4 100644 --- a/src/domains/domains.module.ts +++ b/src/domains/domains.module.ts @@ -6,6 +6,7 @@ import { CoursesModule } from '@/courses/courses.module'; import { PaymentsModule } from '@/payments/payments.module'; import { RevenuesModule } from '@/revenues/revenues.module'; import { UsersModule } from '@/users/users.module'; +import { WithdrawalsModule } from '@/withdrawals/withdrawals.module'; @Module({ imports: [ @@ -15,6 +16,7 @@ import { UsersModule } from '@/users/users.module'; PaymentsModule, RevenuesModule, UsersModule, + WithdrawalsModule, ], }) export class DomainsModule {} diff --git a/src/domains/notifies/email.service.ts b/src/domains/notifies/email.service.ts new file mode 100644 index 0000000..3e15f94 --- /dev/null +++ b/src/domains/notifies/email.service.ts @@ -0,0 +1,80 @@ +import { Injectable } from '@nestjs/common'; +import * as nodemailer from 'nodemailer'; + +import { emailSignature } from '@/notifies/template/email-signature'; +import { withdrawalRejected } from '@/notifies/template/withdrawal-rejected'; +import { withdrawalSuccess } from '@/notifies/template/withdrawal-success'; + +@Injectable() +export class EmailService { + private readonly transporter; + + constructor() { + this.transporter = nodemailer.createTransport({ + service: 'gmail', + auth: { + user: process.env.EMAIL_USERNAME, + pass: process.env.EMAIL_PASSWORD, + }, + debug: true, + logger: true, + secure: true, + tls: { + rejectUnauthorized: false, + }, + }); + } + + async sendEmail( + to: string, + subject: string, + teacherName: string, + amount: string, + transactionDate: string, + template: 'withdrawal-success' | 'withdrawal-rejected', + ) { + let htmlContent = ''; + + const templateMap = { + 'withdrawal-success': withdrawalSuccess, + 'withdrawal-rejected': withdrawalRejected, + }; + + if (templateMap[template]) { + htmlContent = templateMap[template] + .replaceAll('{{TEACHER_NAME}}', teacherName) + .replaceAll('{{AMOUNT}}', amount) + .replaceAll('{{TRANSACTION_DATE}}', transactionDate) + .replaceAll( + '{{EMAIL_USERNAME}}', + process.env.EMAIL_USERNAME || 'brainbox.platform@gmail.com', + ); + } + + let signature = emailSignature; + if (typeof signature === 'string') { + signature = signature + .replaceAll( + '{{FRONTEND_URL}}', + process.env.FRONTEND_URL || 'https://brainbox-platform.vercel.app', + ) + .replaceAll( + '{{EMAIL_USERNAME}}', + process.env.EMAIL_USERNAME || 'brainbox.platform@gmail.com', + ); + } + + const mailOptions = { + from: process.env.EMAIL_USERNAME, + to, + subject, + html: htmlContent + signature, + }; + + try { + await this.transporter.sendMail(mailOptions); + } catch (error) { + console.error('Error sending email:', error); + } + } +} diff --git a/src/domains/notifies/template/email-signature.ts b/src/domains/notifies/template/email-signature.ts new file mode 100644 index 0000000..8af4ac6 --- /dev/null +++ b/src/domains/notifies/template/email-signature.ts @@ -0,0 +1,19 @@ +export const emailSignature = ` +
+ + + + + +
+ BrainBox Logo + + BrainBox Platform
+ E-learning Platform
+ {{EMAIL_USERNAME}}
+ {{FRONTEND_URL}} +
+ +`; diff --git a/src/domains/notifies/template/withdrawal-rejected.ts b/src/domains/notifies/template/withdrawal-rejected.ts new file mode 100644 index 0000000..7d4ded6 --- /dev/null +++ b/src/domains/notifies/template/withdrawal-rejected.ts @@ -0,0 +1,16 @@ +export const withdrawalRejected = ` +
+
+

Withdrawal Failed 😞

+
+
+

Hello {{TEACHER_NAME}},

+

We regret to inform you that your withdrawal request could not be processed. 🚫

+

Amount: {{AMOUNT}}

+

Request Time: {{TRANSACTION_DATE}}

+

Please check your bank account details and try again. 🏦

+

If you have any questions, please contact {{EMAIL_USERNAME}}. 📧

+

Thank you for being with BrainBox Platform! 🙏

+
+
+`; diff --git a/src/domains/notifies/template/withdrawal-success.ts b/src/domains/notifies/template/withdrawal-success.ts new file mode 100644 index 0000000..cadb228 --- /dev/null +++ b/src/domains/notifies/template/withdrawal-success.ts @@ -0,0 +1,16 @@ +export const withdrawalSuccess = ` +
+
+

Withdrawal Successful! 🎉

+
+
+

Hello {{TEACHER_NAME}},

+

Congratulations! Your withdrawal request has been successfully processed. ✅

+

Amount: {{AMOUNT}} 💰

+

Processing Time: {{TRANSACTION_DATE}} ⏰

+

This amount has been transferred to your bank account. 🏦

+

If you have any questions, please contact {{EMAIL_USERNAME}}. 📧

+

Thank you for being with BrainBox Platform! 🙏

+
+
+`; diff --git a/src/domains/payments/payments.module.ts b/src/domains/payments/payments.module.ts index 5753dd0..0607046 100644 --- a/src/domains/payments/payments.module.ts +++ b/src/domains/payments/payments.module.ts @@ -8,6 +8,7 @@ import { PaymentsService } from '@/payments/payments.service'; import { PayOSService } from '@/payments/payos.service'; import { ClerkClientProvider } from '@/providers/clerk.service'; import { PrismaService } from '@/providers/prisma.service'; +import { RevenuesService } from '@/revenues/revenues.service'; @Module({ imports: [ConfigModule.forFeature(payOSConfig)], @@ -17,6 +18,7 @@ import { PrismaService } from '@/providers/prisma.service'; PrismaService, CoursesService, PayOSService, + RevenuesService, ClerkClientProvider, ], exports: [PaymentsService], diff --git a/src/domains/payments/payments.service.ts b/src/domains/payments/payments.service.ts index 4e33880..6b2724c 100644 --- a/src/domains/payments/payments.service.ts +++ b/src/domains/payments/payments.service.ts @@ -11,6 +11,7 @@ import { CoursesService } from '@/courses/courses.service'; import { CreatePaymentDto } from '@/payments/dto/create-payment.dto'; import { PayOSService } from '@/payments/payos.service'; import { PrismaService } from '@/providers/prisma.service'; +import { RevenuesService } from '@/revenues/revenues.service'; @Injectable() export class PaymentsService { @@ -20,6 +21,7 @@ export class PaymentsService { private readonly prismaService: PrismaService, private readonly coursesService: CoursesService, private readonly payOSService: PayOSService, + private readonly revenuesService: RevenuesService, @Inject('ClerkClient') private readonly clerkClient: ClerkClient, ) {} @@ -72,10 +74,11 @@ export class PaymentsService { const orderCode = payload?.data?.orderCode; const description = payload?.data?.description; + const amount = payload?.data?.amount; this.logger.debug(`Processing orderCode: ${orderCode}`); - if (!orderCode || (orderCode == '123' && description == 'VQRIO123')) { + if (!orderCode || (orderCode === '123' && description === 'VQRIO123')) { this.logger.warn( 'Received test webhook payload from PayOS, skipping processing.', ); @@ -84,8 +87,8 @@ export class PaymentsService { const payment = await this.prismaService.payment.findUnique({ where: { id: orderCode }, - select: { id: true, userId: true }, }); + if (!payment) { this.logger.error(`Payment with ID ${orderCode} not found`); throw new Error(`Payment with ID ${orderCode} not found`); @@ -114,13 +117,37 @@ export class PaymentsService { }) : Promise.resolve(); + if (payment.courseId === null) { + this.logger.error( + `Payment with ID ${orderCode} has no associated course`, + ); + throw new Error(`Payment with ID ${orderCode} has no associated course`); + } + const course = await this.coursesService.findOne(payment.courseId); + let updateRevenuePromise = Promise.resolve(); + + if (course) { + this.logger.debug(`Updating revenue for teacher ${course.teacherId}`); + updateRevenuePromise = this.revenuesService + .calculateRevenue(course.teacherId, amount) + .then(() => {}); + } else { + this.logger.warn( + `Order ${payment.courseId} is not associated with a course, skipping revenue update.`, + ); + } + const status = payload.success ? 'paid' : 'canceled'; const updatePaymentPromise = this.prismaService.payment.update({ where: { id: orderCode }, data: { status }, }); - await Promise.all([updateRolePromise, updatePaymentPromise]); + await Promise.all([ + updateRolePromise, + updatePaymentPromise, + updateRevenuePromise, + ]); this.logger.debug(`Payment ${orderCode} marked as ${status}`); return 'Webhook processed successfully'; diff --git a/src/domains/revenues/dto/create-revenue.dto.ts b/src/domains/revenues/dto/create-revenue.dto.ts index 7e425fe..fde79c1 100644 --- a/src/domains/revenues/dto/create-revenue.dto.ts +++ b/src/domains/revenues/dto/create-revenue.dto.ts @@ -6,10 +6,6 @@ export class CreateRevenueDto { @IsNotEmpty() teacherId: number; - @ApiProperty() - @IsNotEmpty() - courseId: number; - @ApiProperty() @IsNotEmpty() totalRevenue: number; diff --git a/src/domains/revenues/revenues.controller.ts b/src/domains/revenues/revenues.controller.ts index 7f315a7..9389536 100644 --- a/src/domains/revenues/revenues.controller.ts +++ b/src/domains/revenues/revenues.controller.ts @@ -1,43 +1,21 @@ -import { - Body, - Controller, - Delete, - Get, - Param, - Patch, - Post, -} from '@nestjs/common'; +import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; import { CreateRevenueDto } from '@/revenues/dto/create-revenue.dto'; -import { UpdateRevenueDto } from '@/revenues/dto/update-revenue.dto'; import { RevenuesService } from '@/revenues/revenues.service'; +@ApiTags('Revenues') @Controller('revenues') export class RevenuesController { constructor(private readonly revenuesService: RevenuesService) {} @Post() - create(@Body() createRevenueDto: CreateRevenueDto) { - return this.revenuesService.create(createRevenueDto); + async create(@Body() createRevenueDto: CreateRevenueDto) { + return await this.revenuesService.create(createRevenueDto); } - @Get() - findAll() { - return this.revenuesService.findAll(); - } - - @Get(':id') - findOne(@Param('id') id: string) { - return this.revenuesService.findOne(+id); - } - - @Patch(':id') - update(@Param('id') id: string, @Body() updateRevenueDto: UpdateRevenueDto) { - return this.revenuesService.update(+id, updateRevenueDto); - } - - @Delete(':id') - remove(@Param('id') id: string) { - return this.revenuesService.remove(+id); + @Get('teacher/:teacherId') + async findByTeacherId(@Param('teacherId') teacherId: string) { + return await this.revenuesService.findByTeacherId(+teacherId); } } diff --git a/src/domains/revenues/revenues.service.ts b/src/domains/revenues/revenues.service.ts index ac182ee..f3c5c96 100644 --- a/src/domains/revenues/revenues.service.ts +++ b/src/domains/revenues/revenues.service.ts @@ -1,27 +1,129 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '@/providers/prisma.service'; import { CreateRevenueDto } from '@/revenues/dto/create-revenue.dto'; -import { UpdateRevenueDto } from '@/revenues/dto/update-revenue.dto'; @Injectable() export class RevenuesService { - create(createRevenueDto: CreateRevenueDto) { - return 'This action adds a new revenue' + createRevenueDto; - } + private readonly logger = new Logger(RevenuesService.name); + + constructor(private readonly prismaService: PrismaService) {} + + async create(createRevenueDto: CreateRevenueDto) { + const existingRevenue = await this.prismaService.revenue.findFirst({ + where: { + teacherId: createRevenueDto.teacherId, + }, + }); + + if (existingRevenue) { + this.logger.debug( + `Revenue for teacher ${createRevenueDto.teacherId} already exists`, + ); + + return existingRevenue; + } + + const newRevenue = await this.prismaService.revenue.create({ + data: createRevenueDto, + }); + + this.logger.debug( + `Revenue for teacher ${createRevenueDto.teacherId} created successfully`, + ); - findAll() { - return `This action returns all revenues`; + this.logger.log('Revenue created successfully'); + + return newRevenue; } - findOne(id: number) { - return `This action returns a #${id} revenue`; + async findByTeacherId(teacherId: number) { + const revenue = await this.prismaService.revenue.findFirst({ + where: { teacherId }, + }); + + if (!revenue) { + this.logger.error(`Revenue for teacher ${teacherId} not found`); + + throw new NotFoundException(`Revenue for teacher ${teacherId} not found`); + } + + return revenue; } - update(id: number, updateRevenueDto: UpdateRevenueDto) { - return `This action updates a #${id} revenue` + updateRevenueDto; + async calculateRevenue(teacherId: number, price: number) { + const revenue = await this.prismaService.revenue.findFirst({ + where: { teacherId }, + }); + + if (!revenue) { + const totalRevenue = price; + const serviceFee = totalRevenue * 0.1; + const netRevenue = totalRevenue - serviceFee; + const totalWithdrawn = 0; + const availableForWithdraw = netRevenue - totalWithdrawn; + + const newRevenue = await this.create({ + teacherId, + totalRevenue, + serviceFee, + netRevenue, + totalWithdrawn, + availableForWithdraw, + }); + + this.logger.debug( + `Revenue for teacher ${teacherId} created successfully`, + ); + + return newRevenue; + } + + const totalRevenue = +revenue.totalRevenue + price; + const totalWithdrawn = revenue.totalWithdrawn; + const serviceFee = totalRevenue * 0.1; + const netRevenue = totalRevenue - serviceFee; + const availableForWithdraw = netRevenue - +totalWithdrawn; + + const updatedRevenue = await this.prismaService.revenue.update({ + where: { id: revenue.id }, + data: { + totalRevenue, + serviceFee, + netRevenue, + availableForWithdraw, + }, + }); + + this.logger.debug(`Revenue for teacher ${teacherId} updated successfully`); + + return updatedRevenue; } - remove(id: number) { - return `This action removes a #${id} revenue`; + async updateTotalWithdrawn(teacherId: number, amount: number) { + const revenue = await this.prismaService.revenue.findFirst({ + where: { teacherId }, + }); + + if (!revenue) { + this.logger.error(`Revenue for teacher ${teacherId} not found`); + + throw new NotFoundException(`Revenue for teacher ${teacherId} not found`); + } + + const totalWithdrawn = +revenue.totalWithdrawn + amount; + const availableForWithdraw = +revenue.availableForWithdraw - amount; + + const updatedRevenue = await this.prismaService.revenue.update({ + where: { id: revenue.id }, + data: { + totalWithdrawn, + availableForWithdraw, + }, + }); + + this.logger.debug(`Revenue for teacher ${teacherId} updated successfully`); + + return updatedRevenue; } } diff --git a/src/domains/users/dto/create-bank-account.dto.ts b/src/domains/users/dto/create-bank-account.dto.ts new file mode 100644 index 0000000..ff5698c --- /dev/null +++ b/src/domains/users/dto/create-bank-account.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty } from 'class-validator'; + +export class CreateBankAccountDto { + @ApiProperty() + @IsNotEmpty() + bank_name: string; + + @ApiProperty() + @IsNotEmpty() + account_number: string; + + @ApiProperty() + @IsNotEmpty() + account_holder: string; +} diff --git a/src/domains/users/dto/update-bank-account.dto.ts b/src/domains/users/dto/update-bank-account.dto.ts new file mode 100644 index 0000000..161c5ec --- /dev/null +++ b/src/domains/users/dto/update-bank-account.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from '@nestjs/swagger'; + +import { CreateBankAccountDto } from '@/users/dto/create-bank-account.dto'; + +export class UpdateBankAccountDto extends PartialType(CreateBankAccountDto) {} diff --git a/src/domains/users/users.controller.ts b/src/domains/users/users.controller.ts index 9fa7f1d..9449fbd 100644 --- a/src/domains/users/users.controller.ts +++ b/src/domains/users/users.controller.ts @@ -6,10 +6,13 @@ import { HttpStatus, Param, Post, + Put, Request, } from '@nestjs/common'; import { BecomeATeacherDto } from '@/users/dto/become-a-teacher.dto'; +import { CreateBankAccountDto } from '@/users/dto/create-bank-account.dto'; +import { UpdateBankAccountDto } from '@/users/dto/update-bank-account.dto'; import { UsersService } from '@/users/users.service'; @Controller('users') @@ -41,4 +44,20 @@ export class UsersController { async getTopTeachers(@Param('top') top: string) { return this.usersService.getTopTeachers(+top); } + + @Post('teachers/:teacherId/create-bank-account') + async createBankAccount( + @Param('teacherId') teacherId: string, + @Body() dto: CreateBankAccountDto, + ) { + return this.usersService.createBankAccount(+teacherId, dto); + } + + @Put('teachers/:teacherId/update-bank-account') + async updateBankAccount( + @Param('teacherId') teacherId: string, + @Body() dto: UpdateBankAccountDto, + ) { + return this.usersService.updateBankAccount(+teacherId, dto); + } } diff --git a/src/domains/users/users.service.ts b/src/domains/users/users.service.ts index a4ba0c9..0abc829 100644 --- a/src/domains/users/users.service.ts +++ b/src/domains/users/users.service.ts @@ -15,6 +15,8 @@ import { Webhook } from 'svix'; import { PayOSService } from '@/payments/payos.service'; import { PrismaService } from '@/providers/prisma.service'; import { BecomeATeacherDto } from '@/users/dto/become-a-teacher.dto'; +import { CreateBankAccountDto } from '@/users/dto/create-bank-account.dto'; +import { UpdateBankAccountDto } from '@/users/dto/update-bank-account.dto'; @Injectable() export class UsersService { @@ -261,4 +263,97 @@ export class UsersService { return detailedTeachers; } + + async createBankAccount(teacherId: number, dto: CreateBankAccountDto) { + const teacher = await this.prismaService.user.findUnique({ + where: { id: teacherId }, + }); + + if (!teacher) { + this.logger.debug(`Teacher ${teacherId} not found`); + throw new NotFoundException('Teacher not found'); + } + + const existingPayment = await this.prismaService.payment.findFirst({ + where: { + userId: teacherId, + courseId: null, + status: 'paid', + }, + }); + + if (!existingPayment) { + this.logger.debug(`User ${teacherId} has not paid for Become a Teacher`); + throw new ConflictException('User has not paid for Become a Teacher'); + } + + const clerkUser = await this.clerkClient.users.getUser(teacher.clerkId); + + if (!clerkUser) { + throw new NotFoundException('Clerk user not found'); + } + + this.logger.debug(clerkUser); + + const existingMetadata = clerkUser.publicMetadata || {}; + + const newBankAccount = await this.clerkClient.users.updateUser( + teacher.clerkId, + { + publicMetadata: { + ...existingMetadata, + bank_account: { + bank_name: dto.bank_name, + account_number: dto.account_number, + account_holder: dto.account_holder, + }, + }, + }, + ); + + this.logger.debug('Bank account created:', newBankAccount); + this.logger.log('Bank account created for teacher:', teacherId); + + return newBankAccount; + } + + async updateBankAccount(teacherId: number, dto: UpdateBankAccountDto) { + const teacher = await this.prismaService.user.findUnique({ + where: { id: teacherId }, + }); + + if (!teacher) { + this.logger.debug(`Teacher ${teacherId} not found`); + throw new NotFoundException('Teacher not found'); + } + + const clerkUser = await this.clerkClient.users.getUser(teacher.clerkId); + + if (!clerkUser) { + throw new NotFoundException('Clerk user not found'); + } + + this.logger.debug(clerkUser); + + const existingMetadata = clerkUser.publicMetadata || {}; + + const updatedBankAccount = await this.clerkClient.users.updateUser( + teacher.clerkId, + { + publicMetadata: { + ...existingMetadata, + bank_account: { + bank_name: dto.bank_name, + account_number: dto.account_number, + account_holder: dto.account_holder, + }, + }, + }, + ); + + this.logger.debug('Bank account updated:', updatedBankAccount); + this.logger.log('Bank account updated for teacher:', teacherId); + + return updatedBankAccount; + } } diff --git a/src/domains/withdrawals/dto/create-withdrawal.dto.ts b/src/domains/withdrawals/dto/create-withdrawal.dto.ts new file mode 100644 index 0000000..eae3e31 --- /dev/null +++ b/src/domains/withdrawals/dto/create-withdrawal.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum, IsNotEmpty, IsOptional } from 'class-validator'; + +export class CreateWithdrawalDto { + @ApiProperty() + @IsNotEmpty() + teacherId: number; + + @ApiProperty() + @IsNotEmpty() + amount: number; + + @ApiProperty() + @IsNotEmpty() + @IsEnum(['pending', 'approved', 'rejected', 'processing', 'completed']) + status: WithdrawalStatus; + + @ApiProperty() + @IsOptional() + adminId?: number; + + @ApiProperty() + @IsOptional() + reason?: string; +} + +export type WithdrawalStatus = + | 'pending' + | 'approved' + | 'rejected' + | 'processing' + | 'completed'; diff --git a/src/domains/withdrawals/dto/update-withdrawal.dto.ts b/src/domains/withdrawals/dto/update-withdrawal.dto.ts new file mode 100644 index 0000000..d5e4c97 --- /dev/null +++ b/src/domains/withdrawals/dto/update-withdrawal.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from '@nestjs/swagger'; + +import { CreateWithdrawalDto } from '@/withdrawals/dto/create-withdrawal.dto'; + +export class UpdateWithdrawalDto extends PartialType(CreateWithdrawalDto) {} diff --git a/src/domains/withdrawals/withdrawals.controller.ts b/src/domains/withdrawals/withdrawals.controller.ts new file mode 100644 index 0000000..067e049 --- /dev/null +++ b/src/domains/withdrawals/withdrawals.controller.ts @@ -0,0 +1,35 @@ +import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; + +import { CreateWithdrawalDto } from '@/withdrawals/dto/create-withdrawal.dto'; +import { UpdateWithdrawalDto } from '@/withdrawals/dto/update-withdrawal.dto'; +import { WithdrawalsService } from '@/withdrawals/withdrawals.service'; + +@ApiTags('Withdrawals') +@Controller('withdrawals') +export class WithdrawalsController { + constructor(private readonly withdrawalsService: WithdrawalsService) {} + + @Post() + async create(@Body() createWithdrawalDto: CreateWithdrawalDto) { + return await this.withdrawalsService.create(createWithdrawalDto); + } + + @Get() + async findAll() { + return await this.withdrawalsService.findAll(); + } + + @Get(':id') + async findOne(@Param('id') id: string) { + return await this.withdrawalsService.findOne(+id); + } + + @Put(':id') + async update( + @Param('id') id: string, + @Body() updateWithdrawalDto: UpdateWithdrawalDto, + ) { + return await this.withdrawalsService.update(+id, updateWithdrawalDto); + } +} diff --git a/src/domains/withdrawals/withdrawals.module.ts b/src/domains/withdrawals/withdrawals.module.ts new file mode 100644 index 0000000..1088d0c --- /dev/null +++ b/src/domains/withdrawals/withdrawals.module.ts @@ -0,0 +1,28 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; + +import payOSConfig from '@/configs/payos.config'; +import { EmailService } from '@/notifies/email.service'; +import { PayOSService } from '@/payments/payos.service'; +import { ClerkClientProvider } from '@/providers/clerk.service'; +import { PrismaService } from '@/providers/prisma.service'; +import { RevenuesService } from '@/revenues/revenues.service'; +import { UsersService } from '@/users/users.service'; +import { WithdrawalsController } from '@/withdrawals/withdrawals.controller'; +import { WithdrawalsService } from '@/withdrawals/withdrawals.service'; + +@Module({ + imports: [ConfigModule.forFeature(payOSConfig)], + controllers: [WithdrawalsController], + providers: [ + WithdrawalsService, + PrismaService, + RevenuesService, + EmailService, + UsersService, + PayOSService, + ClerkClientProvider, + ], + exports: [WithdrawalsService], +}) +export class WithdrawalsModule {} diff --git a/src/domains/withdrawals/withdrawals.service.ts b/src/domains/withdrawals/withdrawals.service.ts new file mode 100644 index 0000000..1831f1d --- /dev/null +++ b/src/domains/withdrawals/withdrawals.service.ts @@ -0,0 +1,150 @@ +import { + ConflictException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; + +import { EmailService } from '@/notifies/email.service'; +import { PrismaService } from '@/providers/prisma.service'; +import { RevenuesService } from '@/revenues/revenues.service'; +import { UsersService } from '@/users/users.service'; +import { CreateWithdrawalDto } from '@/withdrawals/dto/create-withdrawal.dto'; +import { UpdateWithdrawalDto } from '@/withdrawals/dto/update-withdrawal.dto'; + +@Injectable() +export class WithdrawalsService { + private readonly logger = new Logger(WithdrawalsService.name); + + constructor( + private readonly prismaService: PrismaService, + private readonly revenuesService: RevenuesService, + private readonly emailService: EmailService, + private readonly usersService: UsersService, + ) {} + + async create(dto: CreateWithdrawalDto) { + const revenue = await this.revenuesService.findByTeacherId(dto.teacherId); + + if (!revenue || +revenue.availableForWithdraw < dto.amount) { + this.logger.error(`Insufficient funds for teacher ${dto.teacherId}`); + throw new ConflictException( + `Insufficient funds for teacher ${dto.teacherId}`, + ); + } + + this.logger.log('Creating a new withdrawal'); + + const withdrawal = await this.prismaService.withdrawal.create({ + data: dto, + }); + + this.logger.debug('Withdrawal created', withdrawal); + this.logger.log(`Withdrawal created: ${withdrawal.id}`); + + return withdrawal; + } + + async findAll() { + this.logger.log('Finding all withdrawals'); + + const withdrawals = await this.prismaService.withdrawal.findMany(); + + const withdrawalsWithBankAccounts = await Promise.all( + withdrawals.map(async (withdrawal) => { + const user = await this.usersService.findOneClerk( + withdrawal.teacherId.toString(), + ); + const bankAccount = user?.publicMetadata?.bank_account; + return { ...withdrawal, bankAccount }; + }), + ); + + this.logger.debug('Withdrawals found', withdrawalsWithBankAccounts); + this.logger.log(`Found ${withdrawalsWithBankAccounts.length} withdrawals`); + + return withdrawalsWithBankAccounts; + } + + async findOne(id: number) { + this.logger.log(`Finding withdrawal with ID ${id}`); + + const withdrawal = await this.prismaService.withdrawal.findUnique({ + where: { id }, + }); + + if (!withdrawal) { + this.logger.error(`Withdrawal with ID ${id} not found`); + throw new NotFoundException('Withdrawal not found'); + } + + const user = await this.usersService.findOneClerk( + withdrawal.teacherId.toString(), + ); + + const bankAccount = user?.publicMetadata?.bank_account; + + this.logger.debug('Withdrawal found', withdrawal); + this.logger.log(`Found withdrawal with ID ${id}`); + + return { withdrawal, bankAccount }; + } + + async update(id: number, dto: UpdateWithdrawalDto) { + this.logger.log(`Updating withdrawal with ID ${id}`); + + const withdrawal = await this.prismaService.withdrawal.update({ + where: { id }, + data: dto, + }); + + this.logger.debug('Withdrawal updated', withdrawal); + this.logger.log(`Updated withdrawal with ID ${id}`); + + if (dto.status === 'approved' || dto.status === 'rejected') { + if (dto.status === 'approved') { + await this.revenuesService.updateTotalWithdrawn( + withdrawal.teacherId, + +withdrawal.amount, + ); + + this.logger.log('Revenue updated'); + } + + const teacher = await this.usersService.findOneClerk( + withdrawal.teacherId.toString(), + ); + + const email = teacher.email_addresses[0]?.email_address; + const subject = + dto.status === 'approved' + ? '[BrainBox] Withdrawal Request Approved' + : '[BrainBox] Withdrawal Request Rejected'; + const teacherName = teacher.first_name + ' ' + teacher.last_name; + const amount = new Intl.NumberFormat('vi-VN', { + style: 'currency', + currency: 'VND', + }).format(+withdrawal.amount); + const transactionDate = new Date(withdrawal.updateAt) + .toISOString() + .split('T')[0]; + const template = + dto.status === 'approved' + ? 'withdrawal-success' + : 'withdrawal-rejected'; + + await this.emailService.sendEmail( + email, + subject, + teacherName, + amount, + transactionDate, + template, + ); + + this.logger.log('Email sent successfully'); + } + + return withdrawal; + } +} diff --git a/tsconfig.json b/tsconfig.json index 566a7b7..cf50ec7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,9 @@ "@/payments/*": ["src/domains/payments/*"], "@/users/*": ["src/domains/users/*"], "@/chats/*": ["src/domains/chats/*"], - "@/revenues/*": ["src/domains/revenues/*"] + "@/revenues/*": ["src/domains/revenues/*"], + "@/withdrawals/*": ["src/domains/withdrawals/*"], + "@/notifies/*": ["src/domains/notifies/*"], }, "incremental": true, "skipLibCheck": true,