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 { CartModule } from './modules/cart/cart.module';
|
||||||
import { UserCartItem } from './modules/cart/entities/user-cart-item.entity';
|
import { UserCartItem } from './modules/cart/entities/user-cart-item.entity';
|
||||||
import { UserCart } from './modules/cart/entities/user-cart.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 { 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';
|
||||||
@@ -76,12 +78,14 @@ import { UsersModule } from './modules/users/users.module';
|
|||||||
PaymentMethodSetting,
|
PaymentMethodSetting,
|
||||||
UserCart,
|
UserCart,
|
||||||
UserCartItem,
|
UserCartItem,
|
||||||
|
ShippingMethod,
|
||||||
Ticket,
|
Ticket,
|
||||||
TicketMessage,
|
TicketMessage,
|
||||||
]),
|
]),
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
StorageModule,
|
StorageModule,
|
||||||
CartModule,
|
CartModule,
|
||||||
|
CheckoutModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
SettingsModule,
|
SettingsModule,
|
||||||
PricingModule,
|
PricingModule,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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 { UserCartItem } from '../modules/cart/entities/user-cart-item.entity';
|
||||||
import { UserCart } from '../modules/cart/entities/user-cart.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 { 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';
|
||||||
@@ -60,6 +61,7 @@ export const buildTypeOrmOptions = (
|
|||||||
PaymentMethodSetting,
|
PaymentMethodSetting,
|
||||||
UserCart,
|
UserCart,
|
||||||
UserCartItem,
|
UserCartItem,
|
||||||
|
ShippingMethod,
|
||||||
Ticket,
|
Ticket,
|
||||||
TicketMessage,
|
TicketMessage,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -200,17 +200,32 @@ export class CartService {
|
|||||||
: Math.round(irrPrice);
|
: 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) {
|
private serializeCart(cart: UserCart, pricingContext: PricingContext) {
|
||||||
const items = (cart.items ?? []).map((item) => {
|
const items = (cart.items ?? []).map((item) => {
|
||||||
|
const baseUnitPrice = this.buildBaseDisplayPrice(item.product, pricingContext);
|
||||||
const displayPrice = this.buildDisplayPrice(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 lineTotal = displayPrice * item.quantity;
|
||||||
|
const baseLineTotal = baseUnitPrice * item.quantity;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
createdAt: item.createdAt,
|
createdAt: item.createdAt,
|
||||||
updatedAt: item.updatedAt,
|
updatedAt: item.updatedAt,
|
||||||
|
baseUnitPrice,
|
||||||
unitPrice: displayPrice,
|
unitPrice: displayPrice,
|
||||||
|
baseLineTotal,
|
||||||
|
discountAmount,
|
||||||
lineTotal,
|
lineTotal,
|
||||||
currency_unit: pricingContext.currencyUnit,
|
currency_unit: pricingContext.currencyUnit,
|
||||||
currency_label: pricingContext.currencyLabel,
|
currency_label: pricingContext.currencyLabel,
|
||||||
@@ -236,7 +251,9 @@ export class CartService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const itemsCount = items.reduce((sum, item) => sum + item.quantity, 0);
|
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 subtotal = items.reduce((sum, item) => sum + item.lineTotal, 0);
|
||||||
|
const discountAmount = Math.max(0, baseSubtotal - subtotal);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: cart.id,
|
id: cart.id,
|
||||||
@@ -244,6 +261,8 @@ export class CartService {
|
|||||||
items,
|
items,
|
||||||
summary: {
|
summary: {
|
||||||
itemsCount,
|
itemsCount,
|
||||||
|
baseSubtotal,
|
||||||
|
discountAmount,
|
||||||
subtotal,
|
subtotal,
|
||||||
total: subtotal,
|
total: subtotal,
|
||||||
currency_unit: pricingContext.currencyUnit,
|
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 { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
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';
|
import { CurrencyDisplay } from '../enums/currency-display.enum';
|
||||||
|
|
||||||
export class UpdatePricingSettingsDto {
|
export class UpdatePricingSettingsDto {
|
||||||
@@ -13,4 +13,11 @@ export class UpdatePricingSettingsDto {
|
|||||||
@ApiPropertyOptional({ enum: CurrencyDisplay, example: CurrencyDisplay.TOMAN })
|
@ApiPropertyOptional({ enum: CurrencyDisplay, example: CurrencyDisplay.TOMAN })
|
||||||
@IsEnum(CurrencyDisplay)
|
@IsEnum(CurrencyDisplay)
|
||||||
defaultCurrencyDisplay: CurrencyDisplay;
|
defaultCurrencyDisplay: CurrencyDisplay;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 10 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
taxPercent?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,19 @@ export class Settings {
|
|||||||
})
|
})
|
||||||
defaultCurrencyDisplay: CurrencyDisplay;
|
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' })
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export class SettingsService {
|
|||||||
this.settingsRepository.create({
|
this.settingsRepository.create({
|
||||||
usdToIrrRate: 0,
|
usdToIrrRate: 0,
|
||||||
defaultCurrencyDisplay: CurrencyDisplay.IRR,
|
defaultCurrencyDisplay: CurrencyDisplay.IRR,
|
||||||
|
taxPercent: 0,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -36,6 +37,9 @@ export class SettingsService {
|
|||||||
const settings = await this.getSettings();
|
const settings = await this.getSettings();
|
||||||
settings.usdToIrrRate = dto.usdToIrrRate;
|
settings.usdToIrrRate = dto.usdToIrrRate;
|
||||||
settings.defaultCurrencyDisplay = dto.defaultCurrencyDisplay;
|
settings.defaultCurrencyDisplay = dto.defaultCurrencyDisplay;
|
||||||
|
if (dto.taxPercent !== undefined) {
|
||||||
|
settings.taxPercent = dto.taxPercent;
|
||||||
|
}
|
||||||
|
|
||||||
const savedSettings = await this.settingsRepository.save(settings);
|
const savedSettings = await this.settingsRepository.save(settings);
|
||||||
await this.redisCacheService.deleteByPattern('prices:*');
|
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) {
|
async createAddress(userId: string, dto: CreateUserAddressDto) {
|
||||||
const user = await this.requireUser(userId);
|
const user = await this.requireUser(userId);
|
||||||
const currentAddressesCount = await this.userAddressesRepository.count({
|
const currentAddressesCount = await this.userAddressesRepository.count({
|
||||||
|
|||||||
Reference in New Issue
Block a user