feat(checkout): add admin shipping methods management and typed checkout summary swagger docs
This commit is contained in:
49
src/modules/checkout/admin-checkout.controller.ts
Normal file
49
src/modules/checkout/admin-checkout.controller.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
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 { UserRole } from '../users/enums/user-role.enum';
|
||||
import { CheckoutService } from './checkout.service';
|
||||
import { CreateShippingMethodDto } from './dto/create-shipping-method.dto';
|
||||
import { UpdateShippingMethodDto } from './dto/update-shipping-method.dto';
|
||||
|
||||
@ApiTags('Admin Checkout')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard, PermissionsGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
@Permissions('orders.manage')
|
||||
@Controller('admin/checkout')
|
||||
export class AdminCheckoutController {
|
||||
constructor(private readonly checkoutService: CheckoutService) {}
|
||||
|
||||
@Get('shipping-methods')
|
||||
@ApiOperation({ summary: 'List shipping methods for admin management' })
|
||||
findShippingMethods() {
|
||||
return this.checkoutService.getAdminShippingMethods();
|
||||
}
|
||||
|
||||
@Post('shipping-methods')
|
||||
@ApiOperation({ summary: 'Create a new shipping method' })
|
||||
@ApiBody({ type: CreateShippingMethodDto })
|
||||
createShippingMethod(@Body() dto: CreateShippingMethodDto) {
|
||||
return this.checkoutService.createShippingMethod(dto);
|
||||
}
|
||||
|
||||
@Patch('shipping-methods/:shippingMethodId')
|
||||
@ApiOperation({ summary: 'Update an existing shipping method' })
|
||||
@ApiBody({ type: UpdateShippingMethodDto })
|
||||
updateShippingMethod(
|
||||
@Param('shippingMethodId') shippingMethodId: string,
|
||||
@Body() dto: UpdateShippingMethodDto,
|
||||
) {
|
||||
return this.checkoutService.updateShippingMethod(shippingMethodId, dto);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
@@ -8,6 +9,7 @@ import { Request } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { JwtPayload } from '../auth/interfaces/jwt-payload.interface';
|
||||
import { CheckoutSummaryQueryDto } from './dto/checkout-summary-query.dto';
|
||||
import { CheckoutSummaryResponseDto } from './dto/checkout-summary-response.dto';
|
||||
import { CheckoutService } from './checkout.service';
|
||||
|
||||
@ApiTags('Checkout')
|
||||
@@ -21,6 +23,7 @@ export class CheckoutController {
|
||||
@ApiOperation({
|
||||
summary: 'Preview checkout summary without creating an order',
|
||||
})
|
||||
@ApiOkResponse({ type: CheckoutSummaryResponseDto })
|
||||
getSummary(
|
||||
@Req() request: Request & { user: JwtPayload },
|
||||
@Query() query: CheckoutSummaryQueryDto,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CartModule } from '../cart/cart.module';
|
||||
import { PaymentsModule } from '../payments/payments.module';
|
||||
import { SettingsModule } from '../settings/settings.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { AdminCheckoutController } from './admin-checkout.controller';
|
||||
import { CheckoutController } from './checkout.controller';
|
||||
import { CheckoutService } from './checkout.service';
|
||||
import { ShippingMethod } from './entities/shipping-method.entity';
|
||||
@@ -16,7 +17,7 @@ import { ShippingMethod } from './entities/shipping-method.entity';
|
||||
PaymentsModule,
|
||||
SettingsModule,
|
||||
],
|
||||
controllers: [CheckoutController],
|
||||
controllers: [CheckoutController, AdminCheckoutController],
|
||||
providers: [CheckoutService],
|
||||
exports: [CheckoutService],
|
||||
})
|
||||
|
||||
@@ -10,7 +10,9 @@ import { PaymentsService } from '../payments/payments.service';
|
||||
import { SettingsService } from '../settings/settings.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { PaymentMethodCode } from '../payments/enums/payment-method-code.enum';
|
||||
import { CreateShippingMethodDto } from './dto/create-shipping-method.dto';
|
||||
import { CheckoutSummaryQueryDto } from './dto/checkout-summary-query.dto';
|
||||
import { UpdateShippingMethodDto } from './dto/update-shipping-method.dto';
|
||||
import { ShippingMethod } from './entities/shipping-method.entity';
|
||||
|
||||
@Injectable()
|
||||
@@ -129,6 +131,76 @@ export class CheckoutService {
|
||||
};
|
||||
}
|
||||
|
||||
async getAdminShippingMethods() {
|
||||
await this.ensureDefaultShippingMethods();
|
||||
|
||||
const methods = await this.shippingMethodsRepository.find({
|
||||
order: { displayOrder: 'ASC', createdAt: 'ASC' },
|
||||
});
|
||||
|
||||
return {
|
||||
items: methods.map((item) => this.serializeAdminShippingMethod(item)),
|
||||
};
|
||||
}
|
||||
|
||||
async createShippingMethod(dto: CreateShippingMethodDto) {
|
||||
await this.ensureUniqueShippingCode(dto.code);
|
||||
this.ensureEstimatedDays(dto.estimatedDeliveryDaysMin, dto.estimatedDeliveryDaysMax);
|
||||
|
||||
const method = this.shippingMethodsRepository.create({
|
||||
code: dto.code,
|
||||
title: dto.title,
|
||||
description: dto.description ?? null,
|
||||
isEnabled: dto.isEnabled ?? true,
|
||||
feeAmount: dto.feeAmount ?? 0,
|
||||
estimatedDeliveryDaysMin: dto.estimatedDeliveryDaysMin ?? 1,
|
||||
estimatedDeliveryDaysMax: dto.estimatedDeliveryDaysMax ?? 3,
|
||||
displayOrder: dto.displayOrder ?? 0,
|
||||
});
|
||||
|
||||
const savedMethod = await this.shippingMethodsRepository.save(method);
|
||||
return this.serializeAdminShippingMethod(savedMethod);
|
||||
}
|
||||
|
||||
async updateShippingMethod(shippingMethodId: string, dto: UpdateShippingMethodDto) {
|
||||
const method = await this.requireShippingMethod(shippingMethodId);
|
||||
|
||||
if (dto.code && dto.code !== method.code) {
|
||||
await this.ensureUniqueShippingCode(dto.code, method.id);
|
||||
method.code = dto.code;
|
||||
}
|
||||
|
||||
if (dto.title !== undefined) {
|
||||
method.title = dto.title;
|
||||
}
|
||||
if (dto.description !== undefined) {
|
||||
method.description = dto.description;
|
||||
}
|
||||
if (dto.isEnabled !== undefined) {
|
||||
method.isEnabled = dto.isEnabled;
|
||||
}
|
||||
if (dto.feeAmount !== undefined) {
|
||||
method.feeAmount = dto.feeAmount;
|
||||
}
|
||||
if (dto.estimatedDeliveryDaysMin !== undefined) {
|
||||
method.estimatedDeliveryDaysMin = dto.estimatedDeliveryDaysMin;
|
||||
}
|
||||
if (dto.estimatedDeliveryDaysMax !== undefined) {
|
||||
method.estimatedDeliveryDaysMax = dto.estimatedDeliveryDaysMax;
|
||||
}
|
||||
if (dto.displayOrder !== undefined) {
|
||||
method.displayOrder = dto.displayOrder;
|
||||
}
|
||||
|
||||
this.ensureEstimatedDays(
|
||||
method.estimatedDeliveryDaysMin,
|
||||
method.estimatedDeliveryDaysMax,
|
||||
);
|
||||
|
||||
const savedMethod = await this.shippingMethodsRepository.save(method);
|
||||
return this.serializeAdminShippingMethod(savedMethod);
|
||||
}
|
||||
|
||||
private async getShippingMethods() {
|
||||
await this.ensureDefaultShippingMethods();
|
||||
|
||||
@@ -154,6 +226,40 @@ export class CheckoutService {
|
||||
);
|
||||
}
|
||||
|
||||
private async ensureUniqueShippingCode(code: string, ignoreId?: string) {
|
||||
const existing = await this.shippingMethodsRepository.findOne({
|
||||
where: { code },
|
||||
});
|
||||
|
||||
if (existing && existing.id !== ignoreId) {
|
||||
throw new BadRequestException('Shipping method code already exists');
|
||||
}
|
||||
}
|
||||
|
||||
private async requireShippingMethod(shippingMethodId: string) {
|
||||
const method = await this.shippingMethodsRepository.findOne({
|
||||
where: { id: shippingMethodId },
|
||||
});
|
||||
|
||||
if (!method) {
|
||||
throw new NotFoundException('Shipping method not found');
|
||||
}
|
||||
|
||||
return method;
|
||||
}
|
||||
|
||||
private ensureEstimatedDays(minDays?: number, maxDays?: number) {
|
||||
if (
|
||||
minDays !== undefined &&
|
||||
maxDays !== undefined &&
|
||||
minDays > maxDays
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'estimatedDeliveryDaysMin cannot be greater than estimatedDeliveryDaysMax',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveAddress(
|
||||
addresses: Awaited<ReturnType<UsersService['findAddressesForCheckout']>>,
|
||||
addressId?: string,
|
||||
@@ -249,4 +355,20 @@ export class CheckoutService {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private serializeAdminShippingMethod(method: ShippingMethod) {
|
||||
return {
|
||||
id: method.id,
|
||||
code: method.code,
|
||||
title: method.title,
|
||||
description: method.description ?? null,
|
||||
isEnabled: method.isEnabled,
|
||||
feeAmount: method.feeAmount,
|
||||
estimatedDeliveryDaysMin: method.estimatedDeliveryDaysMin,
|
||||
estimatedDeliveryDaysMax: method.estimatedDeliveryDaysMax,
|
||||
displayOrder: method.displayOrder,
|
||||
createdAt: method.createdAt,
|
||||
updatedAt: method.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
261
src/modules/checkout/dto/checkout-summary-response.dto.ts
Normal file
261
src/modules/checkout/dto/checkout-summary-response.dto.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
class CheckoutProductDto {
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
|
||||
@ApiProperty()
|
||||
title: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
slug: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
technicalCode: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
brand: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
brandslug: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
stock: number;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
mainImageUrl: string | null;
|
||||
}
|
||||
|
||||
class CheckoutCartItemDto {
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
|
||||
@ApiProperty()
|
||||
quantity: number;
|
||||
|
||||
@ApiProperty()
|
||||
baseUnitPrice: number;
|
||||
|
||||
@ApiProperty()
|
||||
unitPrice: number;
|
||||
|
||||
@ApiProperty()
|
||||
baseLineTotal: number;
|
||||
|
||||
@ApiProperty()
|
||||
discountAmount: number;
|
||||
|
||||
@ApiProperty()
|
||||
lineTotal: number;
|
||||
|
||||
@ApiProperty()
|
||||
currency_unit: string;
|
||||
|
||||
@ApiProperty()
|
||||
currency_label: string;
|
||||
|
||||
@ApiProperty({ type: CheckoutProductDto })
|
||||
product: CheckoutProductDto;
|
||||
}
|
||||
|
||||
class CheckoutAddressDto {
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
|
||||
@ApiProperty()
|
||||
title: string;
|
||||
|
||||
@ApiProperty()
|
||||
recipientName: string;
|
||||
|
||||
@ApiProperty()
|
||||
phone: string;
|
||||
|
||||
@ApiProperty()
|
||||
province: string;
|
||||
|
||||
@ApiProperty()
|
||||
city: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
postalCode: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
addressLine: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
plaque: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
unit: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
class CheckoutEstimatedDeliveryDto {
|
||||
@ApiProperty()
|
||||
minDays: number;
|
||||
|
||||
@ApiProperty()
|
||||
maxDays: number;
|
||||
}
|
||||
|
||||
class CheckoutShippingMethodDto {
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
|
||||
@ApiProperty()
|
||||
code: string;
|
||||
|
||||
@ApiProperty()
|
||||
title: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
feeAmount: number;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
currency_unit: string | null;
|
||||
|
||||
@ApiProperty({ type: CheckoutEstimatedDeliveryDto })
|
||||
estimatedDelivery: CheckoutEstimatedDeliveryDto;
|
||||
}
|
||||
|
||||
class CheckoutPaymentMethodBankAccountDto {
|
||||
@ApiProperty({ nullable: true })
|
||||
bankName: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
accountHolderName: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
accountNumber: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
cardNumber: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
shebaNumber: string | null;
|
||||
}
|
||||
|
||||
class CheckoutPaymentMethodDto {
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
|
||||
@ApiProperty()
|
||||
code: string;
|
||||
|
||||
@ApiProperty()
|
||||
type: string;
|
||||
|
||||
@ApiProperty()
|
||||
title: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
instructions: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
isSandboxEnabled: boolean;
|
||||
|
||||
@ApiProperty()
|
||||
displayOrder: number;
|
||||
|
||||
@ApiPropertyOptional({ type: CheckoutPaymentMethodBankAccountDto, nullable: true })
|
||||
bankAccount?: CheckoutPaymentMethodBankAccountDto | null;
|
||||
}
|
||||
|
||||
class CheckoutPricingDto {
|
||||
@ApiProperty()
|
||||
subtotal: number;
|
||||
|
||||
@ApiProperty()
|
||||
levelDiscount: number;
|
||||
|
||||
@ApiProperty()
|
||||
payableSubtotal: number;
|
||||
|
||||
@ApiProperty()
|
||||
shipping: number;
|
||||
|
||||
@ApiProperty()
|
||||
tax: number;
|
||||
|
||||
@ApiProperty()
|
||||
total: number;
|
||||
|
||||
@ApiProperty()
|
||||
taxPercent: number;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
currency_unit: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
currency_label: string | null;
|
||||
}
|
||||
|
||||
class CheckoutNotesDto {
|
||||
@ApiProperty()
|
||||
orderWillNotBeCreated: boolean;
|
||||
|
||||
@ApiProperty()
|
||||
requiresAddressSelection: boolean;
|
||||
}
|
||||
|
||||
class CheckoutCartDto {
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
|
||||
@ApiProperty()
|
||||
itemsCount: number;
|
||||
|
||||
@ApiProperty({ type: [CheckoutCartItemDto] })
|
||||
items: CheckoutCartItemDto[];
|
||||
}
|
||||
|
||||
class CheckoutShippingSectionDto {
|
||||
@ApiProperty({ type: CheckoutShippingMethodDto, nullable: true })
|
||||
selectedMethod: CheckoutShippingMethodDto | null;
|
||||
|
||||
@ApiProperty({ type: [CheckoutShippingMethodDto] })
|
||||
methods: CheckoutShippingMethodDto[];
|
||||
}
|
||||
|
||||
class CheckoutPaymentSectionDto {
|
||||
@ApiProperty({ type: CheckoutPaymentMethodDto, nullable: true })
|
||||
selectedMethod: CheckoutPaymentMethodDto | null;
|
||||
|
||||
@ApiProperty({ type: [CheckoutPaymentMethodDto] })
|
||||
methods: CheckoutPaymentMethodDto[];
|
||||
}
|
||||
|
||||
export class CheckoutSummaryResponseDto {
|
||||
@ApiProperty()
|
||||
reviewReady: boolean;
|
||||
|
||||
@ApiProperty({ type: CheckoutCartDto })
|
||||
cart: CheckoutCartDto;
|
||||
|
||||
@ApiProperty({ type: CheckoutAddressDto, nullable: true })
|
||||
address: CheckoutAddressDto | null;
|
||||
|
||||
@ApiProperty({ type: [CheckoutAddressDto] })
|
||||
availableAddresses: CheckoutAddressDto[];
|
||||
|
||||
@ApiProperty({ type: CheckoutShippingSectionDto })
|
||||
shipping: CheckoutShippingSectionDto;
|
||||
|
||||
@ApiProperty({ type: CheckoutPaymentSectionDto })
|
||||
payment: CheckoutPaymentSectionDto;
|
||||
|
||||
@ApiProperty({ type: CheckoutPricingDto })
|
||||
pricing: CheckoutPricingDto;
|
||||
|
||||
@ApiProperty({ type: CheckoutNotesDto })
|
||||
notes: CheckoutNotesDto;
|
||||
}
|
||||
60
src/modules/checkout/dto/create-shipping-method.dto.ts
Normal file
60
src/modules/checkout/dto/create-shipping-method.dto.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateShippingMethodDto {
|
||||
@ApiProperty({ example: 'same_day_tehran' })
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
code: string;
|
||||
|
||||
@ApiProperty({ example: 'Same Day Tehran' })
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
title: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Special courier service for Tehran orders.' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
description?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ example: true, default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isEnabled?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ example: 180000, default: 0 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@Min(0)
|
||||
feeAmount?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 0, default: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
estimatedDeliveryDaysMin?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 1, default: 3 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
estimatedDeliveryDaysMax?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 3, default: 0 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
displayOrder?: number;
|
||||
}
|
||||
4
src/modules/checkout/dto/update-shipping-method.dto.ts
Normal file
4
src/modules/checkout/dto/update-shipping-method.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateShippingMethodDto } from './create-shipping-method.dto';
|
||||
|
||||
export class UpdateShippingMethodDto extends PartialType(CreateShippingMethodDto) {}
|
||||
Reference in New Issue
Block a user