diff --git a/docs/user-addresses-api.md b/docs/user-addresses-api.md new file mode 100644 index 00000000..1a12f023 --- /dev/null +++ b/docs/user-addresses-api.md @@ -0,0 +1,302 @@ +# User Addresses API + +Base URL: `/api` + +تمام endpointهای این فایل نیاز به احراز هویت دارند: + +- Header: `Authorization: Bearer ` +- 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 diff --git a/src/app.module.ts b/src/app.module.ts index 72fdeb91..397e82f2 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -9,6 +9,9 @@ import { typeOrmConfigFactory } from './config/typeorm.config'; import { AuthModule } from './modules/auth/auth.module'; import { AuthOtp } from './modules/auth/entities/auth-otp.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 { AttributeDefinition } from './modules/catalog/entities/attribute-definition.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 { MediaModule } from './modules/media/media.module'; 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 { PricingModule } from './modules/pricing/pricing.module'; import { Settings } from './modules/settings/entities/settings.entity'; @@ -68,14 +73,19 @@ import { UsersModule } from './modules/users/users.module'; ProductAttributeValue, Settings, MediaAsset, + PaymentMethodSetting, + UserCart, + UserCartItem, Ticket, TicketMessage, ]), RedisCacheModule, StorageModule, + CartModule, UsersModule, SettingsModule, PricingModule, + PaymentsModule, TicketsModule, CatalogModule, MediaModule, diff --git a/src/config/typeorm.config.ts b/src/config/typeorm.config.ts index 4892d9a8..230e8435 100644 --- a/src/config/typeorm.config.ts +++ b/src/config/typeorm.config.ts @@ -2,6 +2,8 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModuleAsyncOptions, TypeOrmModuleOptions } from '@nestjs/typeorm'; import { AuthOtp } from '../modules/auth/entities/auth-otp.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 { Brand } from '../modules/catalog/entities/brand.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 { Settings } from '../modules/settings/entities/settings.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 { TicketMessage } from '../modules/tickets/entities/ticket-message.entity'; import { Ticket } from '../modules/tickets/entities/ticket.entity'; @@ -54,6 +57,9 @@ export const buildTypeOrmOptions = ( ProductAttributeValue, Settings, MediaAsset, + PaymentMethodSetting, + UserCart, + UserCartItem, Ticket, TicketMessage, ], diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index ff4a9b04..68fbdc47 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -28,14 +28,14 @@ export class AuthController { constructor(private readonly authService: AuthService) {} @Post('otp/request') - @ApiOperation({ summary: 'Request OTP for mobile auth flow' }) + @ApiOperation({ summary: 'درخواست کد یکبارمصرف برای ورود یا ثبت‌نام موبایلی' }) @ApiBody({ type: RequestOtpDto }) requestOtp(@Body() dto: RequestOtpDto) { return this.authService.requestOtp(dto.phone, dto.fullName); } @Post('register/password') - @ApiOperation({ summary: 'Register with mobile, username, and password' }) + @ApiOperation({ summary: 'ثبت‌نام با موبایل، نام کاربری و رمز عبور' }) @ApiBody({ type: RegisterPasswordDto }) registerWithPassword( @Body() dto: RegisterPasswordDto, @@ -45,21 +45,21 @@ export class AuthController { } @Post('login/password') - @ApiOperation({ summary: 'Login with username and password' }) + @ApiOperation({ summary: 'ورود با نام کاربری و رمز عبور' }) @ApiBody({ type: LoginPasswordDto }) loginWithPassword(@Body() dto: LoginPasswordDto, @Req() request: Request) { return this.authService.loginWithPassword(dto, request); } @Post('otp/verify') - @ApiOperation({ summary: 'Verify OTP code for mobile auth flow' }) + @ApiOperation({ summary: 'تایید کد یکبارمصرف برای ورود موبایلی' }) @ApiBody({ type: VerifyOtpDto }) verifyOtp(@Body() dto: VerifyOtpDto, @Req() request: Request) { return this.authService.verifyOtp(dto.phone, dto.otp, request); } @Post('refresh') - @ApiOperation({ summary: 'Refresh JWT access token' }) + @ApiOperation({ summary: 'دریافت مجدد access token با refresh token' }) @ApiBody({ type: RefreshTokenDto }) refresh(@Body() dto: RefreshTokenDto, @Req() request: Request) { return this.authService.refreshToken(dto.refreshToken, request); diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 4fdc72e7..dedf4980 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -256,6 +256,7 @@ export class AuthService { 'brands.manage', 'users.manage', 'media.manage', + 'payments.manage', 'tickets.manage', ]; } diff --git a/src/modules/cart/cart.controller.ts b/src/modules/cart/cart.controller.ts new file mode 100644 index 00000000..efd4cc8c --- /dev/null +++ b/src/modules/cart/cart.controller.ts @@ -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); + } +} diff --git a/src/modules/cart/cart.module.ts b/src/modules/cart/cart.module.ts new file mode 100644 index 00000000..4ff95b8f --- /dev/null +++ b/src/modules/cart/cart.module.ts @@ -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 {} diff --git a/src/modules/cart/cart.service.ts b/src/modules/cart/cart.service.ts new file mode 100644 index 00000000..ae272e26 --- /dev/null +++ b/src/modules/cart/cart.service.ts @@ -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, + @InjectRepository(UserCartItem) + private readonly cartItemsRepository: Repository, + @InjectRepository(User) + private readonly usersRepository: Repository, + @InjectRepository(Product) + private readonly productsRepository: Repository, + 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, + }; + } +} diff --git a/src/modules/cart/dto/add-cart-item.dto.ts b/src/modules/cart/dto/add-cart-item.dto.ts new file mode 100644 index 00000000..8485c01f --- /dev/null +++ b/src/modules/cart/dto/add-cart-item.dto.ts @@ -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; +} diff --git a/src/modules/cart/dto/update-cart-item.dto.ts b/src/modules/cart/dto/update-cart-item.dto.ts new file mode 100644 index 00000000..0b3c59c0 --- /dev/null +++ b/src/modules/cart/dto/update-cart-item.dto.ts @@ -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; +} diff --git a/src/modules/cart/entities/user-cart-item.entity.ts b/src/modules/cart/entities/user-cart-item.entity.ts new file mode 100644 index 00000000..b1743234 --- /dev/null +++ b/src/modules/cart/entities/user-cart-item.entity.ts @@ -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; +} diff --git a/src/modules/cart/entities/user-cart.entity.ts b/src/modules/cart/entities/user-cart.entity.ts new file mode 100644 index 00000000..6cb20691 --- /dev/null +++ b/src/modules/cart/entities/user-cart.entity.ts @@ -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; +} diff --git a/src/modules/catalog/admin-products.controller.ts b/src/modules/catalog/admin-products.controller.ts index f45e2699..99ca29b9 100644 --- a/src/modules/catalog/admin-products.controller.ts +++ b/src/modules/catalog/admin-products.controller.ts @@ -53,7 +53,7 @@ export class AdminProductsController { constructor(private readonly productsService: ProductsService) {} @Post() - @ApiOperation({ summary: 'Create a product for admin panel' }) + @ApiOperation({ summary: 'ایجاد محصول در پنل ادمین' }) @ApiConsumes('multipart/form-data') @ApiBody({ type: CreateProductDto }) @UseInterceptors( @@ -76,7 +76,7 @@ export class AdminProductsController { } @Get() - @ApiOperation({ summary: 'List all products for admin panel, including drafts' }) + @ApiOperation({ summary: 'لیست همه محصولات پنل ادمین به‌همراه پیش‌نویس‌ها' }) findAll( @Query() filters: FilterProductsDto, @Headers('x-product-type') productType?: string, @@ -85,19 +85,19 @@ export class AdminProductsController { } @Get('check-slug') - @ApiOperation({ summary: 'Check whether a product slug is available for admin create/edit' }) + @ApiOperation({ summary: 'بررسی آزاد بودن اسلاگ محصول برای ساخت یا ویرایش' }) checkSlug(@Query() query: CheckProductSlugDto) { return this.productsService.checkSlugAvailability(query.slug, query.excludeId); } @Get('reviews/list') - @ApiOperation({ summary: 'List product reviews for moderation' }) + @ApiOperation({ summary: 'لیست دیدگاه‌های محصولات برای بررسی ادمین' }) findReviews(@Query() filters: FilterProductReviewsDto) { return this.productsService.findAdminReviews(filters); } @Patch('reviews/:reviewId') - @ApiOperation({ summary: 'Approve or pin a product review' }) + @ApiOperation({ summary: 'تایید یا پین کردن دیدگاه محصول' }) updateReview( @Param('reviewId') reviewId: string, @Body() dto: ModerateProductReviewDto, @@ -106,31 +106,31 @@ export class AdminProductsController { } @Delete('reviews/:reviewId') - @ApiOperation({ summary: 'Delete a product review' }) + @ApiOperation({ summary: 'حذف دیدگاه محصول' }) removeReview(@Param('reviewId') reviewId: string) { return this.productsService.removeReview(reviewId); } @Get(':id') - @ApiOperation({ summary: 'Get one product for admin panel' }) + @ApiOperation({ summary: 'دریافت جزئیات یک محصول برای پنل ادمین' }) findOne(@Param('id') id: string) { return this.productsService.findAdminOne(id); } @Patch(':id/price') - @ApiOperation({ summary: 'Update base USD price for one product' }) + @ApiOperation({ summary: 'ویرایش قیمت پایه دلاری یک محصول' }) updatePrice(@Param('id') id: string, @Body() dto: UpdateProductPriceDto) { return this.productsService.updateBasePrice(id, dto.basePriceUSD); } @Patch('prices/bulk') - @ApiOperation({ summary: 'Bulk update base USD prices for multiple products' }) + @ApiOperation({ summary: 'ویرایش گروهی قیمت پایه دلاری محصولات' }) bulkUpdatePrices(@Body() dto: BulkUpdateProductPricesDto) { return this.productsService.bulkUpdateBasePrices(dto.items); } @Patch(':id') - @ApiOperation({ summary: 'Update a product for admin panel' }) + @ApiOperation({ summary: 'ویرایش محصول در پنل ادمین' }) @ApiConsumes('multipart/form-data') @ApiBody({ type: UpdateProductDto }) @UseInterceptors( @@ -154,7 +154,7 @@ export class AdminProductsController { } @Delete(':id') - @ApiOperation({ summary: 'Delete a product and its assets' }) + @ApiOperation({ summary: 'حذف محصول و فایل‌های وابسته آن' }) remove(@Param('id') id: string) { return this.productsService.remove(id); } diff --git a/src/modules/catalog/product-details.controller.ts b/src/modules/catalog/product-details.controller.ts index 488f8c4a..4bf66bfc 100644 --- a/src/modules/catalog/product-details.controller.ts +++ b/src/modules/catalog/product-details.controller.ts @@ -12,7 +12,7 @@ export class ProductDetailsController { constructor(private readonly productsService: ProductsService) {} @Get(':slug') - @ApiOperation({ summary: 'Get one published product by slug' }) + @ApiOperation({ summary: 'دریافت جزئیات یک محصول منتشرشده بر اساس اسلاگ' }) @ApiHeader({ name: 'x-product-type', required: false, diff --git a/src/modules/catalog/products.controller.ts b/src/modules/catalog/products.controller.ts index 228bae5f..e02c7673 100644 --- a/src/modules/catalog/products.controller.ts +++ b/src/modules/catalog/products.controller.ts @@ -30,7 +30,7 @@ export class ProductsController { constructor(private readonly productsService: ProductsService) {} @Get() - @ApiOperation({ summary: 'List published products for storefront' }) + @ApiOperation({ summary: 'لیست محصولات منتشرشده برای سایت' }) @ApiHeader({ name: 'x-product-type', required: false, @@ -46,7 +46,7 @@ export class ProductsController { } @Get('brands/:brandSlug') - @ApiOperation({ summary: 'List published products by brand slug' }) + @ApiOperation({ summary: 'لیست محصولات منتشرشده بر اساس اسلاگ برند' }) @ApiHeader({ name: 'x-product-type', required: false, @@ -68,7 +68,7 @@ export class ProductsController { } @Get('brands/:brandSlug/filters') - @ApiOperation({ summary: 'Get available brand page filters by brand slug' }) + @ApiOperation({ summary: 'دریافت فیلترهای صفحه برند بر اساس همان برند' }) @ApiHeader({ name: 'x-product-type', required: false, @@ -88,7 +88,7 @@ export class ProductsController { } @Get('categories/:categorySlug') - @ApiOperation({ summary: 'List published products by category slug' }) + @ApiOperation({ summary: 'لیست محصولات منتشرشده بر اساس اسلاگ دسته‌بندی' }) @ApiHeader({ name: 'x-product-type', required: false, @@ -110,7 +110,7 @@ export class ProductsController { } @Get('categories/:categorySlug/filters') - @ApiOperation({ summary: 'Get available category page filters by category slug' }) + @ApiOperation({ summary: 'دریافت فیلترهای صفحه دسته‌بندی بر اساس همان دسته' }) @ApiHeader({ name: 'x-product-type', required: false, @@ -130,20 +130,20 @@ export class ProductsController { } @Get(':id/reviews') - @ApiOperation({ summary: 'List approved reviews for a product' }) + @ApiOperation({ summary: 'لیست دیدگاه‌های تاییدشده یک محصول' }) findApprovedReviews(@Param('id') id: string) { return this.productsService.findApprovedReviews(id); } @Post(':id/reviews') - @ApiOperation({ summary: 'Submit a new product review' }) + @ApiOperation({ summary: 'ثبت دیدگاه جدید برای محصول' }) @ApiBody({ type: CreateProductReviewDto }) createReview(@Param('id') id: string, @Body() dto: CreateProductReviewDto) { return this.productsService.createReview(id, dto); } @Get(':id') - @ApiOperation({ summary: 'Get one published product with approved reviews summary' }) + @ApiOperation({ summary: 'دریافت جزئیات یک محصول منتشرشده' }) @ApiHeader({ name: 'x-product-type', required: false, diff --git a/src/modules/catalog/products.service.ts b/src/modules/catalog/products.service.ts index 780c8149..152caf86 100644 --- a/src/modules/catalog/products.service.ts +++ b/src/modules/catalog/products.service.ts @@ -118,7 +118,8 @@ export class ProductsService { ); 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( @@ -244,7 +245,10 @@ export class ProductsService { } 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) { @@ -444,7 +448,8 @@ export class ProductsService { } 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) { @@ -563,6 +568,7 @@ export class ProductsService { const page = filters.page ?? 1; const limit = filters.limit ?? 20; + const pricingContext = await this.pricingService.getPricingContext(null); const query = this.createFilteredProductsQuery(filters, includeUnpublished) .skip((page - 1) * limit) @@ -571,7 +577,9 @@ export class ProductsService { const [items, total] = await query.getManyAndCount(); return { - items: items.map((item) => this.serializeAdminProductListItem(item)), + items: items.map((item) => + this.serializeAdminProductListItem(item, pricingContext), + ), meta: { total, 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 meta = product.meta ?? null; const shortDescription = meta?.shortDescription ?? null; @@ -1305,9 +1317,13 @@ export class ProductsService { description: meta?.metaDescription ?? shortDescription, } : undefined; + const pricingInfo = pricingContext + ? this.buildDisplayPricing(product, pricingContext) + : null; return { ...product, + ...(pricingInfo ?? {}), brandInfo: product.brandEntity ? { id: product.brandEntity.id, @@ -1384,7 +1400,9 @@ export class ProductsService { brand: product.brand, brandslug: product.brandEntity?.slug ?? null, calculated_price: calculatedPrice, + display_price: calculatedPrice, currency_unit: pricingContext.currencyUnit, + currency_label: pricingContext.currencyLabel, stock: product.stock, averageRating: product.averageRating, reviewsCount: product.reviewsCount, @@ -1454,7 +1472,9 @@ export class ProductsService { } : null, calculated_price: calculatedPrice, + display_price: calculatedPrice, currency_unit: pricingContext.currencyUnit, + currency_label: pricingContext.currencyLabel, stock: product.stock, featured: product.featured, 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 orderedAttributes = this.sortAttributes(product.attributeValues).filter( (item) => item.attribute?.isVisible ?? true, ); + const pricingInfo = this.buildDisplayPricing(product, pricingContext); return { id: product.id, @@ -1538,6 +1562,7 @@ export class ProductsService { featured: product.featured, basePriceUSD: product.basePriceUSD, salePriceUSD: product.salePriceUSD ?? null, + ...pricingInfo, stock: product.stock, averageRating: product.averageRating, 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[]) { return [...(attributes ?? [])].sort((left, right) => { const orderDiff = (left.displayOrder ?? 0) - (right.displayOrder ?? 0); diff --git a/src/modules/payments/admin-payments.controller.ts b/src/modules/payments/admin-payments.controller.ts new file mode 100644 index 00000000..225a384b --- /dev/null +++ b/src/modules/payments/admin-payments.controller.ts @@ -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); + } +} diff --git a/src/modules/payments/dto/review-bank-slip.dto.ts b/src/modules/payments/dto/review-bank-slip.dto.ts new file mode 100644 index 00000000..37e70926 --- /dev/null +++ b/src/modules/payments/dto/review-bank-slip.dto.ts @@ -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; +} diff --git a/src/modules/payments/dto/select-order-payment-method.dto.ts b/src/modules/payments/dto/select-order-payment-method.dto.ts new file mode 100644 index 00000000..12feb10f --- /dev/null +++ b/src/modules/payments/dto/select-order-payment-method.dto.ts @@ -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; +} diff --git a/src/modules/payments/dto/submit-bank-slip.dto.ts b/src/modules/payments/dto/submit-bank-slip.dto.ts new file mode 100644 index 00000000..3bbbecd6 --- /dev/null +++ b/src/modules/payments/dto/submit-bank-slip.dto.ts @@ -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; +} diff --git a/src/modules/payments/dto/update-payment-method-setting.dto.ts b/src/modules/payments/dto/update-payment-method-setting.dto.ts new file mode 100644 index 00000000..57f0b3cf --- /dev/null +++ b/src/modules/payments/dto/update-payment-method-setting.dto.ts @@ -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; +} diff --git a/src/modules/payments/entities/payment-method-setting.entity.ts b/src/modules/payments/entities/payment-method-setting.entity.ts new file mode 100644 index 00000000..25dcc750 --- /dev/null +++ b/src/modules/payments/entities/payment-method-setting.entity.ts @@ -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; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/src/modules/payments/enums/payment-method-code.enum.ts b/src/modules/payments/enums/payment-method-code.enum.ts new file mode 100644 index 00000000..195266fa --- /dev/null +++ b/src/modules/payments/enums/payment-method-code.enum.ts @@ -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', +} diff --git a/src/modules/payments/enums/payment-method-type.enum.ts b/src/modules/payments/enums/payment-method-type.enum.ts new file mode 100644 index 00000000..cfaa9735 --- /dev/null +++ b/src/modules/payments/enums/payment-method-type.enum.ts @@ -0,0 +1,5 @@ +export enum PaymentMethodType { + ONLINE = 'online', + BANK_SLIP = 'bank_slip', + CASH_ON_DELIVERY = 'cash_on_delivery', +} diff --git a/src/modules/payments/payments.module.ts b/src/modules/payments/payments.module.ts new file mode 100644 index 00000000..479ebe18 --- /dev/null +++ b/src/modules/payments/payments.module.ts @@ -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 {} diff --git a/src/modules/payments/payments.service.ts b/src/modules/payments/payments.service.ts new file mode 100644 index 00000000..84e726b8 --- /dev/null +++ b/src/modules/payments/payments.service.ts @@ -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, + @InjectRepository(UserOrder) + private readonly userOrdersRepository: Repository, + @InjectRepository(User) + private readonly usersRepository: Repository, + 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}`; + } +} diff --git a/src/modules/payments/public-payments.controller.ts b/src/modules/payments/public-payments.controller.ts new file mode 100644 index 00000000..9de75648 --- /dev/null +++ b/src/modules/payments/public-payments.controller.ts @@ -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, + ); + } +} diff --git a/src/modules/pricing/pricing.service.ts b/src/modules/pricing/pricing.service.ts index a30672c4..d0622b86 100644 --- a/src/modules/pricing/pricing.service.ts +++ b/src/modules/pricing/pricing.service.ts @@ -14,6 +14,7 @@ export interface PricingContext { userLevel: UserLevel | 'Guest'; discountPercent: number; currencyUnit: CurrencyDisplay; + currencyLabel: 'ریال' | 'تومان'; } @Injectable() @@ -43,6 +44,10 @@ export class PricingService { userLevel, discountPercent, currencyUnit: settings.defaultCurrencyDisplay, + currencyLabel: + settings.defaultCurrencyDisplay === CurrencyDisplay.TOMAN + ? 'تومان' + : 'ریال', }; } @@ -87,7 +92,8 @@ export class PricingService { } 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) { return `ROUND((${irrCalculation}) / 10.0, 0)`; } diff --git a/src/modules/settings/settings.controller.ts b/src/modules/settings/settings.controller.ts index 58f817d3..5e750b9a 100644 --- a/src/modules/settings/settings.controller.ts +++ b/src/modules/settings/settings.controller.ts @@ -23,13 +23,13 @@ export class SettingsController { constructor(private readonly settingsService: SettingsService) {} @Get('pricing') - @ApiOperation({ summary: 'Get pricing settings' }) + @ApiOperation({ summary: 'دریافت تنظیمات نرخ ارز و واحد نمایش قیمت' }) findPricingSettings() { return this.settingsService.getSettings(); } @Patch('pricing') - @ApiOperation({ summary: 'Update pricing settings and invalidate product price cache' }) + @ApiOperation({ summary: 'ویرایش تنظیمات نرخ ارز و پاکسازی کش قیمت محصولات' }) updatePricingSettings(@Body() dto: UpdatePricingSettingsDto) { return this.settingsService.updatePricingSettings(dto); } diff --git a/src/modules/users/entities/user-order.entity.ts b/src/modules/users/entities/user-order.entity.ts index f6a76db1..7ef0808b 100644 --- a/src/modules/users/entities/user-order.entity.ts +++ b/src/modules/users/entities/user-order.entity.ts @@ -11,6 +11,7 @@ import { } from 'typeorm'; import { PaymentStatus } from '../enums/payment-status.enum'; import { OrderStatus } from '../enums/order-status.enum'; +import { PaymentMethodCode } from '../../payments/enums/payment-method-code.enum'; import { User } from './user.entity'; import { UserOrderItem } from './user-order-item.entity'; @@ -99,6 +100,42 @@ export class UserOrder { @Column({ name: 'currency', type: 'varchar', length: 20, default: 'IRR' }) 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 | 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 }) trackingCode?: string; diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts index 90fe115e..88f17c13 100644 --- a/src/modules/users/users.controller.ts +++ b/src/modules/users/users.controller.ts @@ -33,13 +33,13 @@ export class UsersController { constructor(private readonly usersService: UsersService) {} @Get('me') - @ApiOperation({ summary: 'Get current authenticated user profile' }) + @ApiOperation({ summary: 'دریافت پروفایل کاربر لاگین‌شده' }) getMe(@Req() request: Request & { user: JwtPayload }) { return this.usersService.getProfile(request.user.sub); } @Patch('me') - @ApiOperation({ summary: 'Update current authenticated user profile' }) + @ApiOperation({ summary: 'ویرایش پروفایل کاربر لاگین‌شده' }) updateMe( @Req() request: Request & { user: JwtPayload }, @Body() dto: UpdateProfileDto, @@ -48,19 +48,19 @@ export class UsersController { } @Get('me/dashboard') - @ApiOperation({ summary: 'Get dashboard summary for current user' }) + @ApiOperation({ summary: 'دریافت خلاصه داشبورد کاربر' }) getDashboard(@Req() request: Request & { user: JwtPayload }) { return this.usersService.getDashboard(request.user.sub); } @Get('me/wallet') - @ApiOperation({ summary: 'Get wallet summary for current user' }) + @ApiOperation({ summary: 'دریافت خلاصه کیف پول کاربر' }) getWallet(@Req() request: Request & { user: JwtPayload }) { return this.usersService.getWalletSummary(request.user.sub); } @Get('me/wallet/transactions') - @ApiOperation({ summary: 'List wallet transactions for current user' }) + @ApiOperation({ summary: 'لیست تراکنش‌های کیف پول کاربر' }) getWalletTransactions( @Req() request: Request & { user: JwtPayload }, @Query() query: ListWalletTransactionsDto, @@ -69,13 +69,13 @@ export class UsersController { } @Get('me/sessions') - @ApiOperation({ summary: 'List active sessions for current user' }) + @ApiOperation({ summary: 'لیست نشست‌های فعال کاربر' }) getSessions(@Req() request: Request & { user: JwtPayload }) { return this.usersService.getActiveSessions(request.user.sub); } @Delete('me/sessions/:sessionId') - @ApiOperation({ summary: 'Revoke one active session for current user' }) + @ApiOperation({ summary: 'خروج از یک نشست فعال کاربر' }) revokeSession( @Req() request: Request & { user: JwtPayload }, @Param('sessionId') sessionId: string, @@ -84,13 +84,13 @@ export class UsersController { } @Get('me/addresses') - @ApiOperation({ summary: 'List saved addresses for current user' }) + @ApiOperation({ summary: 'لیست آدرس‌های ذخیره‌شده کاربر' }) getAddresses(@Req() request: Request & { user: JwtPayload }) { return this.usersService.getAddresses(request.user.sub); } @Post('me/addresses') - @ApiOperation({ summary: 'Create a new address for current user' }) + @ApiOperation({ summary: 'ایجاد آدرس جدید برای کاربر' }) createAddress( @Req() request: Request & { user: JwtPayload }, @Body() dto: CreateUserAddressDto, @@ -99,7 +99,7 @@ export class UsersController { } @Patch('me/addresses/:addressId') - @ApiOperation({ summary: 'Update one address for current user' }) + @ApiOperation({ summary: 'ویرایش یک آدرس کاربر' }) updateAddress( @Req() request: Request & { user: JwtPayload }, @Param('addressId') addressId: string, @@ -109,7 +109,7 @@ export class UsersController { } @Patch('me/addresses/:addressId/default') - @ApiOperation({ summary: 'Set one address as default for current user' }) + @ApiOperation({ summary: 'انتخاب یک آدرس به عنوان پیش‌فرض' }) setDefaultAddress( @Req() request: Request & { user: JwtPayload }, @Param('addressId') addressId: string, @@ -118,7 +118,7 @@ export class UsersController { } @Delete('me/addresses/:addressId') - @ApiOperation({ summary: 'Delete one address for current user' }) + @ApiOperation({ summary: 'حذف یک آدرس کاربر' }) deleteAddress( @Req() request: Request & { user: JwtPayload }, @Param('addressId') addressId: string, @@ -127,13 +127,13 @@ export class UsersController { } @Get('me/favorites') - @ApiOperation({ summary: 'List favorite products for current user' }) + @ApiOperation({ summary: 'لیست علاقه‌مندی‌های کاربر' }) getFavorites(@Req() request: Request & { user: JwtPayload }) { return this.usersService.getFavorites(request.user.sub); } @Post('me/favorites/:productId') - @ApiOperation({ summary: 'Add one product to current user favorites' }) + @ApiOperation({ summary: 'افزودن محصول به علاقه‌مندی‌ها' }) addFavorite( @Req() request: Request & { user: JwtPayload }, @Param('productId') productId: string, @@ -142,7 +142,7 @@ export class UsersController { } @Delete('me/favorites/:productId') - @ApiOperation({ summary: 'Remove one product from current user favorites' }) + @ApiOperation({ summary: 'حذف محصول از علاقه‌مندی‌ها' }) removeFavorite( @Req() request: Request & { user: JwtPayload }, @Param('productId') productId: string, @@ -151,7 +151,7 @@ export class UsersController { } @Get('me/orders') - @ApiOperation({ summary: 'List orders for current user dashboard' }) + @ApiOperation({ summary: 'لیست سفارش‌های کاربر' }) getOrders( @Req() request: Request & { user: JwtPayload }, @Query() query: ListUserOrdersDto, @@ -160,7 +160,7 @@ export class UsersController { } @Get('me/orders/:orderId') - @ApiOperation({ summary: 'Get one order for current user dashboard' }) + @ApiOperation({ summary: 'دریافت جزئیات یک سفارش کاربر' }) getOrderDetail( @Req() request: Request & { user: JwtPayload }, @Param('orderId') orderId: string, diff --git a/src/modules/users/users.module.ts b/src/modules/users/users.module.ts index 231f4a04..c3469546 100644 --- a/src/modules/users/users.module.ts +++ b/src/modules/users/users.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { UserSession } from '../auth/entities/user-session.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 { FavoriteProduct } from './entities/favorite-product.entity'; import { LoyaltyProfile } from './entities/loyalty-profile.entity'; @@ -29,6 +30,7 @@ import { UsersService } from './users.service'; UserOrder, UserOrderItem, Product, + PaymentMethodSetting, Ticket, ]), ], diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts index a4b9f105..4ee528e7 100644 --- a/src/modules/users/users.service.ts +++ b/src/modules/users/users.service.ts @@ -630,11 +630,19 @@ export class UsersService { orderNumber: order.orderNumber, status: order.status, paymentStatus: order.paymentStatus, + paymentMethod: order.paymentMethod ?? null, + paymentGateway: order.paymentGateway ?? null, subtotalAmount: order.subtotalAmount, shippingAmount: order.shippingAmount, discountAmount: order.discountAmount, 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 ?? {}, trackingCode: order.trackingCode, shippingMethod: order.shippingMethod, notes: order.notes,