feat(checkout): add admin shipping methods management and typed checkout summary swagger docs

This commit is contained in:
2026-04-03 16:29:31 +03:30
parent 417fc927f5
commit 0b022c295d
7 changed files with 501 additions and 1 deletions

View 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);
}
}

View File

@@ -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,

View File

@@ -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],
})

View File

@@ -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,
};
}
}

View 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;
}

View 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;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateShippingMethodDto } from './create-shipping-method.dto';
export class UpdateShippingMethodDto extends PartialType(CreateShippingMethodDto) {}