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 = `
+
+
+
+`;
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 = `
+
+
+
+
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 = `
+
+
+
+
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,