add : PaymentCode And Customer Card
This commit is contained in:
302
docs/user-addresses-api.md
Normal file
302
docs/user-addresses-api.md
Normal 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
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
73
src/modules/cart/cart.controller.ts
Normal file
73
src/modules/cart/cart.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/modules/cart/cart.module.ts
Normal file
20
src/modules/cart/cart.module.ts
Normal 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 {}
|
||||||
256
src/modules/cart/cart.service.ts
Normal file
256
src/modules/cart/cart.service.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/modules/cart/dto/add-cart-item.dto.ts
Normal file
24
src/modules/cart/dto/add-cart-item.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
17
src/modules/cart/dto/update-cart-item.dto.ts
Normal file
17
src/modules/cart/dto/update-cart-item.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
37
src/modules/cart/entities/user-cart-item.entity.ts
Normal file
37
src/modules/cart/entities/user-cart-item.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
34
src/modules/cart/entities/user-cart.entity.ts
Normal file
34
src/modules/cart/entities/user-cart.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
70
src/modules/payments/admin-payments.controller.ts
Normal file
70
src/modules/payments/admin-payments.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/modules/payments/dto/review-bank-slip.dto.ts
Normal file
14
src/modules/payments/dto/review-bank-slip.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
19
src/modules/payments/dto/submit-bank-slip.dto.ts
Normal file
19
src/modules/payments/dto/submit-bank-slip.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
8
src/modules/payments/enums/payment-method-code.enum.ts
Normal file
8
src/modules/payments/enums/payment-method-code.enum.ts
Normal 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',
|
||||||
|
}
|
||||||
5
src/modules/payments/enums/payment-method-type.enum.ts
Normal file
5
src/modules/payments/enums/payment-method-type.enum.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export enum PaymentMethodType {
|
||||||
|
ONLINE = 'online',
|
||||||
|
BANK_SLIP = 'bank_slip',
|
||||||
|
CASH_ON_DELIVERY = 'cash_on_delivery',
|
||||||
|
}
|
||||||
20
src/modules/payments/payments.module.ts
Normal file
20
src/modules/payments/payments.module.ts
Normal 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 {}
|
||||||
451
src/modules/payments/payments.service.ts
Normal file
451
src/modules/payments/payments.service.ts
Normal 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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
88
src/modules/payments/public-payments.controller.ts
Normal file
88
src/modules/payments/public-payments.controller.ts
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user