feat(checkout): add checkout summary preview with address, payment, shipping and tax calculation

This commit is contained in:
2026-04-03 16:24:14 +03:30
parent 68529a182f
commit 417fc927f5
12 changed files with 448 additions and 1 deletions

View File

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

View File

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

View File

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

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

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

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

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

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

View File

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

View File

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

View File

@@ -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:*');

View File

@@ -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({