wallet transaction entity added

This commit is contained in:
2025-12-29 09:59:29 +03:30
parent 352bbe2770
commit 89adbfdb33
10 changed files with 431 additions and 144 deletions
+1 -137
View File
@@ -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: ``,
// },
// },
// },
// };
// }
// }
}
@@ -8,6 +8,7 @@ import { UpdateUserAddressDto } from '../dto/update-user-address.dto';
import { UserId } from 'src/common/decorators/user-id.decorator'; import { UserId } from 'src/common/decorators/user-id.decorator';
import { RestId } from 'src/common/decorators'; import { RestId } from 'src/common/decorators';
import { FindUsersDto } from '../dto/find-user.dto'; 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 { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard';
import { API_HEADER_SLUG } from 'src/common/constants/index'; import { API_HEADER_SLUG } from 'src/common/constants/index';
import { UserSuccessMessage } from 'src/common/enums/message.enum'; import { UserSuccessMessage } from 'src/common/enums/message.enum';
@@ -60,6 +61,21 @@ export class UsersController {
return wallet; 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) @UseGuards(AuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: 'Create a new address for the authenticated user' }) @ApiOperation({ summary: 'Create a new address for the authenticated user' })
@@ -142,7 +158,7 @@ export class UsersController {
@ApiBearerAuth() @ApiBearerAuth()
@Permissions(Permission.MANAGE_USERS) @Permissions(Permission.MANAGE_USERS)
@ApiOperation({ summary: 'Get paginated list of restuarant users with filters' }) @ApiOperation({ summary: 'Get paginated list of restuarant users with filters' })
@Get('admin/users') @Get('admin/users')
async findAllRestaurantUsers( async findAllRestaurantUsers(
@Query(new ValidationPipe({ transform: true, whitelist: true })) @Query(new ValidationPipe({ transform: true, whitelist: true }))
query: FindUsersDto, query: FindUsersDto,
@@ -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;
}
@@ -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';
}
@@ -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;
}
+13
View File
@@ -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',
}
+15 -2
View File
@@ -5,6 +5,8 @@ import { UserAddress } from '../entities/user-address.entity';
import { EntityManager } from '@mikro-orm/postgresql'; import { EntityManager } from '@mikro-orm/postgresql';
import { UpdateUserDto } from '../dto/update-user.dto'; import { UpdateUserDto } from '../dto/update-user.dto';
import { FindUsersDto } from '../dto/find-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 { CreateUserAddressDto } from '../dto/create-user-address.dto';
import { UpdateUserAddressDto } from '../dto/update-user-address.dto'; import { UpdateUserAddressDto } from '../dto/update-user-address.dto';
import { PaginatedResult } from 'src/common/interfaces/pagination.interface'; 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 { RestRepository } from 'src/modules/restaurants/repositories/rest.repository';
import { UserWallet } from '../entities/user-wallet.entity'; import { UserWallet } from '../entities/user-wallet.entity';
import { UserWalletRepository } from '../repositories/user-wallet.repository'; import { UserWalletRepository } from '../repositories/user-wallet.repository';
import { WalletService } from './wallet.service';
@Injectable() @Injectable()
export class UserService { export class UserService {
@@ -20,8 +23,9 @@ export class UserService {
private readonly userRepository: UserRepository, private readonly userRepository: UserRepository,
private readonly restaurantRepository: RestRepository, private readonly restaurantRepository: RestRepository,
private readonly userWalletRepository: UserWalletRepository, private readonly userWalletRepository: UserWalletRepository,
private readonly walletService: WalletService,
private readonly em: EntityManager, private readonly em: EntityManager,
) {} ) { }
async findOrCreateByPhone(phone: string): Promise<User> { async findOrCreateByPhone(phone: string): Promise<User> {
const normalizedPhone = normalizePhone(phone); const normalizedPhone = normalizePhone(phone);
@@ -343,6 +347,15 @@ export class UserService {
return user; return user;
} }
getUserWallet(userId: string, restId: string): Promise<UserWallet | null> { getUserWallet(userId: string, restId: string): Promise<UserWallet | null> {
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);
}
} }
@@ -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<PaginatedResult<WalletTransaction>> {
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<WalletTransaction> = {
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<UserWallet | null> {
return this.userWalletRepository.findOne({ user: { id: userId }, restaurant: { id: restId } });
}
async createTransaction(
userId: string,
restId: string,
dto: CreateWalletTransactionDto,
): Promise<WalletTransaction> {
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;
});
}
}
@@ -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<WalletTransaction> {
constructor(readonly em: EntityManager) {
super(em, WalletTransaction);
}
}
+8 -4
View File
@@ -8,12 +8,16 @@ import { JwtModule } from '@nestjs/jwt';
import { UserRepository } from './repositories/user.repository'; import { UserRepository } from './repositories/user.repository';
import { RestaurantsModule } from '../restaurants/restaurants.module'; import { RestaurantsModule } from '../restaurants/restaurants.module';
import { UserWalletRepository } from './repositories/user-wallet.repository'; 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 { UserWallet } from './entities/user-wallet.entity';
import { WalletTransaction } from './entities/wallet-transaction.entity';
@Module({ @Module({
providers: [UserService, UserRepository, UserWalletRepository], providers: [UserService, WalletService, UserRepository, UserWalletRepository, WalletTransactionRepository],
controllers: [UsersController], controllers: [UsersController],
imports: [MikroOrmModule.forFeature([User, UserAddress, UserWallet]), JwtModule, RestaurantsModule], imports: [MikroOrmModule.forFeature([User, UserAddress, UserWallet,
exports: [UserService, UserRepository, UserWalletRepository], WalletTransaction]), JwtModule, RestaurantsModule],
exports: [UserService, WalletService, UserRepository, UserWalletRepository, WalletTransactionRepository],
}) })
export class UserModule {} export class UserModule { }