From 89adbfdb33efc83ad11e0d13a60d27fffe7198af Mon Sep 17 00:00:00 2001 From: morteza-mortezai Date: Mon, 29 Dec 2025 09:59:29 +0330 Subject: [PATCH] wallet transaction entity added --- .../orders/listeners/order.listeners.ts | 138 +------------- .../users/controllers/users.controller.ts | 18 +- .../dto/create-wallet-transaction.dto.ts | 34 ++++ .../users/dto/find-wallet-transactions.dto.ts | 136 ++++++++++++++ .../entities/wallet-transaction.entity.ts | 23 +++ src/modules/users/interface/wallet.ts | 13 ++ src/modules/users/providers/user.service.ts | 17 +- src/modules/users/providers/wallet.service.ts | 174 ++++++++++++++++++ .../wallet-transaction.repository.ts | 10 + src/modules/users/user.module.ts | 12 +- 10 files changed, 431 insertions(+), 144 deletions(-) create mode 100644 src/modules/users/dto/create-wallet-transaction.dto.ts create mode 100644 src/modules/users/dto/find-wallet-transactions.dto.ts create mode 100644 src/modules/users/entities/wallet-transaction.entity.ts create mode 100644 src/modules/users/interface/wallet.ts create mode 100644 src/modules/users/providers/wallet.service.ts create mode 100644 src/modules/users/repositories/wallet-transaction.repository.ts diff --git a/src/modules/orders/listeners/order.listeners.ts b/src/modules/orders/listeners/order.listeners.ts index 8a3a2e0..8b6b173 100644 --- a/src/modules/orders/listeners/order.listeners.ts +++ b/src/modules/orders/listeners/order.listeners.ts @@ -169,140 +169,4 @@ export class OrderListeners { } } - // private factory(orderStatus: OrderStatus, event: OrderStatusChangedEvent) { - // switch (orderStatus) { - // case OrderStatus.CONFIRMED: - // return { - // message: { - // title: NotifTitleEnum.ORDER_STATUS_CHANGED, - // content: `Order ${event.orderNumber} confirmed .`, - // sms: { - // templateId: this.orderCreatedSmsTemplateId, - // parameters: { - // orderNumber: event.orderNumber, - // }, - // }, - // pushNotif: { - // title: `تایید سفارش`, - // content: `سفارش شماره ${event.orderNumber} با موفقیت تایید شد`, - // icon: `Order ${event.orderNumber} has been confirmed successfully`, - // action: { - // type: NotifTitleEnum.ORDER_STATUS_CHANGED, - // url: ``, - // }, - // }, - // }, - // }; - // case OrderStatus.PREPARING: - // return { - // message: { - // title: NotifTitleEnum.ORDER_STATUS_CHANGED, - // content: `سفارش شماره ${event.orderNumber} در حال آماده‌سازی است`, - // sms: { - // templateId: this.orderCreatedSmsTemplateId, - // parameters: { - // orderNumber: event.orderNumber, - // }, - // }, - // pushNotif: { - // title: `آماده‌سازی سفارش`, - // content: `سفارش شماره ${event.orderNumber} در حال آماده‌سازی است`, - // icon: `Order ${event.orderNumber} is preparing`, - // action: { - // type: NotifTitleEnum.ORDER_STATUS_CHANGED, - // url: ``, - // }, - // }, - // }, - // }; - // case OrderStatus.DELIVERED_TO_RECEPTIONIST: - // return { - // message: { - // title: NotifTitleEnum.ORDER_STATUS_CHANGED, - // content: `سفارش شماره ${event.orderNumber} آماده است`, - // sms: { - // templateId: this.orderCreatedSmsTemplateId, - // parameters: { - // orderNumber: event.orderNumber, - // }, - // }, - // pushNotif: { - // title: `سفارش آماده است`, - // content: `سفارش شماره ${event.orderNumber} آماده است`, - // icon: `Order ${event.orderNumber} is ready`, - // action: { - // type: NotifTitleEnum.ORDER_STATUS_CHANGED, - // url: ``, - // }, - // }, - // }, - // }; - // case OrderStatus.SHIPPED: - // return { - // message: { - // title: NotifTitleEnum.ORDER_STATUS_CHANGED, - // content: `سفارش شماره ${event.orderNumber} ارسال شد`, - // sms: { - // templateId: this.orderCreatedSmsTemplateId, - // parameters: { - // orderNumber: event.orderNumber, - // }, - // }, - // pushNotif: { - // title: `ارسال سفارش`, - // content: `سفارش شماره ${event.orderNumber} ارسال شد`, - // icon: `Order ${event.orderNumber} is shipped`, - // action: { - // type: NotifTitleEnum.ORDER_STATUS_CHANGED, - // url: ``, - // }, - // }, - // }, - // }; - // case OrderStatus.CANCELED: - // return { - // message: { - // title: NotifTitleEnum.ORDER_STATUS_CHANGED, - // content: `سفارش شماره ${event.orderNumber} لغو شد`, - // sms: { - // templateId: this.orderCreatedSmsTemplateId, - // parameters: { - // orderNumber: event.orderNumber, - // }, - // }, - // pushNotif: { - // title: `لغو سفارش`, - // content: `سفارش شماره ${event.orderNumber} لغو شد`, - // icon: `Order ${event.orderNumber} is canceled`, - // action: { - // type: NotifTitleEnum.ORDER_STATUS_CHANGED, - // url: ``, - // }, - // }, - // }, - // }; - // default: - // return { - // message: { - // title: NotifTitleEnum.ORDER_STATUS_CHANGED, - // content: `Order ${event.orderNumber} is ....`, - // sms: { - // templateId: this.orderCreatedSmsTemplateId, - // parameters: { - // orderNumber: event.orderNumber, - // }, - // }, - // pushNotif: { - // title: `Order ${event.orderNumber} is ...`, - // content: `Order ${event.orderNumber} is ...`, - // icon: `Order ${event.orderNumber} is ...`, - // action: { - // type: NotifTitleEnum.ORDER_STATUS_CHANGED, - // url: ``, - // }, - // }, - // }, - // }; - // } - // } -} + } diff --git a/src/modules/users/controllers/users.controller.ts b/src/modules/users/controllers/users.controller.ts index b08a58a..fee04f0 100644 --- a/src/modules/users/controllers/users.controller.ts +++ b/src/modules/users/controllers/users.controller.ts @@ -8,6 +8,7 @@ import { UpdateUserAddressDto } from '../dto/update-user-address.dto'; import { UserId } from 'src/common/decorators/user-id.decorator'; import { RestId } from 'src/common/decorators'; import { FindUsersDto } from '../dto/find-user.dto'; +import { FindWalletTransactionsDto } from '../dto/find-wallet-transactions.dto'; import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard'; import { API_HEADER_SLUG } from 'src/common/constants/index'; import { UserSuccessMessage } from 'src/common/enums/message.enum'; @@ -60,6 +61,21 @@ export class UsersController { return wallet; } + @UseGuards(AuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Get User Wallet Transactions' }) + @ApiHeader(API_HEADER_SLUG) + @Get('public/user/wallet/transactions') + async getUserWalletTransactions( + @UserId() userId: string, + @RestId() restId: string, + @Query(new ValidationPipe({ transform: true, whitelist: true })) + query: FindWalletTransactionsDto, + ) { + const transactions = await this.userService.getUserWalletTransactions(userId, restId, query); + return transactions; + } + @UseGuards(AuthGuard) @ApiBearerAuth() @ApiOperation({ summary: 'Create a new address for the authenticated user' }) @@ -142,7 +158,7 @@ export class UsersController { @ApiBearerAuth() @Permissions(Permission.MANAGE_USERS) @ApiOperation({ summary: 'Get paginated list of restuarant users with filters' }) - @Get('admin/users') + @Get('admin/users') async findAllRestaurantUsers( @Query(new ValidationPipe({ transform: true, whitelist: true })) query: FindUsersDto, diff --git a/src/modules/users/dto/create-wallet-transaction.dto.ts b/src/modules/users/dto/create-wallet-transaction.dto.ts new file mode 100644 index 0000000..7f52fc3 --- /dev/null +++ b/src/modules/users/dto/create-wallet-transaction.dto.ts @@ -0,0 +1,34 @@ +import { IsNotEmpty, IsNumber, IsIn } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; +import { WalletTransactionType, WalletTransactionReason } from '../interface/wallet'; + +export class CreateWalletTransactionDto { + @ApiProperty({ + description: 'Transaction amount', + type: Number, + example: 1000, + }) + @IsNotEmpty() + @IsNumber() + @Type(() => Number) + amount!: number; + + @ApiProperty({ + description: 'Transaction type', + enum: WalletTransactionType, + example: WalletTransactionType.CREDIT, + }) + @IsNotEmpty() + @IsIn(Object.values(WalletTransactionType)) + type!: WalletTransactionType; + + @ApiProperty({ + description: 'Transaction reason', + enum: WalletTransactionReason, + example: WalletTransactionReason.ORDER_PAYMENT, + }) + @IsNotEmpty() + @IsIn(Object.values(WalletTransactionReason)) + reason!: WalletTransactionReason; +} diff --git a/src/modules/users/dto/find-wallet-transactions.dto.ts b/src/modules/users/dto/find-wallet-transactions.dto.ts new file mode 100644 index 0000000..2aa5d4b --- /dev/null +++ b/src/modules/users/dto/find-wallet-transactions.dto.ts @@ -0,0 +1,136 @@ +import { IsOptional, IsString, IsNumber, Min, IsIn, IsDateString } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { WalletTransactionType, WalletTransactionReason } from '../interface/wallet'; + +// Define the valid sort directions +const sortOrderOptions = ['asc', 'desc'] as const; +type SortOrder = (typeof sortOrderOptions)[number]; + +export class FindWalletTransactionsDto { + /** + * The page number to retrieve. + * @default 1 + */ + @ApiPropertyOptional({ + description: 'Page number', + type: Number, + default: 1, + minimum: 1, + }) + @IsOptional() + @IsNumber() + @Min(1) + @Type(() => Number) + page?: number = 1; + + /** + * The number of items per page. + * @default 10 + */ + @ApiPropertyOptional({ + description: 'Items per page', + type: Number, + default: 10, + minimum: 1, + }) + @IsOptional() + @IsNumber() + @Min(1) + @Type(() => Number) + limit?: number = 10; + + /** + * Filter by transaction type + */ + @ApiPropertyOptional({ + description: 'Transaction type filter', + enum: WalletTransactionType, + }) + @IsOptional() + @IsIn(Object.values(WalletTransactionType)) + type?: WalletTransactionType; + + /** + * Filter by transaction reason + */ + @ApiPropertyOptional({ + description: 'Transaction reason filter', + enum: WalletTransactionReason, + }) + @IsOptional() + @IsIn(Object.values(WalletTransactionReason)) + reason?: WalletTransactionReason; + + /** + * Filter transactions from this date onwards + */ + @ApiPropertyOptional({ + description: 'Start date filter (ISO 8601 format)', + type: String, + }) + @IsOptional() + @IsDateString() + startDate?: string; + + /** + * Filter transactions up to this date + */ + @ApiPropertyOptional({ + description: 'End date filter (ISO 8601 format)', + type: String, + }) + @IsOptional() + @IsDateString() + endDate?: string; + + /** + * Minimum amount filter + */ + @ApiPropertyOptional({ + description: 'Minimum amount filter', + type: Number, + }) + @IsOptional() + @IsNumber() + @Type(() => Number) + minAmount?: number; + + /** + * Maximum amount filter + */ + @ApiPropertyOptional({ + description: 'Maximum amount filter', + type: Number, + }) + @IsOptional() + @IsNumber() + @Type(() => Number) + maxAmount?: number; + + /** + * The field to sort the results by. + * @default "createdAt" + */ + @ApiPropertyOptional({ + description: 'Sort field', + type: String, + default: 'createdAt', + }) + @IsOptional() + @IsString() + orderBy?: string = 'createdAt'; + + /** + * The direction to sort the results. + * @default "desc" + */ + @ApiPropertyOptional({ + description: 'Sort direction', + enum: sortOrderOptions, + default: 'desc', + }) + @IsOptional() + @IsIn(sortOrderOptions) + order?: SortOrder = 'desc'; +} diff --git a/src/modules/users/entities/wallet-transaction.entity.ts b/src/modules/users/entities/wallet-transaction.entity.ts new file mode 100644 index 0000000..037f786 --- /dev/null +++ b/src/modules/users/entities/wallet-transaction.entity.ts @@ -0,0 +1,23 @@ +import { Entity, Index, Property, OneToMany, Collection, Cascade, ManyToOne, Enum } from '@mikro-orm/core'; +import { BaseEntity } from '../../../common/entities/base.entity'; +import { User } from './user.entity'; +import { WalletTransactionReason, WalletTransactionType } from '../interface/wallet'; +import { UserWallet } from './user-wallet.entity'; + +@Entity({ tableName: 'wallet_transactions' }) +export class WalletTransaction extends BaseEntity { + @ManyToOne(() => UserWallet) + userWallet!: UserWallet; + + @Property({ type: 'decimal', precision: 10, scale: 0 }) + amount!: number; + + @Property({ type: 'decimal', precision: 10, scale: 0 }) + balance!: number; + + @Enum(() => WalletTransactionType) + type!: WalletTransactionType; + + @Enum(() => WalletTransactionReason) + reason!: WalletTransactionReason; +} diff --git a/src/modules/users/interface/wallet.ts b/src/modules/users/interface/wallet.ts new file mode 100644 index 0000000..ad91490 --- /dev/null +++ b/src/modules/users/interface/wallet.ts @@ -0,0 +1,13 @@ +export enum WalletTransactionType { + CREDIT = 'credit', + DEBIT = 'debit', +} + + +export enum WalletTransactionReason { + ORDER_PAYMENT = 'order_payment', + ORDER_REFUND = 'order_refund', + CONVERT_SCORE_TO_WALLET = 'convert_score_to_wallet', + WITHDRAW = 'withdraw', + DEPOSIT = 'deposit', +} \ No newline at end of file diff --git a/src/modules/users/providers/user.service.ts b/src/modules/users/providers/user.service.ts index 9d57449..a6e3292 100644 --- a/src/modules/users/providers/user.service.ts +++ b/src/modules/users/providers/user.service.ts @@ -5,6 +5,8 @@ import { UserAddress } from '../entities/user-address.entity'; import { EntityManager } from '@mikro-orm/postgresql'; import { UpdateUserDto } from '../dto/update-user.dto'; import { FindUsersDto } from '../dto/find-user.dto'; +import { FindWalletTransactionsDto } from '../dto/find-wallet-transactions.dto'; +import { CreateWalletTransactionDto } from '../dto/create-wallet-transaction.dto'; import { CreateUserAddressDto } from '../dto/create-user-address.dto'; import { UpdateUserAddressDto } from '../dto/update-user-address.dto'; import { PaginatedResult } from 'src/common/interfaces/pagination.interface'; @@ -13,6 +15,7 @@ import { normalizePhone } from '../../utils/phone.util'; import { RestRepository } from 'src/modules/restaurants/repositories/rest.repository'; import { UserWallet } from '../entities/user-wallet.entity'; import { UserWalletRepository } from '../repositories/user-wallet.repository'; +import { WalletService } from './wallet.service'; @Injectable() export class UserService { @@ -20,8 +23,9 @@ export class UserService { private readonly userRepository: UserRepository, private readonly restaurantRepository: RestRepository, private readonly userWalletRepository: UserWalletRepository, + private readonly walletService: WalletService, private readonly em: EntityManager, - ) {} + ) { } async findOrCreateByPhone(phone: string): Promise { const normalizedPhone = normalizePhone(phone); @@ -343,6 +347,15 @@ export class UserService { return user; } getUserWallet(userId: string, restId: string): Promise { - return this.userWalletRepository.findOne({ user: { id: userId }, restaurant: { id: restId } }); + return this.walletService.getUserWallet(userId, restId); } + + getUserWalletTransactions(userId: string, restId: string, dto: FindWalletTransactionsDto) { + return this.walletService.getUserWalletTransactions(userId, restId, dto); + } + + createWalletTransaction(userId: string, restId: string, dto: CreateWalletTransactionDto) { + return this.walletService.createTransaction(userId, restId, dto); + } + } diff --git a/src/modules/users/providers/wallet.service.ts b/src/modules/users/providers/wallet.service.ts new file mode 100644 index 0000000..5682812 --- /dev/null +++ b/src/modules/users/providers/wallet.service.ts @@ -0,0 +1,174 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { FilterQuery } from '@mikro-orm/core'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { WalletTransaction } from '../entities/wallet-transaction.entity'; +import { UserWallet } from '../entities/user-wallet.entity'; +import { WalletTransactionRepository } from '../repositories/wallet-transaction.repository'; +import { UserWalletRepository } from '../repositories/user-wallet.repository'; +import { FindWalletTransactionsDto } from '../dto/find-wallet-transactions.dto'; +import { CreateWalletTransactionDto } from '../dto/create-wallet-transaction.dto'; +import { PaginatedResult } from 'src/common/interfaces/pagination.interface'; +import { WalletTransactionType } from '../interface/wallet'; + +@Injectable() +export class WalletService { + constructor( + private readonly walletTransactionRepository: WalletTransactionRepository, + private readonly userWalletRepository: UserWalletRepository, + private readonly em: EntityManager, + ) { } + + async getUserWalletTransactions( + userId: string, + restId: string, + dto: FindWalletTransactionsDto, + ): Promise> { + const { + page = 1, + limit = 10, + type, + reason, + startDate, + endDate, + minAmount, + maxAmount, + orderBy = 'createdAt', + order = 'desc', + } = dto; + + // Calculate pagination + const offset = (page - 1) * limit; + + // Find the user's wallet for this restaurant + const userWallet = await this.userWalletRepository.findOne({ + user: { id: userId }, + restaurant: { id: restId }, + }); + + if (!userWallet) { + throw new NotFoundException(`User wallet not found for user ${userId} and restaurant ${restId}.`); + } + + // Build the 'where' filter query + const where: FilterQuery = { + userWallet: { id: userWallet.id }, + }; + + // Add type filter + if (type) { + where.type = type; + } + + // Add reason filter + if (reason) { + where.reason = reason; + } + + // Add date range filters + if (startDate || endDate) { + where.createdAt = {}; + if (startDate) { + where.createdAt.$gte = new Date(startDate); + } + if (endDate) { + where.createdAt.$lte = new Date(endDate); + } + } + + // Add amount range filters + if (minAmount !== undefined || maxAmount !== undefined) { + where.amount = {}; + if (minAmount !== undefined) { + where.amount.$gte = minAmount; + } + if (maxAmount !== undefined) { + where.amount.$lte = maxAmount; + } + } + + // Execute the query using findAndCount + const [transactions, total] = await this.walletTransactionRepository.findAndCount(where, { + limit, + offset, + orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' }, + }); + + // Calculate total pages + const totalPages = Math.ceil(total / limit); + + // Return the paginated result + return { + data: transactions, + meta: { + total, + page, + limit, + totalPages, + }, + }; + } + + getUserWallet(userId: string, restId: string): Promise { + return this.userWalletRepository.findOne({ user: { id: userId }, restaurant: { id: restId } }); + } + + async createTransaction( + userId: string, + restId: string, + dto: CreateWalletTransactionDto, + ): Promise { + const { amount, type, reason } = dto; + + // Validate amount + if (amount <= 0) { + throw new BadRequestException('Transaction amount must be greater than 0.'); + } + + // Find the user's wallet for this restaurant + const userWallet = await this.userWalletRepository.findOne({ + user: { id: userId }, + restaurant: { id: restId }, + }); + + if (!userWallet) { + throw new NotFoundException(`User wallet not found for user ${userId} and restaurant ${restId}.`); + } + + // Calculate new balance + const currentBalance = userWallet.wallet || 0; + let newBalance: number; + + if (type === WalletTransactionType.CREDIT) { + newBalance = currentBalance + amount; + } else if (type === WalletTransactionType.DEBIT) { + if (currentBalance < amount) { + throw new BadRequestException( + `Insufficient wallet balance. Current: ${currentBalance}, Required: ${amount}.`, + ); + } + newBalance = currentBalance - amount; + } else { + throw new BadRequestException(`Invalid transaction type: ${type}`); + } + + // Use transaction to ensure consistency + return await this.em.transactional(async (em) => { + // Update wallet balance + userWallet.wallet = newBalance; + await em.persistAndFlush(userWallet); + + // Create transaction record + const transaction = em.create(WalletTransaction, { + userWallet, + amount, + balance: newBalance, + type, + reason, + }); + + await em.persistAndFlush(transaction); + + return transaction; + }); + } +} diff --git a/src/modules/users/repositories/wallet-transaction.repository.ts b/src/modules/users/repositories/wallet-transaction.repository.ts new file mode 100644 index 0000000..391f20d --- /dev/null +++ b/src/modules/users/repositories/wallet-transaction.repository.ts @@ -0,0 +1,10 @@ +import { Injectable } from '@nestjs/common'; +import { EntityManager, EntityRepository } from '@mikro-orm/postgresql'; +import { WalletTransaction } from '../entities/wallet-transaction.entity'; + +@Injectable() +export class WalletTransactionRepository extends EntityRepository { + constructor(readonly em: EntityManager) { + super(em, WalletTransaction); + } +} diff --git a/src/modules/users/user.module.ts b/src/modules/users/user.module.ts index 03cabec..301fcce 100644 --- a/src/modules/users/user.module.ts +++ b/src/modules/users/user.module.ts @@ -8,12 +8,16 @@ import { JwtModule } from '@nestjs/jwt'; import { UserRepository } from './repositories/user.repository'; import { RestaurantsModule } from '../restaurants/restaurants.module'; import { UserWalletRepository } from './repositories/user-wallet.repository'; +import { WalletTransactionRepository } from './repositories/wallet-transaction.repository'; +import { WalletService } from './providers/wallet.service'; import { UserWallet } from './entities/user-wallet.entity'; +import { WalletTransaction } from './entities/wallet-transaction.entity'; @Module({ - providers: [UserService, UserRepository, UserWalletRepository], + providers: [UserService, WalletService, UserRepository, UserWalletRepository, WalletTransactionRepository], controllers: [UsersController], - imports: [MikroOrmModule.forFeature([User, UserAddress, UserWallet]), JwtModule, RestaurantsModule], - exports: [UserService, UserRepository, UserWalletRepository], + imports: [MikroOrmModule.forFeature([User, UserAddress, UserWallet, + WalletTransaction]), JwtModule, RestaurantsModule], + exports: [UserService, WalletService, UserRepository, UserWalletRepository, WalletTransactionRepository], }) -export class UserModule {} +export class UserModule { }