This commit is contained in:
2025-12-13 12:30:38 +03:30
parent 5d872db4ad
commit 9d9de98c03
13 changed files with 142 additions and 91 deletions
@@ -6,8 +6,7 @@ export interface UserLoginResponse {
firstName: string; firstName: string;
lastName?: string; lastName?: string;
phone: string; phone: string;
wallet: number;
points: number;
isActive?: boolean; isActive?: boolean;
restaurant: { restaurant: {
id: string; id: string;
@@ -23,8 +22,6 @@ export class UserLoginTransformer {
firstName: user.firstName, firstName: user.firstName,
lastName: user.lastName, lastName: user.lastName,
phone: user.phone, phone: user.phone,
wallet: user.wallet,
points: user.points,
isActive: user.isActive, isActive: user.isActive,
restaurant: { restaurant: {
id: restaurant.id, id: restaurant.id,
@@ -61,17 +61,17 @@ export class PaymentsService {
const { amount, method, restaurantDomain, gateway, merchantId, user } = await this.validateOrder(orderId); const { amount, method, restaurantDomain, gateway, merchantId, user } = await this.validateOrder(orderId);
// Handle Wallet payment immediately // Handle Wallet payment immediately
if (method === PaymentMethodEnum.Wallet) { // if (method === PaymentMethodEnum.Wallet) {
// Check wallet balance // // Check wallet balance
if (user.wallet < amount) { // if (user.wallet < amount) {
throw new BadRequestException('Insufficient wallet balance'); // throw new BadRequestException('Insufficient wallet balance');
} // }
// Deduct from wallet and create payment record // // Deduct from wallet and create payment record
const payment = await this.processWalletPayment(orderId, amount, user); // const payment = await this.processWalletPayment(orderId, amount, user);
return { paymentUrl: null }; // No redirect needed for wallet // return { paymentUrl: null }; // No redirect needed for wallet
} // }
const { authority } = await this.createPayment(restaurantDomain, { const { authority } = await this.createPayment(restaurantDomain, {
amount, amount,
@@ -149,13 +149,13 @@ export class PaymentsService {
} }
// Double-check balance // Double-check balance
if (freshUser.wallet < amount) { // if (freshUser.wallet < amount) {
throw new BadRequestException('Insufficient wallet balance'); // throw new BadRequestException('Insufficient wallet balance');
} // }
// Deduct from wallet // Deduct from wallet
freshUser.wallet -= amount; // freshUser.wallet -= amount;
em.persist(freshUser); // em.persist(freshUser);
// Create payment record // Create payment record
const payment = em.create(Payment, { const payment = em.create(Payment, {
@@ -9,7 +9,6 @@ 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 { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard'; import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard';
import { ConvertScoreToWalletDto } from '../dto/convert-score-to-wallet.dto';
@ApiTags('User') @ApiTags('User')
@Controller() @Controller()
@@ -45,11 +44,8 @@ export class UsersController {
}) })
@ApiBody({ type: UpdateUserDto }) @ApiBody({ type: UpdateUserDto })
@Patch('public/user/update') @Patch('public/user/update')
async updateUser( async updateUser(@UserId() userId: string, @RestId() restId: string, @Body() dto: UpdateUserDto) {
@UserId() userId: string, const user = await this.userService.updateUser(userId, restId, dto);
@Body(new ValidationPipe({ transform: true, whitelist: true })) dto: UpdateUserDto,
) {
const user = await this.userService.updateUser(userId, dto);
return user; return user;
} }
@@ -71,6 +67,23 @@ export class UsersController {
return addresses; return addresses;
} }
@UseGuards(AuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get User Wallet' })
@ApiHeader({
name: 'X-Slug',
required: true,
schema: {
type: 'string',
default: 'zhivan',
},
})
@Get('public/user/wallet')
async getUserWallet(@UserId() userId: string, @RestId() restId: string) {
const wallet = await this.userService.getUserWallet(userId, restId);
return wallet;
}
@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' })
@@ -194,14 +207,10 @@ export class UsersController {
default: 'zhivan', default: 'zhivan',
}, },
}) })
@ApiBody({ type: ConvertScoreToWalletDto })
@Post('public/user/convert-score-to-wallet') @Post('public/user/convert-score-to-wallet')
async convertScoreToWallet(@UserId() userId: string, @Body() dto: ConvertScoreToWalletDto, @RestId() restId: string) { async convertScoreToWallet(@UserId() userId: string, @RestId() restId: string) {
const user = await this.userService.convertScoreToWallet(userId, restId, dto); await this.userService.convertScoreToWallet(userId, restId);
return { return {
id: user.id,
wallet: user.wallet,
points: user.points,
message: 'Score converted to wallet successfully', message: 'Score converted to wallet successfully',
}; };
} }
@@ -1,17 +0,0 @@
import { IsNumber, IsOptional, Min } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
/**
* DTO for converting user score (points) to wallet balance.
*/
export class ConvertScoreToWalletDto {
@ApiPropertyOptional({
description: 'Amount of points to convert. If not provided, converts all available points',
example: 100,
minimum: 1,
})
@IsOptional()
@IsNumber()
@Min(1)
points?: number;
}
+1 -21
View File
@@ -1,4 +1,4 @@
import { IsString, IsOptional, IsBoolean, IsNumber } from 'class-validator'; import { IsString, IsOptional, IsBoolean } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiPropertyOptional } from '@nestjs/swagger';
/** /**
@@ -28,28 +28,8 @@ export class UpdateUserDto {
@IsString() @IsString()
marriageDate?: string; marriageDate?: string;
@ApiPropertyOptional({ description: "Referrer's identifier (optional)", example: 'abc123' })
@IsOptional()
@IsString()
referrer?: string;
@ApiPropertyOptional({ description: 'Is the user active?', example: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
@ApiPropertyOptional({ description: 'Gender flag (boolean)', example: true }) @ApiPropertyOptional({ description: 'Gender flag (boolean)', example: true })
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
gender?: boolean; gender?: boolean;
@ApiPropertyOptional({ description: 'Wallet balance (integer)', example: 0 })
@IsOptional()
@IsNumber()
wallet?: number;
@ApiPropertyOptional({ description: 'Reward points (integer)', example: 0 })
@IsOptional()
@IsNumber()
points?: number;
} }
@@ -0,0 +1,22 @@
import { Entity, Property, ManyToOne, Index } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity';
import { Restaurant } from 'src/modules/restaurants/entities/restaurant.entity';
import { User } from './user.entity';
@Entity({ tableName: 'user_wallets' })
@Index({ properties: ['user', 'restaurant'] }) // Composite index for most common query: find wallet by user and restaurant
@Index({ properties: ['user'] }) // Index for queries finding all wallets for a user
@Index({ properties: ['restaurant'] }) // Index for queries finding all wallets for a restaurant
export class UserWallet extends BaseEntity {
@ManyToOne(() => Restaurant)
restaurant!: Restaurant;
@ManyToOne(() => User)
user!: User;
@Property({ default: 0, type: 'int' })
wallet: number = 0;
@Property({ default: 0, type: 'int' })
points: number = 0;
}
+4 -4
View File
@@ -42,11 +42,11 @@ export class User extends BaseEntity {
@Property({ default: true }) @Property({ default: true })
gender?: boolean; gender?: boolean;
@Property({ default: 0, type: 'int' }) // @Property({ default: 0, type: 'int' })
wallet: number = 0; // wallet: number = 0;
@Property({ default: 0, type: 'int' }) // @Property({ default: 0, type: 'int' })
points: number = 0; // points: number = 0;
@OneToMany(() => UserAddress, address => address.user, { @OneToMany(() => UserAddress, address => address.user, {
cascade: [Cascade.ALL], cascade: [Cascade.ALL],
+26 -14
View File
@@ -10,14 +10,16 @@ import { UpdateUserAddressDto } from '../dto/update-user-address.dto';
import { PaginatedResult } from 'src/common/interfaces/pagination.interface'; import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
import { UserRepository } from '../repositories/user.repository'; import { UserRepository } from '../repositories/user.repository';
import { normalizePhone } from '../../utils/phone.util'; import { normalizePhone } from '../../utils/phone.util';
import { ConvertScoreToWalletDto } from '../dto/convert-score-to-wallet.dto';
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 { UserWalletRepository } from '../repositories/user-wallet.repository';
@Injectable() @Injectable()
export class UserService { export class UserService {
constructor( constructor(
private readonly userRepository: UserRepository, private readonly userRepository: UserRepository,
private readonly restaurantRepository: RestRepository, private readonly restaurantRepository: RestRepository,
private readonly userWalletRepository: UserWalletRepository,
private readonly em: EntityManager, private readonly em: EntityManager,
) {} ) {}
@@ -85,7 +87,7 @@ export class UserService {
return user; return user;
} }
async updateUser(userId: string, dto: UpdateUserDto): Promise<User> { async updateUser(userId: string, restId: string, dto: UpdateUserDto): Promise<User> {
const user = await this.userRepository.findOne({ id: userId }); const user = await this.userRepository.findOne({ id: userId });
if (!user) { if (!user) {
throw new NotFoundException(`User with ID ${userId} not found.`); throw new NotFoundException(`User with ID ${userId} not found.`);
@@ -99,7 +101,7 @@ export class UserService {
if (dto.marriageDate) { if (dto.marriageDate) {
assignData.marriageDate = new Date(dto.marriageDate); assignData.marriageDate = new Date(dto.marriageDate);
} }
// get restuarant
this.em.assign(user, assignData); this.em.assign(user, assignData);
await this.em.flush(); await this.em.flush();
@@ -287,27 +289,34 @@ export class UserService {
return address; return address;
} }
async convertScoreToWallet(userId: string, restId: string, dto: ConvertScoreToWalletDto): Promise<User> { async convertScoreToWallet(userId: string, restId: string) {
const user = await this.userRepository.findOne({ id: userId }); const user = await this.userRepository.findOne({ id: userId });
if (!user) { if (!user) {
throw new NotFoundException(`User with ID ${userId} not found.`); throw new NotFoundException(`User with ID ${userId} not found.`);
} }
const restaurant = await this.restaurantRepository.findOne({ id: restId });
if (!restaurant) {
throw new NotFoundException(`Restaurant with ID ${restId} not found.`);
}
let userWallet = await this.userWalletRepository.findOne({ user: { id: userId }, restaurant: { id: restId } });
if (!userWallet) {
userWallet = this.userWalletRepository.create({ user: user, restaurant, wallet: 0, points: 0 });
await this.em.persistAndFlush(userWallet);
}
// Determine how many points to convert // Determine how many points to convert
const pointsToConvert = dto.points ?? user.points; const pointsToConvert = userWallet.points;
// Validate points to convert // Validate points to convert
if (pointsToConvert <= 0) { if (pointsToConvert <= 0) {
throw new BadRequestException('Points to convert must be greater than 0.'); throw new BadRequestException('Points to convert must be greater than 0.');
} }
if (user.points < pointsToConvert) { if (userWallet.points < pointsToConvert) {
throw new BadRequestException(`Insufficient points. Available: ${user.points}, Requested: ${pointsToConvert}.`); throw new BadRequestException(
} `Insufficient points. Available: ${userWallet.points}, Requested: ${pointsToConvert}.`,
const restaurant = await this.restaurantRepository.findOne({ id: restId }); );
if (!restaurant) {
throw new NotFoundException(`Restaurant with ID ${restId} not found.`);
} }
if (!restaurant.score) { if (!restaurant.score) {
throw new BadRequestException('Restaurant score not found.'); throw new BadRequestException('Restaurant score not found.');
} }
@@ -322,11 +331,14 @@ export class UserService {
// Convert points to wallet (1:1 ratio - can be made configurable) // Convert points to wallet (1:1 ratio - can be made configurable)
// Update user's wallet and points // Update user's wallet and points
user.wallet = (user.wallet || 0) + walletIncreaseAmount; userWallet.wallet = (userWallet.wallet || 0) + walletIncreaseAmount;
user.points = user.points - pointsToConvert; userWallet.points = (userWallet.points || 0) - pointsToConvert;
await this.em.flush(); await this.em.flush();
return user; return user;
} }
getUserWallet(userId: string, restId: string): Promise<UserWallet | null> {
return this.userWalletRepository.findOne({ user: { id: userId }, restaurant: { id: restId } });
}
} }
@@ -0,0 +1,10 @@
import { Injectable } from '@nestjs/common';
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
import { UserWallet } from '../entities/user-wallet.entity';
@Injectable()
export class UserWalletRepository extends EntityRepository<UserWallet> {
constructor(readonly em: EntityManager) {
super(em, UserWallet);
}
}
+5 -3
View File
@@ -7,11 +7,13 @@ import { UserAddress } from './entities/user-address.entity';
import { JwtModule } from '@nestjs/jwt'; 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 { UserWallet } from './entities/user-wallet.entity';
@Module({ @Module({
providers: [UserService, UserRepository], providers: [UserService, UserRepository, UserWalletRepository],
controllers: [UsersController], controllers: [UsersController],
imports: [MikroOrmModule.forFeature([User, UserAddress]), JwtModule, RestaurantsModule], imports: [MikroOrmModule.forFeature([User, UserAddress, UserWallet]), JwtModule, RestaurantsModule],
exports: [UserService, UserRepository], exports: [UserService, UserRepository, UserWalletRepository],
}) })
export class UserModule {} export class UserModule {}
+5
View File
@@ -13,6 +13,7 @@ import { AdminsSeeder } from './admins.seeder';
import { UsersSeeder } from './users.seeder'; import { UsersSeeder } from './users.seeder';
import { CouponsSeeder } from './coupons.seeder'; import { CouponsSeeder } from './coupons.seeder';
import { NotificationPreferencesSeeder } from './notification-preferences.seeder'; import { NotificationPreferencesSeeder } from './notification-preferences.seeder';
import { UserWalletsSeeder } from './user-wallets.seeder';
export class DatabaseSeeder extends Seeder { export class DatabaseSeeder extends Seeder {
async run(em: EntityManager) { async run(em: EntityManager) {
@@ -64,5 +65,9 @@ export class DatabaseSeeder extends Seeder {
// 12. Create Users // 12. Create Users
const usersSeeder = new UsersSeeder(); const usersSeeder = new UsersSeeder();
await usersSeeder.run(em); await usersSeeder.run(em);
// 13. Create User Wallets
const userWalletsSeeder = new UserWalletsSeeder();
await userWalletsSeeder.run(em);
} }
} }
+33
View File
@@ -0,0 +1,33 @@
import type { EntityManager } from '@mikro-orm/core';
import { User } from '../modules/users/entities/user.entity';
import { Restaurant } from '../modules/restaurants/entities/restaurant.entity';
import { UserWallet } from '../modules/users/entities/user-wallet.entity';
export class UserWalletsSeeder {
async run(em: EntityManager): Promise<void> {
const users = await em.find(User, {});
const restaurants = await em.find(Restaurant, {});
for (const user of users) {
for (const restaurant of restaurants) {
// Check if wallet already exists for this user-restaurant combination
const existingWallet = await em.findOne(UserWallet, {
user: { id: user.id },
restaurant: { id: restaurant.id },
});
if (!existingWallet) {
const wallet = em.create(UserWallet, {
user,
restaurant,
wallet: 150000,
points: 125,
});
em.persist(wallet);
}
}
}
await em.flush();
}
}
-2
View File
@@ -22,8 +22,6 @@ export class UsersSeeder {
marriageDate: new Date(userData.marriageDate), marriageDate: new Date(userData.marriageDate),
isActive: userData.isActive, isActive: userData.isActive,
gender: userData.gender, gender: userData.gender,
wallet: userData.wallet,
points: userData.points,
}); });
em.persist(user); em.persist(user);
} }