add : PaymentCode And Customer Card

This commit is contained in:
2026-03-30 15:51:56 +03:00
parent ea179286a4
commit 42de277a1e
33 changed files with 1712 additions and 51 deletions

302
docs/user-addresses-api.md Normal file
View File

@@ -0,0 +1,302 @@
# User Addresses API
Base URL: `/api`
تمام endpointهای این فایل نیاز به احراز هویت دارند:
- Header: `Authorization: Bearer <access_token>`
- Content-Type: `application/json`
همه پاسخ‌ها داخل wrapper استاندارد برمی‌گردند:
```json
{
"success": true,
"statusCode": 200,
"path": "/api/users/me/addresses",
"timestamp": "2026-03-30T07:00:00.000Z",
"data": {}
}
```
## Address Object
```json
{
"id": "7f95d2c5-5ef4-4a7d-94f9-2c7a0dcb3e40",
"title": "خانه",
"recipientName": "علی رضایی",
"phone": "+989121234567",
"province": "فارس",
"city": "شیراز",
"postalCode": "7183914511",
"addressLine": "بلوار معالی آباد، کوچه 12، ساختمان پارس",
"plaque": "24",
"unit": "3",
"isDefault": true,
"createdAt": "2026-03-30T06:50:00.000Z",
"updatedAt": "2026-03-30T06:50:00.000Z"
}
```
## Validation Rules
فیلدهای بدنه برای ساخت آدرس:
- `title`: رشته، اجباری، طول `2..100`
- `recipientName`: رشته، اجباری، طول `2..150`
- `phone`: رشته، اجباری، طول `8..20`
- `province`: رشته، اجباری، طول `2..100`
- `city`: رشته، اجباری، طول `2..100`
- `postalCode`: رشته، اختیاری، فقط رقم، الگوی `^[0-9]{5,20}$`
- `addressLine`: رشته، اجباری، طول `5..500`
- `plaque`: رشته، اختیاری، طول `1..50`
- `unit`: رشته، اختیاری، طول `1..50`
- `isDefault`: بولین، اختیاری
نکات:
- در `PATCH` همه فیلدها اختیاری هستند.
- به خاطر `whitelist + forbidNonWhitelisted` هر فیلد اضافه و ناشناخته با خطای `400` رد می‌شود.
## 1. دریافت لیست آدرس‌ها
`GET /api/users/me/addresses`
Behavior:
- فقط آدرس‌های کاربر لاگین‌شده را برمی‌گرداند.
- ترتیب خروجی: اول آدرس پیش‌فرض، بعد بقیه بر اساس جدیدترین.
Sample response:
```json
{
"success": true,
"statusCode": 200,
"path": "/api/users/me/addresses",
"timestamp": "2026-03-30T07:00:00.000Z",
"data": {
"items": [
{
"id": "7f95d2c5-5ef4-4a7d-94f9-2c7a0dcb3e40",
"title": "خانه",
"recipientName": "علی رضایی",
"phone": "+989121234567",
"province": "فارس",
"city": "شیراز",
"postalCode": "7183914511",
"addressLine": "بلوار معالی آباد، کوچه 12، ساختمان پارس",
"plaque": "24",
"unit": "3",
"isDefault": true,
"createdAt": "2026-03-30T06:50:00.000Z",
"updatedAt": "2026-03-30T06:50:00.000Z"
}
]
}
}
```
## 2. ساخت آدرس جدید
`POST /api/users/me/addresses`
Sample request:
```json
{
"title": "خانه",
"recipientName": "علی رضایی",
"phone": "+989121234567",
"province": "فارس",
"city": "شیراز",
"postalCode": "7183914511",
"addressLine": "بلوار معالی آباد، کوچه 12، ساختمان پارس",
"plaque": "24",
"unit": "3",
"isDefault": true
}
```
Sample response:
```json
{
"success": true,
"statusCode": 201,
"path": "/api/users/me/addresses",
"timestamp": "2026-03-30T07:00:00.000Z",
"data": {
"id": "7f95d2c5-5ef4-4a7d-94f9-2c7a0dcb3e40",
"title": "خانه",
"recipientName": "علی رضایی",
"phone": "+989121234567",
"province": "فارس",
"city": "شیراز",
"postalCode": "7183914511",
"addressLine": "بلوار معالی آباد، کوچه 12، ساختمان پارس",
"plaque": "24",
"unit": "3",
"isDefault": true,
"createdAt": "2026-03-30T06:50:00.000Z",
"updatedAt": "2026-03-30T06:50:00.000Z"
}
}
```
Business rules:
- اگر اولین آدرس کاربر باشد، خودکار `isDefault = true` می‌شود؛ حتی اگر در درخواست نفرستاده شود.
- اگر `isDefault = true` ارسال شود، هر آدرس پیش‌فرض قبلی از حالت پیش‌فرض خارج می‌شود.
## 3. ویرایش آدرس
`PATCH /api/users/me/addresses/:addressId`
Path params:
- `addressId`: شناسه آدرس
Sample request:
```json
{
"title": "محل کار",
"city": "صدرا",
"addressLine": "بلوار دانش، ساختمان فناوری",
"isDefault": true
}
```
Sample response:
```json
{
"success": true,
"statusCode": 200,
"path": "/api/users/me/addresses/7f95d2c5-5ef4-4a7d-94f9-2c7a0dcb3e40",
"timestamp": "2026-03-30T07:00:00.000Z",
"data": {
"id": "7f95d2c5-5ef4-4a7d-94f9-2c7a0dcb3e40",
"title": "محل کار",
"recipientName": "علی رضایی",
"phone": "+989121234567",
"province": "فارس",
"city": "صدرا",
"postalCode": "7183914511",
"addressLine": "بلوار دانش، ساختمان فناوری",
"plaque": "24",
"unit": "3",
"isDefault": true,
"createdAt": "2026-03-30T06:50:00.000Z",
"updatedAt": "2026-03-30T07:00:00.000Z"
}
}
```
Business rules:
- همه فیلدها اختیاری هستند.
- اگر `isDefault = true` باشد، این آدرس پیش‌فرض می‌شود و بقیه از حالت پیش‌فرض خارج می‌شوند.
- اگر `isDefault = false` برای تنها آدرس پیش‌فرض کاربر ارسال شود، درخواست با `400` رد می‌شود.
## 4. تعیین آدرس پیش‌فرض
`PATCH /api/users/me/addresses/:addressId/default`
Path params:
- `addressId`: شناسه آدرس
Request body:
- ندارد
Sample response:
```json
{
"success": true,
"statusCode": 200,
"path": "/api/users/me/addresses/7f95d2c5-5ef4-4a7d-94f9-2c7a0dcb3e40/default",
"timestamp": "2026-03-30T07:00:00.000Z",
"data": {
"id": "7f95d2c5-5ef4-4a7d-94f9-2c7a0dcb3e40",
"title": "خانه",
"recipientName": "علی رضایی",
"phone": "+989121234567",
"province": "فارس",
"city": "شیراز",
"postalCode": "7183914511",
"addressLine": "بلوار معالی آباد، کوچه 12، ساختمان پارس",
"plaque": "24",
"unit": "3",
"isDefault": true,
"createdAt": "2026-03-30T06:50:00.000Z",
"updatedAt": "2026-03-30T07:00:00.000Z"
}
}
```
Business rules:
- همیشه فقط یک آدرس پیش‌فرض برای هر کاربر وجود دارد.
## 5. حذف آدرس
`DELETE /api/users/me/addresses/:addressId`
Path params:
- `addressId`: شناسه آدرس
Sample response:
```json
{
"success": true,
"statusCode": 200,
"path": "/api/users/me/addresses/7f95d2c5-5ef4-4a7d-94f9-2c7a0dcb3e40",
"timestamp": "2026-03-30T07:00:00.000Z",
"data": {
"message": "Address deleted successfully",
"addressId": "7f95d2c5-5ef4-4a7d-94f9-2c7a0dcb3e40"
}
}
```
Business rules:
- اگر آدرس حذف‌شده پیش‌فرض باشد، جدیدترین آدرس باقی‌مانده خودکار پیش‌فرض می‌شود.
- اگر هیچ آدرسی باقی نماند، طبیعتا آدرس پیش‌فرضی هم وجود نخواهد داشت.
## Error Cases
### `401 Unauthorized`
وقتی توکن ارسال نشده باشد یا نامعتبر باشد.
### `404 Not Found`
برای `addressId` نامعتبر یا آدرسی که متعلق به کاربر فعلی نیست.
نمونه پیام:
```json
{
"message": "Address not found",
"error": "Not Found",
"statusCode": 404
}
```
### `400 Bad Request`
برای خطاهای validation یا ruleهای business.
نمونه‌ها:
- `User must have at least one default address`
- خطای فرمت `postalCode`
- ارسال فیلد ناشناخته در body

View File

@@ -9,6 +9,9 @@ import { typeOrmConfigFactory } from './config/typeorm.config';
import { AuthModule } from './modules/auth/auth.module'; import { AuthModule } from './modules/auth/auth.module';
import { AuthOtp } from './modules/auth/entities/auth-otp.entity'; import { AuthOtp } from './modules/auth/entities/auth-otp.entity';
import { UserSession } from './modules/auth/entities/user-session.entity'; import { UserSession } from './modules/auth/entities/user-session.entity';
import { CartModule } from './modules/cart/cart.module';
import { UserCartItem } from './modules/cart/entities/user-cart-item.entity';
import { UserCart } from './modules/cart/entities/user-cart.entity';
import { Category } from './modules/catalog/entities/category.entity'; import { Category } from './modules/catalog/entities/category.entity';
import { AttributeDefinition } from './modules/catalog/entities/attribute-definition.entity'; import { AttributeDefinition } from './modules/catalog/entities/attribute-definition.entity';
import { Brand } from './modules/catalog/entities/brand.entity'; import { Brand } from './modules/catalog/entities/brand.entity';
@@ -19,6 +22,8 @@ import { ProductReview } from './modules/catalog/entities/product-review.entity'
import { RedisCacheModule } from './modules/cache/redis-cache.module'; import { RedisCacheModule } from './modules/cache/redis-cache.module';
import { MediaModule } from './modules/media/media.module'; import { MediaModule } from './modules/media/media.module';
import { MediaAsset } from './modules/media/entities/media-asset.entity'; import { MediaAsset } from './modules/media/entities/media-asset.entity';
import { PaymentMethodSetting } from './modules/payments/entities/payment-method-setting.entity';
import { PaymentsModule } from './modules/payments/payments.module';
import { CatalogModule } from './modules/catalog/catalog.module'; import { CatalogModule } from './modules/catalog/catalog.module';
import { PricingModule } from './modules/pricing/pricing.module'; import { PricingModule } from './modules/pricing/pricing.module';
import { Settings } from './modules/settings/entities/settings.entity'; import { Settings } from './modules/settings/entities/settings.entity';
@@ -68,14 +73,19 @@ import { UsersModule } from './modules/users/users.module';
ProductAttributeValue, ProductAttributeValue,
Settings, Settings,
MediaAsset, MediaAsset,
PaymentMethodSetting,
UserCart,
UserCartItem,
Ticket, Ticket,
TicketMessage, TicketMessage,
]), ]),
RedisCacheModule, RedisCacheModule,
StorageModule, StorageModule,
CartModule,
UsersModule, UsersModule,
SettingsModule, SettingsModule,
PricingModule, PricingModule,
PaymentsModule,
TicketsModule, TicketsModule,
CatalogModule, CatalogModule,
MediaModule, MediaModule,

View File

@@ -2,6 +2,8 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModuleAsyncOptions, TypeOrmModuleOptions } from '@nestjs/typeorm'; import { TypeOrmModuleAsyncOptions, TypeOrmModuleOptions } from '@nestjs/typeorm';
import { AuthOtp } from '../modules/auth/entities/auth-otp.entity'; import { AuthOtp } from '../modules/auth/entities/auth-otp.entity';
import { UserSession } from '../modules/auth/entities/user-session.entity'; import { UserSession } from '../modules/auth/entities/user-session.entity';
import { UserCartItem } from '../modules/cart/entities/user-cart-item.entity';
import { UserCart } from '../modules/cart/entities/user-cart.entity';
import { AttributeDefinition } from '../modules/catalog/entities/attribute-definition.entity'; import { AttributeDefinition } from '../modules/catalog/entities/attribute-definition.entity';
import { Brand } from '../modules/catalog/entities/brand.entity'; import { Brand } from '../modules/catalog/entities/brand.entity';
import { Category } from '../modules/catalog/entities/category.entity'; import { Category } from '../modules/catalog/entities/category.entity';
@@ -11,6 +13,7 @@ import { Product } from '../modules/catalog/entities/product.entity';
import { ProductReview } from '../modules/catalog/entities/product-review.entity'; import { ProductReview } from '../modules/catalog/entities/product-review.entity';
import { Settings } from '../modules/settings/entities/settings.entity'; import { Settings } from '../modules/settings/entities/settings.entity';
import { MediaAsset } from '../modules/media/entities/media-asset.entity'; import { MediaAsset } from '../modules/media/entities/media-asset.entity';
import { PaymentMethodSetting } from '../modules/payments/entities/payment-method-setting.entity';
import { LoyaltyProfile } from '../modules/users/entities/loyalty-profile.entity'; import { LoyaltyProfile } from '../modules/users/entities/loyalty-profile.entity';
import { TicketMessage } from '../modules/tickets/entities/ticket-message.entity'; import { TicketMessage } from '../modules/tickets/entities/ticket-message.entity';
import { Ticket } from '../modules/tickets/entities/ticket.entity'; import { Ticket } from '../modules/tickets/entities/ticket.entity';
@@ -54,6 +57,9 @@ export const buildTypeOrmOptions = (
ProductAttributeValue, ProductAttributeValue,
Settings, Settings,
MediaAsset, MediaAsset,
PaymentMethodSetting,
UserCart,
UserCartItem,
Ticket, Ticket,
TicketMessage, TicketMessage,
], ],

View File

@@ -28,14 +28,14 @@ export class AuthController {
constructor(private readonly authService: AuthService) {} constructor(private readonly authService: AuthService) {}
@Post('otp/request') @Post('otp/request')
@ApiOperation({ summary: 'Request OTP for mobile auth flow' }) @ApiOperation({ summary: 'درخواست کد یکبارمصرف برای ورود یا ثبت‌نام موبایلی' })
@ApiBody({ type: RequestOtpDto }) @ApiBody({ type: RequestOtpDto })
requestOtp(@Body() dto: RequestOtpDto) { requestOtp(@Body() dto: RequestOtpDto) {
return this.authService.requestOtp(dto.phone, dto.fullName); return this.authService.requestOtp(dto.phone, dto.fullName);
} }
@Post('register/password') @Post('register/password')
@ApiOperation({ summary: 'Register with mobile, username, and password' }) @ApiOperation({ summary: 'ثبت‌نام با موبایل، نام کاربری و رمز عبور' })
@ApiBody({ type: RegisterPasswordDto }) @ApiBody({ type: RegisterPasswordDto })
registerWithPassword( registerWithPassword(
@Body() dto: RegisterPasswordDto, @Body() dto: RegisterPasswordDto,
@@ -45,21 +45,21 @@ export class AuthController {
} }
@Post('login/password') @Post('login/password')
@ApiOperation({ summary: 'Login with username and password' }) @ApiOperation({ summary: 'ورود با نام کاربری و رمز عبور' })
@ApiBody({ type: LoginPasswordDto }) @ApiBody({ type: LoginPasswordDto })
loginWithPassword(@Body() dto: LoginPasswordDto, @Req() request: Request) { loginWithPassword(@Body() dto: LoginPasswordDto, @Req() request: Request) {
return this.authService.loginWithPassword(dto, request); return this.authService.loginWithPassword(dto, request);
} }
@Post('otp/verify') @Post('otp/verify')
@ApiOperation({ summary: 'Verify OTP code for mobile auth flow' }) @ApiOperation({ summary: 'تایید کد یکبارمصرف برای ورود موبایلی' })
@ApiBody({ type: VerifyOtpDto }) @ApiBody({ type: VerifyOtpDto })
verifyOtp(@Body() dto: VerifyOtpDto, @Req() request: Request) { verifyOtp(@Body() dto: VerifyOtpDto, @Req() request: Request) {
return this.authService.verifyOtp(dto.phone, dto.otp, request); return this.authService.verifyOtp(dto.phone, dto.otp, request);
} }
@Post('refresh') @Post('refresh')
@ApiOperation({ summary: 'Refresh JWT access token' }) @ApiOperation({ summary: 'دریافت مجدد access token با refresh token' })
@ApiBody({ type: RefreshTokenDto }) @ApiBody({ type: RefreshTokenDto })
refresh(@Body() dto: RefreshTokenDto, @Req() request: Request) { refresh(@Body() dto: RefreshTokenDto, @Req() request: Request) {
return this.authService.refreshToken(dto.refreshToken, request); return this.authService.refreshToken(dto.refreshToken, request);

View File

@@ -256,6 +256,7 @@ export class AuthService {
'brands.manage', 'brands.manage',
'users.manage', 'users.manage',
'media.manage', 'media.manage',
'payments.manage',
'tickets.manage', 'tickets.manage',
]; ];
} }

View File

@@ -0,0 +1,73 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import { Request } from 'express';
import {
ApiBearerAuth,
ApiBody,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { JwtPayload } from '../auth/interfaces/jwt-payload.interface';
import { AddCartItemDto } from './dto/add-cart-item.dto';
import { UpdateCartItemDto } from './dto/update-cart-item.dto';
import { CartService } from './cart.service';
@ApiTags('سبد خرید')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('users/me/cart')
export class CartController {
constructor(private readonly cartService: CartService) {}
@Get()
@ApiOperation({ summary: 'دریافت سبد خرید کاربر جاری' })
getCart(@Req() request: Request & { user: JwtPayload }) {
return this.cartService.getCart(request.user.sub);
}
@Post('items')
@ApiOperation({ summary: 'افزودن محصول به سبد خرید' })
@ApiBody({ type: AddCartItemDto })
addItem(
@Req() request: Request & { user: JwtPayload },
@Body() dto: AddCartItemDto,
) {
return this.cartService.addItem(request.user.sub, dto);
}
@Patch('items/:itemId')
@ApiOperation({ summary: 'ویرایش تعداد یک آیتم در سبد خرید' })
@ApiBody({ type: UpdateCartItemDto })
updateItem(
@Req() request: Request & { user: JwtPayload },
@Param('itemId') itemId: string,
@Body() dto: UpdateCartItemDto,
) {
return this.cartService.updateItem(request.user.sub, itemId, dto);
}
@Delete('items/:itemId')
@ApiOperation({ summary: 'حذف یک آیتم از سبد خرید' })
removeItem(
@Req() request: Request & { user: JwtPayload },
@Param('itemId') itemId: string,
) {
return this.cartService.removeItem(request.user.sub, itemId);
}
@Delete()
@ApiOperation({ summary: 'خالی کردن کامل سبد خرید' })
clearCart(@Req() request: Request & { user: JwtPayload }) {
return this.cartService.clearCart(request.user.sub);
}
}

View File

@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Product } from '../catalog/entities/product.entity';
import { PricingModule } from '../pricing/pricing.module';
import { User } from '../users/entities/user.entity';
import { CartController } from './cart.controller';
import { CartService } from './cart.service';
import { UserCartItem } from './entities/user-cart-item.entity';
import { UserCart } from './entities/user-cart.entity';
@Module({
imports: [
TypeOrmModule.forFeature([UserCart, UserCartItem, User, Product]),
PricingModule,
],
controllers: [CartController],
providers: [CartService],
exports: [CartService],
})
export class CartModule {}

View File

@@ -0,0 +1,256 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { JwtPayload } from '../auth/interfaces/jwt-payload.interface';
import { Product } from '../catalog/entities/product.entity';
import { ProductStatus } from '../catalog/enums/product-status.enum';
import { PricingContext, PricingService } from '../pricing/pricing.service';
import { User } from '../users/entities/user.entity';
import { AddCartItemDto } from './dto/add-cart-item.dto';
import { UpdateCartItemDto } from './dto/update-cart-item.dto';
import { UserCartItem } from './entities/user-cart-item.entity';
import { UserCart } from './entities/user-cart.entity';
@Injectable()
export class CartService {
constructor(
@InjectRepository(UserCart)
private readonly cartsRepository: Repository<UserCart>,
@InjectRepository(UserCartItem)
private readonly cartItemsRepository: Repository<UserCartItem>,
@InjectRepository(User)
private readonly usersRepository: Repository<User>,
@InjectRepository(Product)
private readonly productsRepository: Repository<Product>,
private readonly pricingService: PricingService,
) {}
async getCart(userId: string) {
const [cart, pricingContext] = await Promise.all([
this.getOrCreateCart(userId),
this.pricingService.getPricingContext({ sub: userId } as JwtPayload),
]);
return this.serializeCart(cart, pricingContext);
}
async addItem(userId: string, dto: AddCartItemDto) {
const [cart, product] = await Promise.all([
this.getOrCreateCart(userId),
this.requirePurchasableProduct(dto.productId),
]);
const existingItem = await this.cartItemsRepository.findOne({
where: {
cart: { id: cart.id },
product: { id: product.id },
},
relations: { cart: true, product: true },
});
const nextQuantity = (existingItem?.quantity ?? 0) + dto.quantity;
this.ensureStock(product, nextQuantity);
if (existingItem) {
existingItem.quantity = nextQuantity;
await this.cartItemsRepository.save(existingItem);
} else {
await this.cartItemsRepository.save(
this.cartItemsRepository.create({
cart,
product,
quantity: dto.quantity,
}),
);
}
return this.getCart(userId);
}
async updateItem(userId: string, itemId: string, dto: UpdateCartItemDto) {
const item = await this.requireOwnedCartItem(userId, itemId);
await this.ensureProductStillPurchasable(item.product.id);
this.ensureStock(item.product, dto.quantity);
item.quantity = dto.quantity;
await this.cartItemsRepository.save(item);
return this.getCart(userId);
}
async removeItem(userId: string, itemId: string) {
const item = await this.requireOwnedCartItem(userId, itemId);
await this.cartItemsRepository.remove(item);
return this.getCart(userId);
}
async clearCart(userId: string) {
const cart = await this.getOrCreateCart(userId);
await this.cartItemsRepository.delete({
cart: { id: cart.id },
});
return this.getCart(userId);
}
private async getOrCreateCart(userId: string) {
let cart = await this.cartsRepository.findOne({
where: { user: { id: userId } },
relations: {
user: true,
items: { product: { primaryCategory: true, brandEntity: true } },
},
order: {
items: {
createdAt: 'ASC',
},
},
});
if (cart) {
return cart;
}
const user = await this.usersRepository.findOne({
where: { id: userId },
});
if (!user) {
throw new NotFoundException('User not found');
}
cart = await this.cartsRepository.save(
this.cartsRepository.create({
user,
}),
);
return this.cartsRepository.findOneOrFail({
where: { id: cart.id },
relations: {
user: true,
items: { product: { primaryCategory: true, brandEntity: true } },
},
});
}
private async requirePurchasableProduct(productId: string) {
const product = await this.productsRepository.findOne({
where: { id: productId, status: ProductStatus.PUBLISHED },
relations: {
primaryCategory: true,
brandEntity: true,
},
});
if (!product) {
throw new NotFoundException('Product not found');
}
if (product.stock <= 0) {
throw new BadRequestException('Product is out of stock');
}
return product;
}
private async ensureProductStillPurchasable(productId: string) {
return this.requirePurchasableProduct(productId);
}
private async requireOwnedCartItem(userId: string, itemId: string) {
const item = await this.cartItemsRepository.findOne({
where: {
id: itemId,
cart: { user: { id: userId } },
},
relations: {
cart: { user: true },
product: { primaryCategory: true, brandEntity: true },
},
});
if (!item) {
throw new NotFoundException('Cart item not found');
}
return item;
}
private ensureStock(product: Product, quantity: number) {
if (quantity > product.stock) {
throw new BadRequestException('Requested quantity exceeds available stock');
}
}
private buildDisplayPrice(product: Product, pricingContext: PricingContext) {
const usdPrice = product.salePriceUSD ?? product.basePriceUSD ?? 0;
const irrPrice =
usdPrice *
pricingContext.usdToIrrRate *
(1 - pricingContext.discountPercent / 100);
return pricingContext.currencyUnit === 'TOMAN'
? Math.round(irrPrice / 10)
: Math.round(irrPrice);
}
private serializeCart(cart: UserCart, pricingContext: PricingContext) {
const items = (cart.items ?? []).map((item) => {
const displayPrice = this.buildDisplayPrice(item.product, pricingContext);
const lineTotal = displayPrice * item.quantity;
return {
id: item.id,
quantity: item.quantity,
createdAt: item.createdAt,
updatedAt: item.updatedAt,
unitPrice: displayPrice,
lineTotal,
currency_unit: pricingContext.currencyUnit,
currency_label: pricingContext.currencyLabel,
product: {
id: item.product.id,
title: item.product.title,
slug: item.product.slug,
technicalCode: item.product.technicalCode,
brand: item.product.brand,
brandslug: item.product.brandEntity?.slug ?? null,
stock: item.product.stock,
mainImageUrl: item.product.mainImageUrl ?? null,
primaryCategory: item.product.primaryCategory
? {
id: item.product.primaryCategory.id,
name: item.product.primaryCategory.name,
slug: item.product.primaryCategory.slug,
imageUrl: item.product.primaryCategory.imageUrl,
}
: null,
},
};
});
const itemsCount = items.reduce((sum, item) => sum + item.quantity, 0);
const subtotal = items.reduce((sum, item) => sum + item.lineTotal, 0);
return {
id: cart.id,
itemsCount,
items,
summary: {
itemsCount,
subtotal,
total: subtotal,
currency_unit: pricingContext.currencyUnit,
currency_label: pricingContext.currencyLabel,
},
createdAt: cart.createdAt,
updatedAt: cart.updatedAt,
};
}
}

View File

@@ -0,0 +1,24 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsUUID, Max, Min } from 'class-validator';
export class AddCartItemDto {
@ApiProperty({
example: 'product-uuid',
description: 'شناسه محصولی که باید به سبد خرید اضافه شود.',
})
@IsUUID()
productId: string;
@ApiProperty({
example: 2,
minimum: 1,
maximum: 999,
description: 'تعداد موردنیاز از این محصول.',
})
@Type(() => Number)
@IsInt()
@Min(1)
@Max(999)
quantity: number;
}

View File

@@ -0,0 +1,17 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, Max, Min } from 'class-validator';
export class UpdateCartItemDto {
@ApiProperty({
example: 3,
minimum: 1,
maximum: 999,
description: 'تعداد جدید آیتم سبد خرید.',
})
@Type(() => Number)
@IsInt()
@Min(1)
@Max(999)
quantity: number;
}

View File

@@ -0,0 +1,37 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Product } from '../../catalog/entities/product.entity';
import { UserCart } from './user-cart.entity';
@Entity({ name: 'user_cart_items' })
export class UserCartItem {
@PrimaryGeneratedColumn('uuid')
id: string;
@ManyToOne(() => UserCart, (cart) => cart.items, {
nullable: false,
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'cart_id' })
cart: UserCart;
@ManyToOne(() => Product, { nullable: false, eager: true, onDelete: 'CASCADE' })
@JoinColumn({ name: 'product_id' })
product: Product;
@Column({ type: 'int', default: 1 })
quantity: number;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@@ -0,0 +1,34 @@
import {
CreateDateColumn,
Entity,
Index,
JoinColumn,
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
import { UserCartItem } from './user-cart-item.entity';
@Entity({ name: 'user_carts' })
export class UserCart {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index({ unique: true })
@OneToOne(() => User, { nullable: false, onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
@OneToMany(() => UserCartItem, (item) => item.cart, {
cascade: true,
})
items: UserCartItem[];
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@@ -53,7 +53,7 @@ export class AdminProductsController {
constructor(private readonly productsService: ProductsService) {} constructor(private readonly productsService: ProductsService) {}
@Post() @Post()
@ApiOperation({ summary: 'Create a product for admin panel' }) @ApiOperation({ summary: 'ایجاد محصول در پنل ادمین' })
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@ApiBody({ type: CreateProductDto }) @ApiBody({ type: CreateProductDto })
@UseInterceptors( @UseInterceptors(
@@ -76,7 +76,7 @@ export class AdminProductsController {
} }
@Get() @Get()
@ApiOperation({ summary: 'List all products for admin panel, including drafts' }) @ApiOperation({ summary: 'لیست همه محصولات پنل ادمین به‌همراه پیش‌نویس‌ها' })
findAll( findAll(
@Query() filters: FilterProductsDto, @Query() filters: FilterProductsDto,
@Headers('x-product-type') productType?: string, @Headers('x-product-type') productType?: string,
@@ -85,19 +85,19 @@ export class AdminProductsController {
} }
@Get('check-slug') @Get('check-slug')
@ApiOperation({ summary: 'Check whether a product slug is available for admin create/edit' }) @ApiOperation({ summary: 'بررسی آزاد بودن اسلاگ محصول برای ساخت یا ویرایش' })
checkSlug(@Query() query: CheckProductSlugDto) { checkSlug(@Query() query: CheckProductSlugDto) {
return this.productsService.checkSlugAvailability(query.slug, query.excludeId); return this.productsService.checkSlugAvailability(query.slug, query.excludeId);
} }
@Get('reviews/list') @Get('reviews/list')
@ApiOperation({ summary: 'List product reviews for moderation' }) @ApiOperation({ summary: 'لیست دیدگاه‌های محصولات برای بررسی ادمین' })
findReviews(@Query() filters: FilterProductReviewsDto) { findReviews(@Query() filters: FilterProductReviewsDto) {
return this.productsService.findAdminReviews(filters); return this.productsService.findAdminReviews(filters);
} }
@Patch('reviews/:reviewId') @Patch('reviews/:reviewId')
@ApiOperation({ summary: 'Approve or pin a product review' }) @ApiOperation({ summary: 'تایید یا پین کردن دیدگاه محصول' })
updateReview( updateReview(
@Param('reviewId') reviewId: string, @Param('reviewId') reviewId: string,
@Body() dto: ModerateProductReviewDto, @Body() dto: ModerateProductReviewDto,
@@ -106,31 +106,31 @@ export class AdminProductsController {
} }
@Delete('reviews/:reviewId') @Delete('reviews/:reviewId')
@ApiOperation({ summary: 'Delete a product review' }) @ApiOperation({ summary: 'حذف دیدگاه محصول' })
removeReview(@Param('reviewId') reviewId: string) { removeReview(@Param('reviewId') reviewId: string) {
return this.productsService.removeReview(reviewId); return this.productsService.removeReview(reviewId);
} }
@Get(':id') @Get(':id')
@ApiOperation({ summary: 'Get one product for admin panel' }) @ApiOperation({ summary: 'دریافت جزئیات یک محصول برای پنل ادمین' })
findOne(@Param('id') id: string) { findOne(@Param('id') id: string) {
return this.productsService.findAdminOne(id); return this.productsService.findAdminOne(id);
} }
@Patch(':id/price') @Patch(':id/price')
@ApiOperation({ summary: 'Update base USD price for one product' }) @ApiOperation({ summary: 'ویرایش قیمت پایه دلاری یک محصول' })
updatePrice(@Param('id') id: string, @Body() dto: UpdateProductPriceDto) { updatePrice(@Param('id') id: string, @Body() dto: UpdateProductPriceDto) {
return this.productsService.updateBasePrice(id, dto.basePriceUSD); return this.productsService.updateBasePrice(id, dto.basePriceUSD);
} }
@Patch('prices/bulk') @Patch('prices/bulk')
@ApiOperation({ summary: 'Bulk update base USD prices for multiple products' }) @ApiOperation({ summary: 'ویرایش گروهی قیمت پایه دلاری محصولات' })
bulkUpdatePrices(@Body() dto: BulkUpdateProductPricesDto) { bulkUpdatePrices(@Body() dto: BulkUpdateProductPricesDto) {
return this.productsService.bulkUpdateBasePrices(dto.items); return this.productsService.bulkUpdateBasePrices(dto.items);
} }
@Patch(':id') @Patch(':id')
@ApiOperation({ summary: 'Update a product for admin panel' }) @ApiOperation({ summary: 'ویرایش محصول در پنل ادمین' })
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@ApiBody({ type: UpdateProductDto }) @ApiBody({ type: UpdateProductDto })
@UseInterceptors( @UseInterceptors(
@@ -154,7 +154,7 @@ export class AdminProductsController {
} }
@Delete(':id') @Delete(':id')
@ApiOperation({ summary: 'Delete a product and its assets' }) @ApiOperation({ summary: 'حذف محصول و فایل‌های وابسته آن' })
remove(@Param('id') id: string) { remove(@Param('id') id: string) {
return this.productsService.remove(id); return this.productsService.remove(id);
} }

View File

@@ -12,7 +12,7 @@ export class ProductDetailsController {
constructor(private readonly productsService: ProductsService) {} constructor(private readonly productsService: ProductsService) {}
@Get(':slug') @Get(':slug')
@ApiOperation({ summary: 'Get one published product by slug' }) @ApiOperation({ summary: 'دریافت جزئیات یک محصول منتشرشده بر اساس اسلاگ' })
@ApiHeader({ @ApiHeader({
name: 'x-product-type', name: 'x-product-type',
required: false, required: false,

View File

@@ -30,7 +30,7 @@ export class ProductsController {
constructor(private readonly productsService: ProductsService) {} constructor(private readonly productsService: ProductsService) {}
@Get() @Get()
@ApiOperation({ summary: 'List published products for storefront' }) @ApiOperation({ summary: 'لیست محصولات منتشرشده برای سایت' })
@ApiHeader({ @ApiHeader({
name: 'x-product-type', name: 'x-product-type',
required: false, required: false,
@@ -46,7 +46,7 @@ export class ProductsController {
} }
@Get('brands/:brandSlug') @Get('brands/:brandSlug')
@ApiOperation({ summary: 'List published products by brand slug' }) @ApiOperation({ summary: 'لیست محصولات منتشرشده بر اساس اسلاگ برند' })
@ApiHeader({ @ApiHeader({
name: 'x-product-type', name: 'x-product-type',
required: false, required: false,
@@ -68,7 +68,7 @@ export class ProductsController {
} }
@Get('brands/:brandSlug/filters') @Get('brands/:brandSlug/filters')
@ApiOperation({ summary: 'Get available brand page filters by brand slug' }) @ApiOperation({ summary: 'دریافت فیلترهای صفحه برند بر اساس همان برند' })
@ApiHeader({ @ApiHeader({
name: 'x-product-type', name: 'x-product-type',
required: false, required: false,
@@ -88,7 +88,7 @@ export class ProductsController {
} }
@Get('categories/:categorySlug') @Get('categories/:categorySlug')
@ApiOperation({ summary: 'List published products by category slug' }) @ApiOperation({ summary: 'لیست محصولات منتشرشده بر اساس اسلاگ دسته‌بندی' })
@ApiHeader({ @ApiHeader({
name: 'x-product-type', name: 'x-product-type',
required: false, required: false,
@@ -110,7 +110,7 @@ export class ProductsController {
} }
@Get('categories/:categorySlug/filters') @Get('categories/:categorySlug/filters')
@ApiOperation({ summary: 'Get available category page filters by category slug' }) @ApiOperation({ summary: 'دریافت فیلترهای صفحه دسته‌بندی بر اساس همان دسته' })
@ApiHeader({ @ApiHeader({
name: 'x-product-type', name: 'x-product-type',
required: false, required: false,
@@ -130,20 +130,20 @@ export class ProductsController {
} }
@Get(':id/reviews') @Get(':id/reviews')
@ApiOperation({ summary: 'List approved reviews for a product' }) @ApiOperation({ summary: 'لیست دیدگاه‌های تاییدشده یک محصول' })
findApprovedReviews(@Param('id') id: string) { findApprovedReviews(@Param('id') id: string) {
return this.productsService.findApprovedReviews(id); return this.productsService.findApprovedReviews(id);
} }
@Post(':id/reviews') @Post(':id/reviews')
@ApiOperation({ summary: 'Submit a new product review' }) @ApiOperation({ summary: 'ثبت دیدگاه جدید برای محصول' })
@ApiBody({ type: CreateProductReviewDto }) @ApiBody({ type: CreateProductReviewDto })
createReview(@Param('id') id: string, @Body() dto: CreateProductReviewDto) { createReview(@Param('id') id: string, @Body() dto: CreateProductReviewDto) {
return this.productsService.createReview(id, dto); return this.productsService.createReview(id, dto);
} }
@Get(':id') @Get(':id')
@ApiOperation({ summary: 'Get one published product with approved reviews summary' }) @ApiOperation({ summary: 'دریافت جزئیات یک محصول منتشرشده' })
@ApiHeader({ @ApiHeader({
name: 'x-product-type', name: 'x-product-type',
required: false, required: false,

View File

@@ -118,7 +118,8 @@ export class ProductsService {
); );
await this.invalidatePriceCache(); await this.invalidatePriceCache();
return this.serializeProduct(await this.findOneById(product.id)); const pricingContext = await this.pricingService.getPricingContext(null);
return this.serializeProduct(await this.findOneById(product.id), pricingContext);
} }
async findPublic( async findPublic(
@@ -244,7 +245,10 @@ export class ProductsService {
} }
async findAdminOne(id: string) { async findAdminOne(id: string) {
return this.serializeProduct(await this.findOneById(id)); const product = await this.findOneById(id);
const pricingContext = await this.pricingService.getPricingContext(null);
return this.serializeProduct(product, pricingContext);
} }
async createReview(productId: string, dto: CreateProductReviewDto) { async createReview(productId: string, dto: CreateProductReviewDto) {
@@ -444,7 +448,8 @@ export class ProductsService {
} }
await this.invalidatePriceCache(); await this.invalidatePriceCache();
return this.serializeProduct(await this.findOneById(product.id)); const pricingContext = await this.pricingService.getPricingContext(null);
return this.serializeProduct(await this.findOneById(product.id), pricingContext);
} }
async remove(id: string) { async remove(id: string) {
@@ -563,6 +568,7 @@ export class ProductsService {
const page = filters.page ?? 1; const page = filters.page ?? 1;
const limit = filters.limit ?? 20; const limit = filters.limit ?? 20;
const pricingContext = await this.pricingService.getPricingContext(null);
const query = this.createFilteredProductsQuery(filters, includeUnpublished) const query = this.createFilteredProductsQuery(filters, includeUnpublished)
.skip((page - 1) * limit) .skip((page - 1) * limit)
@@ -571,7 +577,9 @@ export class ProductsService {
const [items, total] = await query.getManyAndCount(); const [items, total] = await query.getManyAndCount();
return { return {
items: items.map((item) => this.serializeAdminProductListItem(item)), items: items.map((item) =>
this.serializeAdminProductListItem(item, pricingContext),
),
meta: { meta: {
total, total,
page, page,
@@ -1290,7 +1298,11 @@ export class ProductsService {
); );
} }
private serializeProduct(product: Product, includeMeta = true) { private serializeProduct(
product: Product,
pricingContext?: PricingContext,
includeMeta = true,
) {
const orderedAttributes = this.sortAttributes(product.attributeValues); const orderedAttributes = this.sortAttributes(product.attributeValues);
const meta = product.meta ?? null; const meta = product.meta ?? null;
const shortDescription = meta?.shortDescription ?? null; const shortDescription = meta?.shortDescription ?? null;
@@ -1305,9 +1317,13 @@ export class ProductsService {
description: meta?.metaDescription ?? shortDescription, description: meta?.metaDescription ?? shortDescription,
} }
: undefined; : undefined;
const pricingInfo = pricingContext
? this.buildDisplayPricing(product, pricingContext)
: null;
return { return {
...product, ...product,
...(pricingInfo ?? {}),
brandInfo: product.brandEntity brandInfo: product.brandEntity
? { ? {
id: product.brandEntity.id, id: product.brandEntity.id,
@@ -1384,7 +1400,9 @@ export class ProductsService {
brand: product.brand, brand: product.brand,
brandslug: product.brandEntity?.slug ?? null, brandslug: product.brandEntity?.slug ?? null,
calculated_price: calculatedPrice, calculated_price: calculatedPrice,
display_price: calculatedPrice,
currency_unit: pricingContext.currencyUnit, currency_unit: pricingContext.currencyUnit,
currency_label: pricingContext.currencyLabel,
stock: product.stock, stock: product.stock,
averageRating: product.averageRating, averageRating: product.averageRating,
reviewsCount: product.reviewsCount, reviewsCount: product.reviewsCount,
@@ -1454,7 +1472,9 @@ export class ProductsService {
} }
: null, : null,
calculated_price: calculatedPrice, calculated_price: calculatedPrice,
display_price: calculatedPrice,
currency_unit: pricingContext.currencyUnit, currency_unit: pricingContext.currencyUnit,
currency_label: pricingContext.currencyLabel,
stock: product.stock, stock: product.stock,
featured: product.featured, featured: product.featured,
type: product.type, type: product.type,
@@ -1512,11 +1532,15 @@ export class ProductsService {
}; };
} }
private serializeAdminProductListItem(product: Product) { private serializeAdminProductListItem(
product: Product,
pricingContext: PricingContext,
) {
const meta = product.meta ?? null; const meta = product.meta ?? null;
const orderedAttributes = this.sortAttributes(product.attributeValues).filter( const orderedAttributes = this.sortAttributes(product.attributeValues).filter(
(item) => item.attribute?.isVisible ?? true, (item) => item.attribute?.isVisible ?? true,
); );
const pricingInfo = this.buildDisplayPricing(product, pricingContext);
return { return {
id: product.id, id: product.id,
@@ -1538,6 +1562,7 @@ export class ProductsService {
featured: product.featured, featured: product.featured,
basePriceUSD: product.basePriceUSD, basePriceUSD: product.basePriceUSD,
salePriceUSD: product.salePriceUSD ?? null, salePriceUSD: product.salePriceUSD ?? null,
...pricingInfo,
stock: product.stock, stock: product.stock,
averageRating: product.averageRating, averageRating: product.averageRating,
reviewsCount: product.reviewsCount, reviewsCount: product.reviewsCount,
@@ -1577,6 +1602,25 @@ export class ProductsService {
}; };
} }
private buildDisplayPricing(product: Product, pricingContext: PricingContext) {
const usdPrice = product.salePriceUSD ?? product.basePriceUSD ?? 0;
const irrPrice =
usdPrice *
pricingContext.usdToIrrRate *
(1 - pricingContext.discountPercent / 100);
const normalizedPrice =
pricingContext.currencyUnit === 'TOMAN'
? Math.round(irrPrice / 10)
: Math.round(irrPrice);
return {
calculated_price: normalizedPrice,
display_price: normalizedPrice,
currency_unit: pricingContext.currencyUnit,
currency_label: pricingContext.currencyLabel,
};
}
private sortAttributes(attributes?: ProductAttributeValue[]) { private sortAttributes(attributes?: ProductAttributeValue[]) {
return [...(attributes ?? [])].sort((left, right) => { return [...(attributes ?? [])].sort((left, right) => {
const orderDiff = (left.displayOrder ?? 0) - (right.displayOrder ?? 0); const orderDiff = (left.displayOrder ?? 0) - (right.displayOrder ?? 0);

View File

@@ -0,0 +1,70 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Req,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiBody,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { Request } from 'express';
import { Permissions } from '../../common/decorators/permissions.decorator';
import { Roles } from '../../common/decorators/roles.decorator';
import { PermissionsGuard } from '../../common/guards/permissions.guard';
import { RolesGuard } from '../../common/guards/roles.guard';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { JwtPayload } from '../auth/interfaces/jwt-payload.interface';
import { UserRole } from '../users/enums/user-role.enum';
import { ReviewBankSlipDto } from './dto/review-bank-slip.dto';
import { UpdatePaymentMethodSettingDto } from './dto/update-payment-method-setting.dto';
import { PaymentMethodCode } from './enums/payment-method-code.enum';
import { PaymentsService } from './payments.service';
@ApiTags('Admin Payments')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard, PermissionsGuard)
@Roles(UserRole.ADMIN)
@Permissions('payments.manage')
@Controller('admin/payments')
export class AdminPaymentsController {
constructor(private readonly paymentsService: PaymentsService) {}
@Get('methods')
@ApiOperation({ summary: 'لیست همه روش‌های پرداخت برای تنظیمات ادمین' })
findPaymentMethods() {
return this.paymentsService.getAdminPaymentMethods();
}
@Patch('methods/:code')
@ApiOperation({ summary: 'ویرایش تنظیمات یک روش پرداخت' })
@ApiBody({ type: UpdatePaymentMethodSettingDto })
updatePaymentMethod(
@Param('code') code: PaymentMethodCode,
@Body() dto: UpdatePaymentMethodSettingDto,
) {
return this.paymentsService.updatePaymentMethod(code, dto);
}
@Get('bank-slip/orders')
@ApiOperation({ summary: 'لیست سفارش‌های منتظر بررسی فیش بانکی' })
findPendingBankSlipOrders() {
return this.paymentsService.listPendingBankSlipOrders();
}
@Patch('bank-slip/orders/:orderId/review')
@ApiOperation({ summary: 'تایید یا رد یک فیش بانکی' })
@ApiBody({ type: ReviewBankSlipDto })
reviewBankSlip(
@Req() request: Request & { user: JwtPayload },
@Param('orderId') orderId: string,
@Body() dto: ReviewBankSlipDto,
) {
return this.paymentsService.reviewBankSlip(request.user.sub, orderId, dto);
}
}

View File

@@ -0,0 +1,14 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsOptional, IsString, MaxLength } from 'class-validator';
export class ReviewBankSlipDto {
@ApiProperty()
@IsBoolean()
approved: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(500)
adminNote?: string;
}

View File

@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum } from 'class-validator';
import { PaymentMethodCode } from '../enums/payment-method-code.enum';
export class SelectOrderPaymentMethodDto {
@ApiProperty({ enum: PaymentMethodCode })
@IsEnum(PaymentMethodCode)
methodCode: PaymentMethodCode;
}

View File

@@ -0,0 +1,19 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
export class SubmitBankSlipDto {
@ApiProperty({
example: '556677',
description: 'Bank receipt tracking/reference number submitted by user.',
})
@IsString()
@IsNotEmpty()
@MaxLength(100)
trackingNumber: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(500)
notes?: string;
}

View File

@@ -0,0 +1,45 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsInt, IsObject, IsOptional, IsString, MaxLength, Min } from 'class-validator';
export class UpdatePaymentMethodSettingDto {
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
isEnabled?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
isSandboxEnabled?: boolean;
@ApiPropertyOptional({ example: 1 })
@IsOptional()
@IsInt()
@Min(0)
displayOrder?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(120)
title?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(500)
description?: string | null;
@ApiPropertyOptional()
@IsOptional()
@IsString()
instructions?: string | null;
@ApiPropertyOptional({
description: 'Gateway-specific config. Example: terminalId, merchantId, callbackUrl.',
type: Object,
})
@IsOptional()
@IsObject()
config?: Record<string, unknown>;
}

View File

@@ -0,0 +1,55 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { PaymentMethodCode } from '../enums/payment-method-code.enum';
import { PaymentMethodType } from '../enums/payment-method-type.enum';
@Entity({ name: 'payment_method_settings' })
export class PaymentMethodSetting {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({
type: 'enum',
enum: PaymentMethodCode,
unique: true,
})
code: PaymentMethodCode;
@Column({
type: 'enum',
enum: PaymentMethodType,
})
type: PaymentMethodType;
@Column({ type: 'varchar', length: 120 })
title: string;
@Column({ name: 'is_enabled', type: 'boolean', default: false })
isEnabled: boolean;
@Column({ name: 'is_sandbox_enabled', type: 'boolean', default: false })
isSandboxEnabled: boolean;
@Column({ name: 'display_order', type: 'int', default: 0 })
displayOrder: number;
@Column({ type: 'varchar', length: 500, nullable: true })
description?: string | null;
@Column({ type: 'text', nullable: true })
instructions?: string | null;
@Column({ type: 'jsonb', default: {} })
config: Record<string, unknown>;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@@ -0,0 +1,8 @@
export enum PaymentMethodCode {
ZARINPAL = 'zarinpal',
SAMAN = 'saman',
MELLAT = 'mellat',
PASARGAD = 'pasargad',
BANK_SLIP = 'bank_slip',
CASH_ON_DELIVERY = 'cash_on_delivery',
}

View File

@@ -0,0 +1,5 @@
export enum PaymentMethodType {
ONLINE = 'online',
BANK_SLIP = 'bank_slip',
CASH_ON_DELIVERY = 'cash_on_delivery',
}

View File

@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { StorageModule } from '../storage/storage.module';
import { User } from '../users/entities/user.entity';
import { UserOrder } from '../users/entities/user-order.entity';
import { AdminPaymentsController } from './admin-payments.controller';
import { PaymentMethodSetting } from './entities/payment-method-setting.entity';
import { PaymentsService } from './payments.service';
import { PublicPaymentsController } from './public-payments.controller';
@Module({
imports: [
TypeOrmModule.forFeature([PaymentMethodSetting, UserOrder, User]),
StorageModule,
],
controllers: [PublicPaymentsController, AdminPaymentsController],
providers: [PaymentsService],
exports: [PaymentsService],
})
export class PaymentsModule {}

View File

@@ -0,0 +1,451 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { StorageService } from '../storage/storage.service';
import { User } from '../users/entities/user.entity';
import { UserOrder } from '../users/entities/user-order.entity';
import { OrderStatus } from '../users/enums/order-status.enum';
import { PaymentStatus } from '../users/enums/payment-status.enum';
import { UserRole } from '../users/enums/user-role.enum';
import { PaymentMethodSetting } from './entities/payment-method-setting.entity';
import { PaymentMethodCode } from './enums/payment-method-code.enum';
import { PaymentMethodType } from './enums/payment-method-type.enum';
import { ReviewBankSlipDto } from './dto/review-bank-slip.dto';
import { SelectOrderPaymentMethodDto } from './dto/select-order-payment-method.dto';
import { SubmitBankSlipDto } from './dto/submit-bank-slip.dto';
import { UpdatePaymentMethodSettingDto } from './dto/update-payment-method-setting.dto';
@Injectable()
export class PaymentsService {
private static readonly DEFAULT_METHODS: Array<
Pick<
PaymentMethodSetting,
| 'code'
| 'type'
| 'title'
| 'isEnabled'
| 'isSandboxEnabled'
| 'displayOrder'
| 'description'
| 'instructions'
| 'config'
>
> = [
{
code: PaymentMethodCode.ZARINPAL,
type: PaymentMethodType.ONLINE,
title: 'زرین‌پال',
isEnabled: false,
isSandboxEnabled: true,
displayOrder: 1,
description: 'پرداخت آنلاین از طریق زرین‌پال',
instructions: null,
config: {},
},
{
code: PaymentMethodCode.SAMAN,
type: PaymentMethodType.ONLINE,
title: 'سامان',
isEnabled: false,
isSandboxEnabled: true,
displayOrder: 2,
description: 'پرداخت آنلاین از طریق بانک سامان',
instructions: null,
config: {},
},
{
code: PaymentMethodCode.MELLAT,
type: PaymentMethodType.ONLINE,
title: 'ملت',
isEnabled: false,
isSandboxEnabled: true,
displayOrder: 3,
description: 'پرداخت آنلاین از طریق بانک ملت',
instructions: null,
config: {},
},
{
code: PaymentMethodCode.PASARGAD,
type: PaymentMethodType.ONLINE,
title: 'پاسارگاد',
isEnabled: false,
isSandboxEnabled: true,
displayOrder: 4,
description: 'پرداخت آنلاین از طریق بانک پاسارگاد',
instructions: null,
config: {},
},
{
code: PaymentMethodCode.BANK_SLIP,
type: PaymentMethodType.BANK_SLIP,
title: 'ثبت فیش بانکی',
isEnabled: true,
isSandboxEnabled: false,
displayOrder: 5,
description: 'ثبت فیش بانکی و بررسی توسط ادمین',
instructions:
'پس از واریز، تصویر فیش و شماره پیگیری را ثبت کنید. سفارش در وضعیت منتظر تایید پرداخت قرار می‌گیرد.',
config: {},
},
{
code: PaymentMethodCode.CASH_ON_DELIVERY,
type: PaymentMethodType.CASH_ON_DELIVERY,
title: 'پرداخت پس از تحویل',
isEnabled: true,
isSandboxEnabled: false,
displayOrder: 6,
description: 'پرداخت هنگام تحویل کالا',
instructions: 'سفارش ثبت می‌شود و پرداخت هنگام تحویل انجام خواهد شد.',
config: {},
},
];
constructor(
@InjectRepository(PaymentMethodSetting)
private readonly paymentMethodSettingsRepository: Repository<PaymentMethodSetting>,
@InjectRepository(UserOrder)
private readonly userOrdersRepository: Repository<UserOrder>,
@InjectRepository(User)
private readonly usersRepository: Repository<User>,
private readonly storageService: StorageService,
) {}
async getAdminPaymentMethods() {
return this.ensureDefaultPaymentMethods();
}
async updatePaymentMethod(
code: PaymentMethodCode,
dto: UpdatePaymentMethodSettingDto,
) {
const method = await this.requirePaymentMethod(code);
Object.assign(method, {
...(dto.isEnabled !== undefined ? { isEnabled: dto.isEnabled } : {}),
...(dto.isSandboxEnabled !== undefined
? { isSandboxEnabled: dto.isSandboxEnabled }
: {}),
...(dto.displayOrder !== undefined ? { displayOrder: dto.displayOrder } : {}),
...(dto.title !== undefined ? { title: dto.title } : {}),
...(dto.description !== undefined ? { description: dto.description } : {}),
...(dto.instructions !== undefined ? { instructions: dto.instructions } : {}),
...(dto.config !== undefined
? { config: { ...(method.config ?? {}), ...dto.config } }
: {}),
});
return this.paymentMethodSettingsRepository.save(method);
}
async getPublicPaymentMethods() {
const methods = await this.ensureDefaultPaymentMethods();
return methods
.filter((method) => method.isEnabled)
.map((method) => this.serializePaymentMethod(method));
}
async selectOrderPaymentMethod(
userId: string,
orderId: string,
dto: SelectOrderPaymentMethodDto,
) {
const [order, method] = await Promise.all([
this.requireOwnedOrder(userId, orderId),
this.requirePaymentMethod(dto.methodCode),
]);
if (!method.isEnabled) {
throw new BadRequestException('Selected payment method is not enabled');
}
order.paymentMethod = method.code;
order.paymentGateway =
method.type === PaymentMethodType.ONLINE ? method.code : null;
if (method.type === PaymentMethodType.ONLINE) {
order.paymentStatus = PaymentStatus.PENDING;
order.status = OrderStatus.PENDING_PAYMENT;
order.paymentMetadata = {
...(order.paymentMetadata ?? {}),
initiationType: 'redirect',
sandboxEnabled: method.isSandboxEnabled,
initiatedAt: new Date().toISOString(),
};
const savedOrder = await this.userOrdersRepository.save(order);
return {
order: this.mapOrderPayment(savedOrder),
action: {
type: 'redirect',
paymentUrl: this.buildMockGatewayUrl(savedOrder, method),
sandboxEnabled: method.isSandboxEnabled,
gateway: method.code,
},
};
}
if (method.type === PaymentMethodType.BANK_SLIP) {
order.paymentStatus = PaymentStatus.PENDING;
order.status = OrderStatus.PENDING_PAYMENT;
order.paymentMetadata = {
...(order.paymentMetadata ?? {}),
bankSlipAwaitingUpload: true,
};
const savedOrder = await this.userOrdersRepository.save(order);
return {
order: this.mapOrderPayment(savedOrder),
action: {
type: 'upload_bank_slip',
instructions: method.instructions,
},
};
}
order.paymentStatus = PaymentStatus.PENDING;
order.status = OrderStatus.PROCESSING;
order.placedAt = order.placedAt ?? new Date();
order.paymentMetadata = {
...(order.paymentMetadata ?? {}),
codSelectedAt: new Date().toISOString(),
};
const savedOrder = await this.userOrdersRepository.save(order);
return {
order: this.mapOrderPayment(savedOrder),
action: {
type: 'cod_confirmed',
message: 'Order placed successfully with cash on delivery',
},
};
}
async submitBankSlip(
userId: string,
orderId: string,
dto: SubmitBankSlipDto,
file?: Express.Multer.File,
) {
const [order, method] = await Promise.all([
this.requireOwnedOrder(userId, orderId),
this.requirePaymentMethod(PaymentMethodCode.BANK_SLIP),
]);
if (!method.isEnabled) {
throw new BadRequestException('Bank slip payment method is not enabled');
}
if (file) {
const upload = await this.storageService.uploadPublicFile(file, 'payments/bank-slips');
if (order.bankSlipImageUrl && order.bankSlipImageUrl !== upload.url) {
await this.storageService.deletePublicFileByUrl(order.bankSlipImageUrl);
}
order.bankSlipImageUrl = upload.url;
}
order.paymentMethod = PaymentMethodCode.BANK_SLIP;
order.paymentGateway = null;
order.bankSlipTrackingNumber = dto.trackingNumber;
order.bankSlipSubmittedAt = new Date();
order.paymentStatus = PaymentStatus.PENDING;
order.status = OrderStatus.PENDING_PAYMENT;
order.paymentMetadata = {
...(order.paymentMetadata ?? {}),
bankSlipNotes: dto.notes ?? null,
bankSlipAwaitingReview: true,
};
const savedOrder = await this.userOrdersRepository.save(order);
return {
message: 'Bank slip submitted successfully and is waiting for admin approval',
order: this.mapOrderPayment(savedOrder),
};
}
async listPendingBankSlipOrders() {
const items = await this.userOrdersRepository.find({
where: {
paymentMethod: PaymentMethodCode.BANK_SLIP,
paymentStatus: PaymentStatus.PENDING,
},
relations: { user: true, items: true },
order: { bankSlipSubmittedAt: 'DESC', createdAt: 'DESC' },
});
return {
items: items.map((order) => ({
...this.mapOrderPayment(order),
user: {
id: order.user.id,
fullName: order.user.fullName,
phone: order.user.phone,
username: order.user.username,
},
})),
};
}
async reviewBankSlip(
adminUserId: string,
orderId: string,
dto: ReviewBankSlipDto,
) {
const [order, adminUser] = await Promise.all([
this.requireOrder(orderId),
this.requireAdmin(adminUserId),
]);
if (order.paymentMethod !== PaymentMethodCode.BANK_SLIP) {
throw new BadRequestException('Order payment method is not bank slip');
}
order.paymentReviewedByAdminId = adminUser.id;
order.paymentReviewedAt = new Date();
order.paymentMetadata = {
...(order.paymentMetadata ?? {}),
bankSlipAwaitingReview: false,
bankSlipAdminNote: dto.adminNote ?? null,
};
if (dto.approved) {
order.paymentStatus = PaymentStatus.PAID;
order.status = OrderStatus.PROCESSING;
order.paymentVerifiedAt = new Date();
order.placedAt = order.placedAt ?? new Date();
} else {
order.paymentStatus = PaymentStatus.FAILED;
order.status = OrderStatus.PENDING_PAYMENT;
}
const savedOrder = await this.userOrdersRepository.save(order);
return {
message: dto.approved
? 'Bank slip approved successfully'
: 'Bank slip rejected successfully',
order: this.mapOrderPayment(savedOrder),
};
}
private async ensureDefaultPaymentMethods() {
const existing = await this.paymentMethodSettingsRepository.find({
order: { displayOrder: 'ASC', createdAt: 'ASC' },
});
const missing = PaymentsService.DEFAULT_METHODS.filter(
(defaultMethod) =>
!existing.some((existingMethod) => existingMethod.code === defaultMethod.code),
);
if (missing.length > 0) {
await this.paymentMethodSettingsRepository.save(
missing.map((item) => this.paymentMethodSettingsRepository.create(item)),
);
}
return this.paymentMethodSettingsRepository.find({
order: { displayOrder: 'ASC', createdAt: 'ASC' },
});
}
private async requirePaymentMethod(code: PaymentMethodCode) {
await this.ensureDefaultPaymentMethods();
const method = await this.paymentMethodSettingsRepository.findOne({
where: { code },
});
if (!method) {
throw new NotFoundException('Payment method not found');
}
return method;
}
private async requireOwnedOrder(userId: string, orderId: string) {
const order = await this.userOrdersRepository.findOne({
where: {
id: orderId,
user: { id: userId },
},
relations: { user: true, items: true },
});
if (!order) {
throw new NotFoundException('Order not found');
}
return order;
}
private async requireOrder(orderId: string) {
const order = await this.userOrdersRepository.findOne({
where: { id: orderId },
relations: { user: true, items: true },
});
if (!order) {
throw new NotFoundException('Order not found');
}
return order;
}
private async requireAdmin(adminUserId: string) {
const admin = await this.usersRepository.findOne({
where: { id: adminUserId, role: UserRole.ADMIN },
});
if (!admin) {
throw new NotFoundException('Admin user not found');
}
return admin;
}
private serializePaymentMethod(method: PaymentMethodSetting) {
return {
id: method.id,
code: method.code,
type: method.type,
title: method.title,
description: method.description,
instructions: method.instructions,
isSandboxEnabled: method.isSandboxEnabled,
displayOrder: method.displayOrder,
};
}
private mapOrderPayment(order: UserOrder) {
return {
id: order.id,
orderNumber: order.orderNumber,
status: order.status,
paymentStatus: order.paymentStatus,
paymentMethod: order.paymentMethod ?? null,
paymentGateway: order.paymentGateway ?? null,
totalAmount: order.totalAmount,
currency: order.currency,
bankSlipTrackingNumber: order.bankSlipTrackingNumber ?? null,
bankSlipImageUrl: order.bankSlipImageUrl ?? null,
bankSlipSubmittedAt: order.bankSlipSubmittedAt ?? null,
paymentVerifiedAt: order.paymentVerifiedAt ?? null,
paymentReviewedAt: order.paymentReviewedAt ?? null,
paymentMetadata: order.paymentMetadata ?? {},
updatedAt: order.updatedAt,
};
}
private buildMockGatewayUrl(
order: UserOrder,
method: PaymentMethodSetting,
) {
const mode = method.isSandboxEnabled ? 'sandbox' : 'production';
return `https://payments.mock.local/${method.code}/${mode}/pay?orderId=${order.id}`;
}
}

View File

@@ -0,0 +1,88 @@
import {
Body,
Controller,
Get,
Param,
Post,
Req,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import {
ApiBearerAuth,
ApiBody,
ApiConsumes,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { Request } from 'express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { JwtPayload } from '../auth/interfaces/jwt-payload.interface';
import { SelectOrderPaymentMethodDto } from './dto/select-order-payment-method.dto';
import { SubmitBankSlipDto } from './dto/submit-bank-slip.dto';
import { PaymentsService } from './payments.service';
@ApiTags('Payments')
@Controller('payments')
export class PublicPaymentsController {
constructor(private readonly paymentsService: PaymentsService) {}
@Get('methods')
@ApiOperation({ summary: 'لیست روش‌های پرداخت فعال برای checkout' })
findMethods() {
return this.paymentsService.getPublicPaymentMethods();
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Post('orders/:orderId/select-method')
@ApiOperation({ summary: 'انتخاب روش پرداخت برای یک سفارش کاربر' })
@ApiBody({ type: SelectOrderPaymentMethodDto })
selectMethod(
@Req() request: Request & { user: JwtPayload },
@Param('orderId') orderId: string,
@Body() dto: SelectOrderPaymentMethodDto,
) {
return this.paymentsService.selectOrderPaymentMethod(
request.user.sub,
orderId,
dto,
);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Post('orders/:orderId/bank-slip')
@ApiOperation({ summary: 'ثبت فیش بانکی برای یک سفارش کاربر' })
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
trackingNumber: { type: 'string', example: '556677' },
notes: { type: 'string', example: 'واریز از اپلیکیشن بانک انجام شد.' },
receiptImage: {
type: 'string',
format: 'binary',
},
},
required: ['trackingNumber'],
},
})
@UseInterceptors(FileInterceptor('receiptImage'))
submitBankSlip(
@Req() request: Request & { user: JwtPayload },
@Param('orderId') orderId: string,
@Body() dto: SubmitBankSlipDto,
@UploadedFile() receiptImage?: Express.Multer.File,
) {
return this.paymentsService.submitBankSlip(
request.user.sub,
orderId,
dto,
receiptImage,
);
}
}

View File

@@ -14,6 +14,7 @@ export interface PricingContext {
userLevel: UserLevel | 'Guest'; userLevel: UserLevel | 'Guest';
discountPercent: number; discountPercent: number;
currencyUnit: CurrencyDisplay; currencyUnit: CurrencyDisplay;
currencyLabel: 'ریال' | 'تومان';
} }
@Injectable() @Injectable()
@@ -43,6 +44,10 @@ export class PricingService {
userLevel, userLevel,
discountPercent, discountPercent,
currencyUnit: settings.defaultCurrencyDisplay, currencyUnit: settings.defaultCurrencyDisplay,
currencyLabel:
settings.defaultCurrencyDisplay === CurrencyDisplay.TOMAN
? 'تومان'
: 'ریال',
}; };
} }
@@ -87,7 +92,8 @@ export class PricingService {
} }
private buildCalculatedPriceSql(alias: string, currencyDisplay: CurrencyDisplay) { private buildCalculatedPriceSql(alias: string, currencyDisplay: CurrencyDisplay) {
const irrCalculation = `(${alias}.base_price_usd * :current_usd_rate) * (1 - :user_discount_percent / 100.0)`; const usdSource = `COALESCE(${alias}.sale_price_usd, ${alias}.base_price_usd)`;
const irrCalculation = `(${usdSource} * :current_usd_rate) * (1 - :user_discount_percent / 100.0)`;
if (currencyDisplay === CurrencyDisplay.TOMAN) { if (currencyDisplay === CurrencyDisplay.TOMAN) {
return `ROUND((${irrCalculation}) / 10.0, 0)`; return `ROUND((${irrCalculation}) / 10.0, 0)`;
} }

View File

@@ -23,13 +23,13 @@ export class SettingsController {
constructor(private readonly settingsService: SettingsService) {} constructor(private readonly settingsService: SettingsService) {}
@Get('pricing') @Get('pricing')
@ApiOperation({ summary: 'Get pricing settings' }) @ApiOperation({ summary: 'دریافت تنظیمات نرخ ارز و واحد نمایش قیمت' })
findPricingSettings() { findPricingSettings() {
return this.settingsService.getSettings(); return this.settingsService.getSettings();
} }
@Patch('pricing') @Patch('pricing')
@ApiOperation({ summary: 'Update pricing settings and invalidate product price cache' }) @ApiOperation({ summary: 'ویرایش تنظیمات نرخ ارز و پاکسازی کش قیمت محصولات' })
updatePricingSettings(@Body() dto: UpdatePricingSettingsDto) { updatePricingSettings(@Body() dto: UpdatePricingSettingsDto) {
return this.settingsService.updatePricingSettings(dto); return this.settingsService.updatePricingSettings(dto);
} }

View File

@@ -11,6 +11,7 @@ import {
} from 'typeorm'; } from 'typeorm';
import { PaymentStatus } from '../enums/payment-status.enum'; import { PaymentStatus } from '../enums/payment-status.enum';
import { OrderStatus } from '../enums/order-status.enum'; import { OrderStatus } from '../enums/order-status.enum';
import { PaymentMethodCode } from '../../payments/enums/payment-method-code.enum';
import { User } from './user.entity'; import { User } from './user.entity';
import { UserOrderItem } from './user-order-item.entity'; import { UserOrderItem } from './user-order-item.entity';
@@ -99,6 +100,42 @@ export class UserOrder {
@Column({ name: 'currency', type: 'varchar', length: 20, default: 'IRR' }) @Column({ name: 'currency', type: 'varchar', length: 20, default: 'IRR' })
currency: string; currency: string;
@Column({
name: 'payment_method',
type: 'enum',
enum: PaymentMethodCode,
nullable: true,
})
paymentMethod?: PaymentMethodCode | null;
@Column({ name: 'payment_gateway', type: 'varchar', length: 50, nullable: true })
paymentGateway?: string | null;
@Column({
name: 'payment_metadata',
type: 'jsonb',
nullable: true,
})
paymentMetadata?: Record<string, unknown> | null;
@Column({ name: 'bank_slip_tracking_number', type: 'varchar', length: 100, nullable: true })
bankSlipTrackingNumber?: string | null;
@Column({ name: 'bank_slip_image_url', type: 'varchar', length: 500, nullable: true })
bankSlipImageUrl?: string | null;
@Column({ name: 'bank_slip_submitted_at', type: 'timestamp with time zone', nullable: true })
bankSlipSubmittedAt?: Date | null;
@Column({ name: 'payment_verified_at', type: 'timestamp with time zone', nullable: true })
paymentVerifiedAt?: Date | null;
@Column({ name: 'payment_reviewed_at', type: 'timestamp with time zone', nullable: true })
paymentReviewedAt?: Date | null;
@Column({ name: 'payment_reviewed_by_admin_id', type: 'uuid', nullable: true })
paymentReviewedByAdminId?: string | null;
@Column({ name: 'tracking_code', type: 'varchar', length: 100, nullable: true }) @Column({ name: 'tracking_code', type: 'varchar', length: 100, nullable: true })
trackingCode?: string; trackingCode?: string;

View File

@@ -33,13 +33,13 @@ export class UsersController {
constructor(private readonly usersService: UsersService) {} constructor(private readonly usersService: UsersService) {}
@Get('me') @Get('me')
@ApiOperation({ summary: 'Get current authenticated user profile' }) @ApiOperation({ summary: 'دریافت پروفایل کاربر لاگین‌شده' })
getMe(@Req() request: Request & { user: JwtPayload }) { getMe(@Req() request: Request & { user: JwtPayload }) {
return this.usersService.getProfile(request.user.sub); return this.usersService.getProfile(request.user.sub);
} }
@Patch('me') @Patch('me')
@ApiOperation({ summary: 'Update current authenticated user profile' }) @ApiOperation({ summary: 'ویرایش پروفایل کاربر لاگین‌شده' })
updateMe( updateMe(
@Req() request: Request & { user: JwtPayload }, @Req() request: Request & { user: JwtPayload },
@Body() dto: UpdateProfileDto, @Body() dto: UpdateProfileDto,
@@ -48,19 +48,19 @@ export class UsersController {
} }
@Get('me/dashboard') @Get('me/dashboard')
@ApiOperation({ summary: 'Get dashboard summary for current user' }) @ApiOperation({ summary: 'دریافت خلاصه داشبورد کاربر' })
getDashboard(@Req() request: Request & { user: JwtPayload }) { getDashboard(@Req() request: Request & { user: JwtPayload }) {
return this.usersService.getDashboard(request.user.sub); return this.usersService.getDashboard(request.user.sub);
} }
@Get('me/wallet') @Get('me/wallet')
@ApiOperation({ summary: 'Get wallet summary for current user' }) @ApiOperation({ summary: 'دریافت خلاصه کیف پول کاربر' })
getWallet(@Req() request: Request & { user: JwtPayload }) { getWallet(@Req() request: Request & { user: JwtPayload }) {
return this.usersService.getWalletSummary(request.user.sub); return this.usersService.getWalletSummary(request.user.sub);
} }
@Get('me/wallet/transactions') @Get('me/wallet/transactions')
@ApiOperation({ summary: 'List wallet transactions for current user' }) @ApiOperation({ summary: 'لیست تراکنش‌های کیف پول کاربر' })
getWalletTransactions( getWalletTransactions(
@Req() request: Request & { user: JwtPayload }, @Req() request: Request & { user: JwtPayload },
@Query() query: ListWalletTransactionsDto, @Query() query: ListWalletTransactionsDto,
@@ -69,13 +69,13 @@ export class UsersController {
} }
@Get('me/sessions') @Get('me/sessions')
@ApiOperation({ summary: 'List active sessions for current user' }) @ApiOperation({ summary: 'لیست نشست‌های فعال کاربر' })
getSessions(@Req() request: Request & { user: JwtPayload }) { getSessions(@Req() request: Request & { user: JwtPayload }) {
return this.usersService.getActiveSessions(request.user.sub); return this.usersService.getActiveSessions(request.user.sub);
} }
@Delete('me/sessions/:sessionId') @Delete('me/sessions/:sessionId')
@ApiOperation({ summary: 'Revoke one active session for current user' }) @ApiOperation({ summary: 'خروج از یک نشست فعال کاربر' })
revokeSession( revokeSession(
@Req() request: Request & { user: JwtPayload }, @Req() request: Request & { user: JwtPayload },
@Param('sessionId') sessionId: string, @Param('sessionId') sessionId: string,
@@ -84,13 +84,13 @@ export class UsersController {
} }
@Get('me/addresses') @Get('me/addresses')
@ApiOperation({ summary: 'List saved addresses for current user' }) @ApiOperation({ summary: 'لیست آدرس‌های ذخیره‌شده کاربر' })
getAddresses(@Req() request: Request & { user: JwtPayload }) { getAddresses(@Req() request: Request & { user: JwtPayload }) {
return this.usersService.getAddresses(request.user.sub); return this.usersService.getAddresses(request.user.sub);
} }
@Post('me/addresses') @Post('me/addresses')
@ApiOperation({ summary: 'Create a new address for current user' }) @ApiOperation({ summary: 'ایجاد آدرس جدید برای کاربر' })
createAddress( createAddress(
@Req() request: Request & { user: JwtPayload }, @Req() request: Request & { user: JwtPayload },
@Body() dto: CreateUserAddressDto, @Body() dto: CreateUserAddressDto,
@@ -99,7 +99,7 @@ export class UsersController {
} }
@Patch('me/addresses/:addressId') @Patch('me/addresses/:addressId')
@ApiOperation({ summary: 'Update one address for current user' }) @ApiOperation({ summary: 'ویرایش یک آدرس کاربر' })
updateAddress( updateAddress(
@Req() request: Request & { user: JwtPayload }, @Req() request: Request & { user: JwtPayload },
@Param('addressId') addressId: string, @Param('addressId') addressId: string,
@@ -109,7 +109,7 @@ export class UsersController {
} }
@Patch('me/addresses/:addressId/default') @Patch('me/addresses/:addressId/default')
@ApiOperation({ summary: 'Set one address as default for current user' }) @ApiOperation({ summary: 'انتخاب یک آدرس به عنوان پیش‌فرض' })
setDefaultAddress( setDefaultAddress(
@Req() request: Request & { user: JwtPayload }, @Req() request: Request & { user: JwtPayload },
@Param('addressId') addressId: string, @Param('addressId') addressId: string,
@@ -118,7 +118,7 @@ export class UsersController {
} }
@Delete('me/addresses/:addressId') @Delete('me/addresses/:addressId')
@ApiOperation({ summary: 'Delete one address for current user' }) @ApiOperation({ summary: 'حذف یک آدرس کاربر' })
deleteAddress( deleteAddress(
@Req() request: Request & { user: JwtPayload }, @Req() request: Request & { user: JwtPayload },
@Param('addressId') addressId: string, @Param('addressId') addressId: string,
@@ -127,13 +127,13 @@ export class UsersController {
} }
@Get('me/favorites') @Get('me/favorites')
@ApiOperation({ summary: 'List favorite products for current user' }) @ApiOperation({ summary: 'لیست علاقه‌مندی‌های کاربر' })
getFavorites(@Req() request: Request & { user: JwtPayload }) { getFavorites(@Req() request: Request & { user: JwtPayload }) {
return this.usersService.getFavorites(request.user.sub); return this.usersService.getFavorites(request.user.sub);
} }
@Post('me/favorites/:productId') @Post('me/favorites/:productId')
@ApiOperation({ summary: 'Add one product to current user favorites' }) @ApiOperation({ summary: 'افزودن محصول به علاقه‌مندی‌ها' })
addFavorite( addFavorite(
@Req() request: Request & { user: JwtPayload }, @Req() request: Request & { user: JwtPayload },
@Param('productId') productId: string, @Param('productId') productId: string,
@@ -142,7 +142,7 @@ export class UsersController {
} }
@Delete('me/favorites/:productId') @Delete('me/favorites/:productId')
@ApiOperation({ summary: 'Remove one product from current user favorites' }) @ApiOperation({ summary: 'حذف محصول از علاقه‌مندی‌ها' })
removeFavorite( removeFavorite(
@Req() request: Request & { user: JwtPayload }, @Req() request: Request & { user: JwtPayload },
@Param('productId') productId: string, @Param('productId') productId: string,
@@ -151,7 +151,7 @@ export class UsersController {
} }
@Get('me/orders') @Get('me/orders')
@ApiOperation({ summary: 'List orders for current user dashboard' }) @ApiOperation({ summary: 'لیست سفارش‌های کاربر' })
getOrders( getOrders(
@Req() request: Request & { user: JwtPayload }, @Req() request: Request & { user: JwtPayload },
@Query() query: ListUserOrdersDto, @Query() query: ListUserOrdersDto,
@@ -160,7 +160,7 @@ export class UsersController {
} }
@Get('me/orders/:orderId') @Get('me/orders/:orderId')
@ApiOperation({ summary: 'Get one order for current user dashboard' }) @ApiOperation({ summary: 'دریافت جزئیات یک سفارش کاربر' })
getOrderDetail( getOrderDetail(
@Req() request: Request & { user: JwtPayload }, @Req() request: Request & { user: JwtPayload },
@Param('orderId') orderId: string, @Param('orderId') orderId: string,

View File

@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { UserSession } from '../auth/entities/user-session.entity'; import { UserSession } from '../auth/entities/user-session.entity';
import { Product } from '../catalog/entities/product.entity'; import { Product } from '../catalog/entities/product.entity';
import { PaymentMethodSetting } from '../payments/entities/payment-method-setting.entity';
import { Ticket } from '../tickets/entities/ticket.entity'; import { Ticket } from '../tickets/entities/ticket.entity';
import { FavoriteProduct } from './entities/favorite-product.entity'; import { FavoriteProduct } from './entities/favorite-product.entity';
import { LoyaltyProfile } from './entities/loyalty-profile.entity'; import { LoyaltyProfile } from './entities/loyalty-profile.entity';
@@ -29,6 +30,7 @@ import { UsersService } from './users.service';
UserOrder, UserOrder,
UserOrderItem, UserOrderItem,
Product, Product,
PaymentMethodSetting,
Ticket, Ticket,
]), ]),
], ],

View File

@@ -630,11 +630,19 @@ export class UsersService {
orderNumber: order.orderNumber, orderNumber: order.orderNumber,
status: order.status, status: order.status,
paymentStatus: order.paymentStatus, paymentStatus: order.paymentStatus,
paymentMethod: order.paymentMethod ?? null,
paymentGateway: order.paymentGateway ?? null,
subtotalAmount: order.subtotalAmount, subtotalAmount: order.subtotalAmount,
shippingAmount: order.shippingAmount, shippingAmount: order.shippingAmount,
discountAmount: order.discountAmount, discountAmount: order.discountAmount,
totalAmount: order.totalAmount, totalAmount: order.totalAmount,
currency: order.currency, currency: order.currency,
bankSlipTrackingNumber: order.bankSlipTrackingNumber ?? null,
bankSlipImageUrl: order.bankSlipImageUrl ?? null,
bankSlipSubmittedAt: order.bankSlipSubmittedAt ?? null,
paymentVerifiedAt: order.paymentVerifiedAt ?? null,
paymentReviewedAt: order.paymentReviewedAt ?? null,
paymentMetadata: order.paymentMetadata ?? {},
trackingCode: order.trackingCode, trackingCode: order.trackingCode,
shippingMethod: order.shippingMethod, shippingMethod: order.shippingMethod,
notes: order.notes, notes: order.notes,