feat(checkout): add checkout summary preview with address, payment, shipping and tax calculation
This commit is contained in:
@@ -12,6 +12,8 @@ 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 { ShippingMethod } from './modules/checkout/entities/shipping-method.entity';
|
||||
import { CheckoutModule } from './modules/checkout/checkout.module';
|
||||
import { Category } from './modules/catalog/entities/category.entity';
|
||||
import { AttributeDefinition } from './modules/catalog/entities/attribute-definition.entity';
|
||||
import { Brand } from './modules/catalog/entities/brand.entity';
|
||||
@@ -76,12 +78,14 @@ import { UsersModule } from './modules/users/users.module';
|
||||
PaymentMethodSetting,
|
||||
UserCart,
|
||||
UserCartItem,
|
||||
ShippingMethod,
|
||||
Ticket,
|
||||
TicketMessage,
|
||||
]),
|
||||
RedisCacheModule,
|
||||
StorageModule,
|
||||
CartModule,
|
||||
CheckoutModule,
|
||||
UsersModule,
|
||||
SettingsModule,
|
||||
PricingModule,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AuthOtp } from '../modules/auth/entities/auth-otp.entity';
|
||||
import { UserSession } from '../modules/auth/entities/user-session.entity';
|
||||
import { UserCartItem } from '../modules/cart/entities/user-cart-item.entity';
|
||||
import { UserCart } from '../modules/cart/entities/user-cart.entity';
|
||||
import { ShippingMethod } from '../modules/checkout/entities/shipping-method.entity';
|
||||
import { AttributeDefinition } from '../modules/catalog/entities/attribute-definition.entity';
|
||||
import { Brand } from '../modules/catalog/entities/brand.entity';
|
||||
import { Category } from '../modules/catalog/entities/category.entity';
|
||||
@@ -60,6 +61,7 @@ export const buildTypeOrmOptions = (
|
||||
PaymentMethodSetting,
|
||||
UserCart,
|
||||
UserCartItem,
|
||||
ShippingMethod,
|
||||
Ticket,
|
||||
TicketMessage,
|
||||
],
|
||||
|
||||
@@ -200,17 +200,32 @@ export class CartService {
|
||||
: Math.round(irrPrice);
|
||||
}
|
||||
|
||||
private buildBaseDisplayPrice(product: Product, pricingContext: PricingContext) {
|
||||
const usdPrice = product.salePriceUSD ?? product.basePriceUSD ?? 0;
|
||||
const irrPrice = usdPrice * pricingContext.usdToIrrRate;
|
||||
|
||||
return pricingContext.currencyUnit === 'TOMAN'
|
||||
? Math.round(irrPrice / 10)
|
||||
: Math.round(irrPrice);
|
||||
}
|
||||
|
||||
private serializeCart(cart: UserCart, pricingContext: PricingContext) {
|
||||
const items = (cart.items ?? []).map((item) => {
|
||||
const baseUnitPrice = this.buildBaseDisplayPrice(item.product, pricingContext);
|
||||
const displayPrice = this.buildDisplayPrice(item.product, pricingContext);
|
||||
const discountAmount = Math.max(0, baseUnitPrice - displayPrice) * item.quantity;
|
||||
const lineTotal = displayPrice * item.quantity;
|
||||
const baseLineTotal = baseUnitPrice * item.quantity;
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
quantity: item.quantity,
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt,
|
||||
baseUnitPrice,
|
||||
unitPrice: displayPrice,
|
||||
baseLineTotal,
|
||||
discountAmount,
|
||||
lineTotal,
|
||||
currency_unit: pricingContext.currencyUnit,
|
||||
currency_label: pricingContext.currencyLabel,
|
||||
@@ -236,7 +251,9 @@ export class CartService {
|
||||
});
|
||||
|
||||
const itemsCount = items.reduce((sum, item) => sum + item.quantity, 0);
|
||||
const baseSubtotal = items.reduce((sum, item) => sum + item.baseLineTotal, 0);
|
||||
const subtotal = items.reduce((sum, item) => sum + item.lineTotal, 0);
|
||||
const discountAmount = Math.max(0, baseSubtotal - subtotal);
|
||||
|
||||
return {
|
||||
id: cart.id,
|
||||
@@ -244,6 +261,8 @@ export class CartService {
|
||||
items,
|
||||
summary: {
|
||||
itemsCount,
|
||||
baseSubtotal,
|
||||
discountAmount,
|
||||
subtotal,
|
||||
total: subtotal,
|
||||
currency_unit: pricingContext.currencyUnit,
|
||||
|
||||
30
src/modules/checkout/checkout.controller.ts
Normal file
30
src/modules/checkout/checkout.controller.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
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 { CheckoutSummaryQueryDto } from './dto/checkout-summary-query.dto';
|
||||
import { CheckoutService } from './checkout.service';
|
||||
|
||||
@ApiTags('Checkout')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('checkout')
|
||||
export class CheckoutController {
|
||||
constructor(private readonly checkoutService: CheckoutService) {}
|
||||
|
||||
@Get('summary')
|
||||
@ApiOperation({
|
||||
summary: 'Preview checkout summary without creating an order',
|
||||
})
|
||||
getSummary(
|
||||
@Req() request: Request & { user: JwtPayload },
|
||||
@Query() query: CheckoutSummaryQueryDto,
|
||||
) {
|
||||
return this.checkoutService.getSummary(request.user.sub, query);
|
||||
}
|
||||
}
|
||||
23
src/modules/checkout/checkout.module.ts
Normal file
23
src/modules/checkout/checkout.module.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
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 { CheckoutController } from './checkout.controller';
|
||||
import { CheckoutService } from './checkout.service';
|
||||
import { ShippingMethod } from './entities/shipping-method.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ShippingMethod]),
|
||||
CartModule,
|
||||
UsersModule,
|
||||
PaymentsModule,
|
||||
SettingsModule,
|
||||
],
|
||||
controllers: [CheckoutController],
|
||||
providers: [CheckoutService],
|
||||
exports: [CheckoutService],
|
||||
})
|
||||
export class CheckoutModule {}
|
||||
252
src/modules/checkout/checkout.service.ts
Normal file
252
src/modules/checkout/checkout.service.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CartService } from '../cart/cart.service';
|
||||
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 { CheckoutSummaryQueryDto } from './dto/checkout-summary-query.dto';
|
||||
import { ShippingMethod } from './entities/shipping-method.entity';
|
||||
|
||||
@Injectable()
|
||||
export class CheckoutService {
|
||||
private static readonly DEFAULT_SHIPPING_METHODS: Array<
|
||||
Pick<
|
||||
ShippingMethod,
|
||||
| 'code'
|
||||
| 'title'
|
||||
| 'description'
|
||||
| 'isEnabled'
|
||||
| 'feeAmount'
|
||||
| 'estimatedDeliveryDaysMin'
|
||||
| 'estimatedDeliveryDaysMax'
|
||||
| 'displayOrder'
|
||||
>
|
||||
> = [
|
||||
{
|
||||
code: 'standard_post',
|
||||
title: 'Standard Post',
|
||||
description: 'Economical delivery for regular orders.',
|
||||
isEnabled: true,
|
||||
feeAmount: 0,
|
||||
estimatedDeliveryDaysMin: 3,
|
||||
estimatedDeliveryDaysMax: 5,
|
||||
displayOrder: 1,
|
||||
},
|
||||
{
|
||||
code: 'express_post',
|
||||
title: 'Express Post',
|
||||
description: 'Faster delivery for urgent orders.',
|
||||
isEnabled: true,
|
||||
feeAmount: 250000,
|
||||
estimatedDeliveryDaysMin: 1,
|
||||
estimatedDeliveryDaysMax: 2,
|
||||
displayOrder: 2,
|
||||
},
|
||||
];
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ShippingMethod)
|
||||
private readonly shippingMethodsRepository: Repository<ShippingMethod>,
|
||||
private readonly cartService: CartService,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly paymentsService: PaymentsService,
|
||||
private readonly settingsService: SettingsService,
|
||||
) {}
|
||||
|
||||
async getSummary(userId: string, query: CheckoutSummaryQueryDto) {
|
||||
const [cart, addresses, paymentMethods, settings, shippingMethods] =
|
||||
await Promise.all([
|
||||
this.cartService.getCart(userId),
|
||||
this.usersService.findAddressesForCheckout(userId),
|
||||
this.paymentsService.getPublicPaymentMethods(),
|
||||
this.settingsService.getSettings(),
|
||||
this.getShippingMethods(),
|
||||
]);
|
||||
|
||||
if ((cart.itemsCount ?? 0) === 0) {
|
||||
throw new BadRequestException('Cart is empty');
|
||||
}
|
||||
|
||||
const address = this.resolveAddress(addresses, query.addressId);
|
||||
const shippingMethod = this.resolveShippingMethod(
|
||||
shippingMethods,
|
||||
query.shippingMethodId,
|
||||
);
|
||||
const paymentMethod = this.resolvePaymentMethod(
|
||||
paymentMethods,
|
||||
query.paymentMethodCode,
|
||||
);
|
||||
|
||||
const subtotal = Number(cart.summary?.baseSubtotal ?? cart.summary?.subtotal ?? 0);
|
||||
const levelDiscount = Number(cart.summary?.discountAmount ?? 0);
|
||||
const payableSubtotal = Math.max(0, subtotal - levelDiscount);
|
||||
const shipping = Number(shippingMethod?.feeAmount ?? 0);
|
||||
const tax = this.calculateTax(payableSubtotal + shipping, settings.taxPercent);
|
||||
const total = Math.max(0, payableSubtotal + shipping + tax);
|
||||
|
||||
return {
|
||||
reviewReady: true,
|
||||
cart: {
|
||||
id: cart.id,
|
||||
itemsCount: cart.itemsCount,
|
||||
items: cart.items,
|
||||
},
|
||||
address: address ? this.serializeAddress(address) : null,
|
||||
availableAddresses: addresses.map((item) => this.serializeAddress(item)),
|
||||
shipping: {
|
||||
selectedMethod: shippingMethod
|
||||
? this.serializeShippingMethod(shippingMethod, cart.summary?.currency_unit)
|
||||
: null,
|
||||
methods: shippingMethods.map((item) =>
|
||||
this.serializeShippingMethod(item, cart.summary?.currency_unit),
|
||||
),
|
||||
},
|
||||
payment: {
|
||||
selectedMethod: paymentMethod ?? null,
|
||||
methods: paymentMethods,
|
||||
},
|
||||
pricing: {
|
||||
subtotal,
|
||||
levelDiscount,
|
||||
payableSubtotal,
|
||||
shipping,
|
||||
tax,
|
||||
total,
|
||||
taxPercent: settings.taxPercent,
|
||||
currency_unit: cart.summary?.currency_unit ?? null,
|
||||
currency_label: cart.summary?.currency_label ?? null,
|
||||
},
|
||||
notes: {
|
||||
orderWillNotBeCreated: true,
|
||||
requiresAddressSelection: !address,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async getShippingMethods() {
|
||||
await this.ensureDefaultShippingMethods();
|
||||
|
||||
return this.shippingMethodsRepository.find({
|
||||
where: { isEnabled: true },
|
||||
order: { displayOrder: 'ASC', createdAt: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureDefaultShippingMethods() {
|
||||
const existing = await this.shippingMethodsRepository.find();
|
||||
const missing = CheckoutService.DEFAULT_SHIPPING_METHODS.filter(
|
||||
(defaultMethod) =>
|
||||
!existing.some((existingMethod) => existingMethod.code === defaultMethod.code),
|
||||
);
|
||||
|
||||
if (missing.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.shippingMethodsRepository.save(
|
||||
missing.map((item) => this.shippingMethodsRepository.create(item)),
|
||||
);
|
||||
}
|
||||
|
||||
private resolveAddress(
|
||||
addresses: Awaited<ReturnType<UsersService['findAddressesForCheckout']>>,
|
||||
addressId?: string,
|
||||
) {
|
||||
if (addresses.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!addressId) {
|
||||
return addresses.find((item) => item.isDefault) ?? addresses[0];
|
||||
}
|
||||
|
||||
const address = addresses.find((item) => item.id === addressId);
|
||||
if (!address) {
|
||||
throw new NotFoundException('Selected address not found');
|
||||
}
|
||||
|
||||
return address;
|
||||
}
|
||||
|
||||
private resolveShippingMethod(
|
||||
methods: ShippingMethod[],
|
||||
shippingMethodId?: string,
|
||||
) {
|
||||
if (methods.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!shippingMethodId) {
|
||||
return methods[0];
|
||||
}
|
||||
|
||||
const method = methods.find((item) => item.id === shippingMethodId);
|
||||
if (!method) {
|
||||
throw new NotFoundException('Selected shipping method not found');
|
||||
}
|
||||
|
||||
return method;
|
||||
}
|
||||
|
||||
private resolvePaymentMethod(
|
||||
methods: Awaited<ReturnType<PaymentsService['getPublicPaymentMethods']>>,
|
||||
paymentMethodCode?: PaymentMethodCode,
|
||||
) {
|
||||
if (!paymentMethodCode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const method = methods.find((item) => item.code === paymentMethodCode);
|
||||
if (!method) {
|
||||
throw new NotFoundException('Selected payment method not found');
|
||||
}
|
||||
|
||||
return method;
|
||||
}
|
||||
private calculateTax(baseAmount: number, taxPercent: number) {
|
||||
if (taxPercent <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.round((baseAmount * taxPercent) / 100);
|
||||
}
|
||||
|
||||
private serializeAddress(
|
||||
address: Awaited<ReturnType<UsersService['findAddressesForCheckout']>>[number],
|
||||
) {
|
||||
return {
|
||||
id: address.id,
|
||||
title: address.title,
|
||||
recipientName: address.recipientName,
|
||||
phone: address.phone,
|
||||
province: address.province,
|
||||
city: address.city,
|
||||
postalCode: address.postalCode ?? null,
|
||||
addressLine: address.addressLine,
|
||||
plaque: address.plaque ?? null,
|
||||
unit: address.unit ?? null,
|
||||
isDefault: address.isDefault,
|
||||
};
|
||||
}
|
||||
|
||||
private serializeShippingMethod(method: ShippingMethod, currencyUnit?: string | null) {
|
||||
return {
|
||||
id: method.id,
|
||||
code: method.code,
|
||||
title: method.title,
|
||||
description: method.description ?? null,
|
||||
feeAmount: method.feeAmount,
|
||||
currency_unit: currencyUnit ?? null,
|
||||
estimatedDelivery: {
|
||||
minDays: method.estimatedDeliveryDaysMin,
|
||||
maxDays: method.estimatedDeliveryDaysMax,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
29
src/modules/checkout/dto/checkout-summary-query.dto.ts
Normal file
29
src/modules/checkout/dto/checkout-summary-query.dto.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional, IsUUID } from 'class-validator';
|
||||
import { PaymentMethodCode } from '../../payments/enums/payment-method-code.enum';
|
||||
|
||||
export class CheckoutSummaryQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description: 'Use a specific saved address. Falls back to the default address when omitted.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
addressId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description:
|
||||
'Use a specific shipping method. Falls back to the first enabled method when omitted.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
shippingMethodId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Optional payment method code selected by the user for review purposes.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(PaymentMethodCode)
|
||||
paymentMethodCode?: PaymentMethodCode;
|
||||
}
|
||||
53
src/modules/checkout/entities/shipping-method.entity.ts
Normal file
53
src/modules/checkout/entities/shipping-method.entity.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity({ name: 'shipping_methods' })
|
||||
export class ShippingMethod {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 80, unique: true })
|
||||
code: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 120 })
|
||||
title: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 500, nullable: true })
|
||||
description?: string | null;
|
||||
|
||||
@Column({ name: 'is_enabled', type: 'boolean', default: true })
|
||||
isEnabled: boolean;
|
||||
|
||||
@Column({
|
||||
name: 'fee_amount',
|
||||
type: 'numeric',
|
||||
precision: 12,
|
||||
scale: 2,
|
||||
default: 0,
|
||||
transformer: {
|
||||
to: (value: number) => value,
|
||||
from: (value: string) => Number(value),
|
||||
},
|
||||
})
|
||||
feeAmount: number;
|
||||
|
||||
@Column({ name: 'estimated_delivery_days_min', type: 'int', default: 1 })
|
||||
estimatedDeliveryDaysMin: number;
|
||||
|
||||
@Column({ name: 'estimated_delivery_days_max', type: 'int', default: 3 })
|
||||
estimatedDeliveryDaysMax: number;
|
||||
|
||||
@Column({ name: 'display_order', type: 'int', default: 0 })
|
||||
displayOrder: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsEnum, IsNumber, Min } from 'class-validator';
|
||||
import { IsEnum, IsNumber, IsOptional, Min } from 'class-validator';
|
||||
import { CurrencyDisplay } from '../enums/currency-display.enum';
|
||||
|
||||
export class UpdatePricingSettingsDto {
|
||||
@@ -13,4 +13,11 @@ export class UpdatePricingSettingsDto {
|
||||
@ApiPropertyOptional({ enum: CurrencyDisplay, example: CurrencyDisplay.TOMAN })
|
||||
@IsEnum(CurrencyDisplay)
|
||||
defaultCurrencyDisplay: CurrencyDisplay;
|
||||
|
||||
@ApiPropertyOptional({ example: 10 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
taxPercent?: number;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,19 @@ export class Settings {
|
||||
})
|
||||
defaultCurrencyDisplay: CurrencyDisplay;
|
||||
|
||||
@Column({
|
||||
name: 'tax_percent',
|
||||
type: 'numeric',
|
||||
precision: 5,
|
||||
scale: 2,
|
||||
default: 0,
|
||||
transformer: {
|
||||
to: (value: number) => value,
|
||||
from: (value: string) => Number(value),
|
||||
},
|
||||
})
|
||||
taxPercent: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ export class SettingsService {
|
||||
this.settingsRepository.create({
|
||||
usdToIrrRate: 0,
|
||||
defaultCurrencyDisplay: CurrencyDisplay.IRR,
|
||||
taxPercent: 0,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -36,6 +37,9 @@ export class SettingsService {
|
||||
const settings = await this.getSettings();
|
||||
settings.usdToIrrRate = dto.usdToIrrRate;
|
||||
settings.defaultCurrencyDisplay = dto.defaultCurrencyDisplay;
|
||||
if (dto.taxPercent !== undefined) {
|
||||
settings.taxPercent = dto.taxPercent;
|
||||
}
|
||||
|
||||
const savedSettings = await this.settingsRepository.save(settings);
|
||||
await this.redisCacheService.deleteByPattern('prices:*');
|
||||
|
||||
@@ -337,6 +337,17 @@ export class UsersService {
|
||||
};
|
||||
}
|
||||
|
||||
async findAddressesForCheckout(userId: string) {
|
||||
return this.userAddressesRepository.find({
|
||||
where: { user: { id: userId } },
|
||||
order: { isDefault: 'DESC', createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findAddressForCheckout(userId: string, addressId: string) {
|
||||
return this.requireAddress(userId, addressId);
|
||||
}
|
||||
|
||||
async createAddress(userId: string, dto: CreateUserAddressDto) {
|
||||
const user = await this.requireUser(userId);
|
||||
const currentAddressesCount = await this.userAddressesRepository.count({
|
||||
|
||||
Reference in New Issue
Block a user