From ff0d87c228928d967a0144a1246a8190711fe309 Mon Sep 17 00:00:00 2001 From: MinhhTien <92145479+MinhhTien@users.noreply.github.com> Date: Fri, 14 Jun 2024 12:45:41 +0700 Subject: [PATCH] feature:FUR-33 [BE][Web] API payment for upgrading GenAI Premium Plan (#116) --- src/ai-generation/ai-generation.module.ts | 18 +- src/ai-generation/contracts/constant.ts | 15 + .../controllers/pricing.controller.ts | 29 ++ src/ai-generation/dtos/pricing.dto.ts | 51 +++ src/ai-generation/services/pricing.service.ts | 75 ++++ src/order/services/order.service.ts | 6 +- src/payment/contracts/constant.ts | 5 + src/payment/controllers/payment.controller.ts | 6 +- src/payment/payment.module.ts | 4 +- src/payment/schemas/payment.schema.ts | 11 +- src/payment/services/payment.service.ts | 237 +------------ src/payment/strategies/momo.strategy.ts | 239 ++++++++++++- .../strategies/payment-strategy.interface.ts | 1 + src/payment/strategies/payos.strategy.ts | 327 +++++++++++++++++- src/payment/strategies/zalopay.strategy.ts | 1 + 15 files changed, 779 insertions(+), 246 deletions(-) create mode 100644 src/ai-generation/controllers/pricing.controller.ts create mode 100644 src/ai-generation/dtos/pricing.dto.ts create mode 100644 src/ai-generation/services/pricing.service.ts diff --git a/src/ai-generation/ai-generation.module.ts b/src/ai-generation/ai-generation.module.ts index 39ac054..6a46f13 100644 --- a/src/ai-generation/ai-generation.module.ts +++ b/src/ai-generation/ai-generation.module.ts @@ -8,6 +8,8 @@ import { AIGenerationTextToModelService } from './services/text-to-model.service import { AIGenerationRepository } from './repositories/ai-generation.repository' import { AIGenerationTextToImageController } from './controllers/text-to-image.controller' import { AIGenerationTextToImageService } from './services/text-to-image.service' +import { AIGenerationPricingService } from './services/pricing.service' +import { AIGenerationPricingController } from './controllers/pricing.controller' @Global() @Module({ @@ -16,8 +18,18 @@ import { AIGenerationTextToImageService } from './services/text-to-image.service HttpModule, CustomerModule ], - controllers: [AIGenerationTextToModelController, AIGenerationTextToImageController], - providers: [AIGenerationTextToModelService, AIGenerationTextToImageService, AIGenerationRepository], - exports: [AIGenerationTextToModelService, AIGenerationTextToImageService, AIGenerationRepository] + controllers: [AIGenerationTextToModelController, AIGenerationTextToImageController, AIGenerationPricingController], + providers: [ + AIGenerationTextToModelService, + AIGenerationTextToImageService, + AIGenerationPricingService, + AIGenerationRepository + ], + exports: [ + AIGenerationTextToModelService, + AIGenerationTextToImageService, + AIGenerationPricingService, + AIGenerationRepository + ] }) export class AIGenerationModule {} diff --git a/src/ai-generation/contracts/constant.ts b/src/ai-generation/contracts/constant.ts index 34e5989..1ceabed 100644 --- a/src/ai-generation/contracts/constant.ts +++ b/src/ai-generation/contracts/constant.ts @@ -33,4 +33,19 @@ export const DEFAULT_CREDITS = 50; export enum AIGenerationPricing { TEXT_TO_MODEL = 15, TEXT_TO_IMAGE = 10 +} + +export enum AIPricingPlan { + PERSONAL = 'PERSONAL', + PREMIUM = 'PREMIUM' +} + +export const AIPricingPlanCost = { + [AIPricingPlan.PERSONAL] : 2000, // 199000, + [AIPricingPlan.PREMIUM] : 499000 +} + +export const AIPricingPlanCredits = { + [AIPricingPlan.PERSONAL] : 250, + [AIPricingPlan.PREMIUM] : 600 } \ No newline at end of file diff --git a/src/ai-generation/controllers/pricing.controller.ts b/src/ai-generation/controllers/pricing.controller.ts new file mode 100644 index 0000000..8bb6698 --- /dev/null +++ b/src/ai-generation/controllers/pricing.controller.ts @@ -0,0 +1,29 @@ +import { Body, Controller, Post, Req, UseGuards } from '@nestjs/common' +import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger' +import * as _ from 'lodash' +import { Roles } from '@auth/decorators/roles.decorator' +import { UserRole } from '@common/contracts/constant' +import { RolesGuard } from '@auth/guards/roles.guard' +import { JwtAuthGuard } from '@auth/guards/jwt-auth.guard' +import { GenerateTextToImageDto, TextToImageResponseDto } from '@ai-generation/dtos/text-to-image.dto' +import { AIGenerationPricingService } from '@ai-generation/services/pricing.service' +import { CreateCreditPurchaseDto, CreditPurchaseResponseDto } from '@ai-generation/dtos/pricing.dto' + +@ApiTags('AIGeneration - Pricing') +@ApiBearerAuth() +@Roles(UserRole.CUSTOMER) +@UseGuards(JwtAuthGuard.ACCESS_TOKEN, RolesGuard) +@Controller('pricing') +export class AIGenerationPricingController { + constructor(private readonly aiGenerationPricingService: AIGenerationPricingService) {} + + @ApiOperation({ + summary: 'Create payment for credits purchase' + }) + @ApiOkResponse({ type: CreditPurchaseResponseDto }) + @Post() + createPaymentForCreditsPurchase(@Req() req, @Body() createCreditPurchaseDto: CreateCreditPurchaseDto) { + createCreditPurchaseDto.customerId = _.get(req, 'user._id') + return this.aiGenerationPricingService.createPayment(createCreditPurchaseDto) + } +} diff --git a/src/ai-generation/dtos/pricing.dto.ts b/src/ai-generation/dtos/pricing.dto.ts new file mode 100644 index 0000000..e2072a1 --- /dev/null +++ b/src/ai-generation/dtos/pricing.dto.ts @@ -0,0 +1,51 @@ +import { ApiProperty } from '@nestjs/swagger' +import { DataResponse } from '@common/contracts/openapi-builder' +import { IsEnum, IsNotEmpty, MaxLength } from 'class-validator' +import { AIPricingPlan } from '@ai-generation/contracts/constant' +import { PayOSStatus, PaymentMethod } from '@payment/contracts/constant' + +export class CreateCreditPurchaseDto { + @ApiProperty({ + enum: AIPricingPlan, + example: 'PERSONAL | PREMIUM' + }) + @IsNotEmpty() + @IsEnum(AIPricingPlan) + plan: string + + @ApiProperty({ enum: PaymentMethod, example: 'PAY_OS | MOMO' }) + @IsNotEmpty() + @IsEnum(PaymentMethod) + paymentMethod?: PaymentMethod + + customerId?: string +} + +export class CreditPurchaseDto { + @ApiProperty() + bin: string + @ApiProperty() + accountNumber: string + @ApiProperty() + accountName: string + @ApiProperty() + amount: number + @ApiProperty() + description: string + @ApiProperty() + orderCode: number + @ApiProperty() + currency: string + @ApiProperty() + paymentLinkId: string + @ApiProperty({ + enum: PayOSStatus + }) + status: PayOSStatus + @ApiProperty() + checkoutUrl: string + @ApiProperty() + qrCode: string +} + +export class CreditPurchaseResponseDto extends DataResponse(CreditPurchaseDto) {} \ No newline at end of file diff --git a/src/ai-generation/services/pricing.service.ts b/src/ai-generation/services/pricing.service.ts new file mode 100644 index 0000000..76a812d --- /dev/null +++ b/src/ai-generation/services/pricing.service.ts @@ -0,0 +1,75 @@ +import { Injectable } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { AIPricingPlanCost } from '@ai-generation/contracts/constant' +import { TransactionStatus } from '@common/contracts/constant' +import { CreateCreditPurchaseDto } from '@ai-generation/dtos/pricing.dto' +import { Connection } from 'mongoose' +import { InjectConnection } from '@nestjs/mongoose' +import { CheckoutRequestType, CheckoutResponseDataType, PaymentLinkDataType } from '@payos/node/lib/type' +import { CreateMomoPaymentResponse } from '@payment/dto/momo-payment.dto' +import { PaymentMethod, PaymentType } from '@payment/contracts/constant' +import { PaymentService } from '@payment/services/payment.service' +import { PaymentRepository } from '@payment/repositories/payment.repository' + +@Injectable() +export class AIGenerationPricingService { + constructor( + @InjectConnection() readonly connection: Connection, + private readonly configService: ConfigService, + private readonly paymentService: PaymentService, + private readonly paymentRepository: PaymentRepository + ) {} + + async createPayment(createCreditPurchaseDto: CreateCreditPurchaseDto) { + const { customerId, plan } = createCreditPurchaseDto + try { + // 1. Calculate plan cost + const totalAmount = AIPricingPlanCost[plan] + + // 2. Process transaction + let paymentResponseData: CreateMomoPaymentResponse | PaymentLinkDataType + let checkoutData: CreateMomoPaymentResponse | CheckoutResponseDataType + const MAX_VALUE = 9_007_199_254_740_991 + const MIM_VALUE = 1_000_000_000_000_000 + const orderCode = Math.floor(MIM_VALUE + Math.random() * (MAX_VALUE - MIM_VALUE)) + createCreditPurchaseDto['paymentMethod'] = PaymentMethod.PAY_OS + switch (createCreditPurchaseDto.paymentMethod) { + case PaymentMethod.PAY_OS: + default: + this.paymentService.setStrategy(PaymentMethod.PAY_OS) + const checkoutRequestType: CheckoutRequestType = { + orderCode: orderCode, + amount: totalAmount, + description: `FUR - Purchase credits`, + items: [ + { + name: plan, + quantity: 1, + price: totalAmount + } + ], + cancelUrl: `${this.configService.get('WEB_URL')}/pricing`, + returnUrl: `${this.configService.get('WEB_URL')}/ai` + } + checkoutData = await this.paymentService.createTransaction(checkoutRequestType) + paymentResponseData = await this.paymentService.getTransaction(checkoutData['orderCode']) + break + } + + // 3. Create payment + await this.paymentRepository.create({ + customerId, + transactionStatus: TransactionStatus.DRAFT, + transaction: paymentResponseData, + transactionHistory: [paymentResponseData], + paymentMethod: createCreditPurchaseDto.paymentMethod, + amount: totalAmount, + paymentType: PaymentType.AI + }) + return checkoutData + } catch (error) { + console.error(error) + throw error + } + } +} diff --git a/src/order/services/order.service.ts b/src/order/services/order.service.ts index 97cb3c1..4588665 100644 --- a/src/order/services/order.service.ts +++ b/src/order/services/order.service.ts @@ -12,7 +12,7 @@ import { CartService } from '@cart/services/cart.service' import { InjectConnection } from '@nestjs/mongoose' import { ProductRepository } from '@product/repositories/product.repository' import { PaymentRepository } from '@payment/repositories/payment.repository' -import { PaymentMethod } from '@payment/contracts/constant' +import { PaymentMethod, PaymentType } from '@payment/contracts/constant' import { PaymentService } from '@payment/services/payment.service' import { CreateMomoPaymentResponse, QueryMomoPaymentDto } from '@payment/dto/momo-payment.dto' import { ConfigService } from '@nestjs/config' @@ -174,11 +174,13 @@ export class OrderService { // 5. Create payment const payment = await this.paymentRepository.create( { + customerId: createOrderDto.customer?._id, transactionStatus: TransactionStatus.DRAFT, transaction: paymentResponseData, transactionHistory: [paymentResponseData], paymentMethod: createOrderDto.paymentMethod, - amount: totalAmount + amount: totalAmount, + paymentType: PaymentType.ORDER }, { session diff --git a/src/payment/contracts/constant.ts b/src/payment/contracts/constant.ts index 5b577b0..9d027f8 100644 --- a/src/payment/contracts/constant.ts +++ b/src/payment/contracts/constant.ts @@ -4,6 +4,11 @@ export enum PaymentMethod { ZALO_PAY = 'ZALO_PAY' } +export enum PaymentType { + ORDER = 'ORDER', + AI = 'AI' +} + export enum MomoResultCode { SUCCESS = 0, AUTHORIZED = 9000, diff --git a/src/payment/controllers/payment.controller.ts b/src/payment/controllers/payment.controller.ts index ef52339..1952863 100644 --- a/src/payment/controllers/payment.controller.ts +++ b/src/payment/controllers/payment.controller.ts @@ -44,7 +44,8 @@ export class PaymentController { if (!result) return false //2. Process webhook - return this.paymentService.processWebhook(PaymentMethod.MOMO, momoPaymentResponseDto) + this.paymentService.setStrategy(PaymentMethod.MOMO) + return this.paymentService.processWebhook(momoPaymentResponseDto) } @ApiOperation({ @@ -63,7 +64,8 @@ export class PaymentController { if (!result) return false //2. Process webhook - return this.paymentService.processWebhook(PaymentMethod.PAY_OS, webhookData) + this.paymentService.setStrategy(PaymentMethod.PAY_OS) + return this.paymentService.processWebhook(webhookData) } // @ApiOperation({ diff --git a/src/payment/payment.module.ts b/src/payment/payment.module.ts index 9f2e195..fd15008 100644 --- a/src/payment/payment.module.ts +++ b/src/payment/payment.module.ts @@ -11,6 +11,7 @@ import { OrderModule } from '@order/order.module' import { CartModule } from '@cart/cart.module' import { ProductModule } from '@product/product.module' import { PayOSPaymentStrategy } from '@payment/strategies/payos.strategy' +import { CustomerModule } from '@customer/customer.module' @Global() @Module({ @@ -19,7 +20,8 @@ import { PayOSPaymentStrategy } from '@payment/strategies/payos.strategy' HttpModule, OrderModule, CartModule, - ProductModule + ProductModule, + CustomerModule ], controllers: [PaymentController], providers: [PaymentService, PaymentRepository, ZaloPayPaymentStrategy, MomoPaymentStrategy, PayOSPaymentStrategy], diff --git a/src/payment/schemas/payment.schema.ts b/src/payment/schemas/payment.schema.ts index db1c2aa..585e3a5 100644 --- a/src/payment/schemas/payment.schema.ts +++ b/src/payment/schemas/payment.schema.ts @@ -3,7 +3,7 @@ import { HydratedDocument } from 'mongoose' import * as paginate from 'mongoose-paginate-v2' import { Transform } from 'class-transformer' import { TransactionStatus } from '@common/contracts/constant' -import { PaymentMethod } from '@payment/contracts/constant' +import { PaymentMethod, PaymentType } from '@payment/contracts/constant' export type PaymentDocument = HydratedDocument @@ -23,6 +23,9 @@ export class Payment { @Transform(({ value }) => value?.toString()) _id: string + @Prop({ type: String }) + customerId: string + @Prop({ enum: TransactionStatus, default: TransactionStatus.DRAFT }) transactionStatus: TransactionStatus @@ -38,6 +41,12 @@ export class Payment { }) paymentMethod: PaymentMethod + @Prop({ + enum: PaymentType, + default: PaymentType.ORDER + }) + paymentType: PaymentType + @Prop({ type: Number, required: true }) amount: number } diff --git a/src/payment/services/payment.service.ts b/src/payment/services/payment.service.ts index ab4999c..6966c50 100644 --- a/src/payment/services/payment.service.ts +++ b/src/payment/services/payment.service.ts @@ -1,22 +1,14 @@ import { Injectable, Logger } from '@nestjs/common' -import { get } from 'lodash' import { IPaymentStrategy } from '@payment/strategies/payment-strategy.interface' import { MomoPaymentResponseDto, QueryMomoPaymentDto, RefundMomoPaymentDto } from '@payment/dto/momo-payment.dto' import { MomoPaymentStrategy } from '@payment/strategies/momo.strategy' import { InjectConnection } from '@nestjs/mongoose' import { Connection, FilterQuery } from 'mongoose' -import { OrderRepository } from '@order/repositories/order.repository' -import { CartService } from '@cart/services/cart.service' -import { ProductRepository } from '@product/repositories/product.repository' import { PaymentRepository } from '@payment/repositories/payment.repository' -import { AppException } from '@common/exceptions/app.exception' -import { Errors } from '@common/contracts/error' -import { OrderHistoryDto } from '@order/schemas/order.schema' -import { OrderStatus, TransactionStatus, UserRole } from '@common/contracts/constant' -import { MomoResultCode, PayOSResultCode, PaymentMethod } from '@payment/contracts/constant' +import { TransactionStatus } from '@common/contracts/constant' +import { PaymentMethod } from '@payment/contracts/constant' import { PaginationParams } from '@common/decorators/pagination.decorator' import { Payment } from '@payment/schemas/payment.schema' -import { MailerService } from '@nestjs-modules/mailer' import { PayOSPaymentStrategy } from '@payment/strategies/payos.strategy' import { WebhookType as PayOSWebhookData } from '@payos/node/lib/type' import { ZaloPayPaymentStrategy } from '@payment/strategies/zalopay.strategy' @@ -27,14 +19,10 @@ export class PaymentService { private readonly logger = new Logger(PaymentService.name) constructor( @InjectConnection() readonly connection: Connection, - private readonly orderRepository: OrderRepository, - private readonly cartService: CartService, - private readonly productRepository: ProductRepository, private readonly paymentRepository: PaymentRepository, private readonly momoPaymentStrategy: MomoPaymentStrategy, private readonly zaloPayPaymentStrategy: ZaloPayPaymentStrategy, - readonly payOSPaymentStrategy: PayOSPaymentStrategy, - private readonly mailerService: MailerService + private readonly payOSPaymentStrategy: PayOSPaymentStrategy ) {} public setStrategy(paymentMethod: PaymentMethod) { @@ -88,223 +76,8 @@ export class PaymentService { return result } - public async processWebhook(paymentMethod: PaymentMethod, webhookData: MomoPaymentResponseDto | PayOSWebhookData) { + public async processWebhook(webhookData: MomoPaymentResponseDto | PayOSWebhookData) { this.logger.log('processWebhook::', JSON.stringify(webhookData)) - // Execute in transaction - const session = await this.connection.startSession() - session.startTransaction() - try { - // 1. Get order from orderId - const orderId = get(webhookData, 'data.orderCode', get(webhookData, 'orderId')) - console.log('orderId', orderId, typeof orderId) - const order = await this.orderRepository.findOne({ - conditions: { - orderId: String(orderId) - }, - projection: '+items' - }) - if (!order) throw new AppException(Errors.ORDER_NOT_FOUND) - this.logger.log('processWebhook: order', JSON.stringify(order)) - - const isPaymentSuccess = - get(webhookData, 'code') === PayOSResultCode.SUCCESS || - get(webhookData, 'resultCode') === MomoResultCode.SUCCESS - if (isPaymentSuccess) { - this.logger.log('processWebhook: payment SUCCESS') - // Payment success - // 1. Fetch product in cart items - const { _id: cartId, items, totalAmount: cartTotalAmount } = await this.cartService.getCart(order.customer._id) - if (items.length === 0) throw new AppException(Errors.CART_EMPTY) - let cartItems = items - let totalAmount = 0 - let orderItems = order.items - // array to process bulk update - const operations = [] - - orderItems = orderItems.map((orderItem) => { - // 2. Check valid dto with cartItems - const index = cartItems.findIndex((cartItem) => { - return cartItem.productId == orderItem.productId && cartItem.sku === orderItem.sku - }) - if (index === -1) throw new AppException(Errors.ORDER_ITEMS_INVALID) - - const { product, quantity } = cartItems[index] - const variant = product?.variants?.find((variant) => variant.sku === orderItem.sku) - if (!variant) throw new AppException(Errors.ORDER_ITEMS_INVALID) - - // 3. Check remain quantity in inventory - const { sku, quantity: remainQuantity, price } = variant - if (quantity > remainQuantity) throw new AppException(Errors.ORDER_ITEMS_INVALID) - totalAmount += price * quantity - - // 4. Subtract items in cart - cartItems.splice(index, 1) - - // 5. Push update quantity in product.variants to operation to execute later - operations.push({ - updateOne: { - filter: { 'variants.sku': sku }, - update: { $set: { 'variants.$.quantity': remainQuantity - quantity } }, - session - } - }) - - return { - ...orderItem, - quantity, - product: product.toJSON() - } - }) - - // 5. Update new cart - cartItems = cartItems.map((item) => { - delete item.product // remove product populate before update - return item - }) - await this.cartService.cartRepository.findOneAndUpdate( - { - _id: cartId - }, - { - items: cartItems, - totalAmount: cartTotalAmount - totalAmount - }, - { - session - } - ) - - // 6. Bulk write Update quantity in product.variants - await this.productRepository.model.bulkWrite(operations) - - // 7. Update payment transactionStatus, transaction - let transaction - switch (paymentMethod) { - case PaymentMethod.MOMO: - transaction = webhookData - case PaymentMethod.PAY_OS: - this.setStrategy(PaymentMethod.PAY_OS) - transaction = await this.getTransaction(get(webhookData, 'data.orderCode')) - break - } - - const payment = await this.paymentRepository.findOneAndUpdate( - { - _id: order.payment._id - }, - { - $set: { - transactionStatus: TransactionStatus.CAPTURED, - transaction: transaction - }, - $push: { transactionHistory: transaction } - }, - { - session, - new: true - } - ) - - // 8. Update order transactionStatus - const orderHistory = new OrderHistoryDto( - OrderStatus.PENDING, - TransactionStatus.CAPTURED, - order.customer._id, - UserRole.CUSTOMER - ) - await this.orderRepository.findOneAndUpdate( - { - _id: order._id - }, - { - $set: { - transactionStatus: TransactionStatus.CAPTURED, - payment - }, - $push: { orderHistory } - }, - { - session - } - ) - // 9. Send email/notification to customer - await this.mailerService.sendMail({ - to: order.customer.email, - subject: `[Furnique] Đã nhận đơn hàng #${order.orderId}`, - template: 'order-created', - context: { - ...order.toJSON(), - _id: order._id, - orderId: order.orderId, - customer: order.customer, - items: order.items.map((item) => { - const variant = item.product.variants.find((variant) => variant.sku === item.sku) - return { - ...item, - product: { - ...item.product, - variant: { - ...variant, - price: Intl.NumberFormat('en-DE').format(variant.price) - } - } - } - }), - totalAmount: Intl.NumberFormat('en-DE').format(order.totalAmount) - } - }) - // 10. Send notification to staff - } else { - // Payment failed - this.logger.log('processWebhook: payment FAILED') - // 1. Update payment transactionStatus, transaction - const payment = await this.paymentRepository.findOneAndUpdate( - { - _id: order.payment._id - }, - { - $set: { - transactionStatus: TransactionStatus.ERROR, - transaction: webhookData, - transactionHistory: [webhookData] - } - }, - { - session, - new: true - } - ) - - // 1. Update order transactionStatus - const orderHistory = new OrderHistoryDto( - OrderStatus.PENDING, - TransactionStatus.ERROR, - order.customer._id, - UserRole.CUSTOMER - ) - await this.orderRepository.findOneAndUpdate( - { - _id: order._id - }, - { - $set: { - transactionStatus: TransactionStatus.ERROR, - payment: payment - }, - $push: { orderHistory } - }, - { - session - } - ) - } - await session.commitTransaction() - this.logger.log('processWebhook: SUCCESS!!!') - return true - } catch (error) { - await session.abortTransaction() - this.logger.error('processWebhook: catch', JSON.stringify(error)) - throw error - } + return this.strategy.processWebhook(webhookData) } } diff --git a/src/payment/strategies/momo.strategy.ts b/src/payment/strategies/momo.strategy.ts index 74f1221..65491a9 100644 --- a/src/payment/strategies/momo.strategy.ts +++ b/src/payment/strategies/momo.strategy.ts @@ -1,10 +1,28 @@ +import { CartService } from '@cart/services/cart.service' +import { OrderStatus, TransactionStatus, UserRole } from '@common/contracts/constant' +import { Errors } from '@common/contracts/error' +import { AppException } from '@common/exceptions/app.exception' import { HelperService } from '@common/services/helper.service' +import { MailerService } from '@nestjs-modules/mailer' import { HttpService } from '@nestjs/axios' import { Injectable, Logger } from '@nestjs/common' import { ConfigService } from '@nestjs/config' -import { CreateMomoPaymentDto, QueryMomoPaymentDto, RefundMomoPaymentDto } from '@payment/dto/momo-payment.dto' +import { InjectConnection } from '@nestjs/mongoose' +import { OrderRepository } from '@order/repositories/order.repository' +import { OrderHistoryDto } from '@order/schemas/order.schema' +import { MomoResultCode } from '@payment/contracts/constant' +import { + CreateMomoPaymentDto, + MomoPaymentResponseDto, + QueryMomoPaymentDto, + RefundMomoPaymentDto +} from '@payment/dto/momo-payment.dto' +import { PaymentRepository } from '@payment/repositories/payment.repository' import { IPaymentStrategy } from '@payment/strategies/payment-strategy.interface' +import { ProductRepository } from '@product/repositories/product.repository' import { AxiosError } from 'axios' +import { get } from 'lodash' +import { Connection } from 'mongoose' import { catchError, firstValueFrom } from 'rxjs' @Injectable() @@ -12,9 +30,15 @@ export class MomoPaymentStrategy implements IPaymentStrategy { private readonly logger = new Logger(MomoPaymentStrategy.name) private config constructor( + @InjectConnection() readonly connection: Connection, + private readonly orderRepository: OrderRepository, + private readonly cartService: CartService, + private readonly productRepository: ProductRepository, + private readonly paymentRepository: PaymentRepository, private readonly httpService: HttpService, private readonly configService: ConfigService, - private readonly helperService: HelperService + private readonly helperService: HelperService, + private readonly mailerService: MailerService ) { this.config = this.configService.get('payment.momo') } @@ -103,6 +127,217 @@ export class MomoPaymentStrategy implements IPaymentStrategy { return data } + async processWebhook(webhookData: MomoPaymentResponseDto) { + // Execute in transaction + const session = await this.connection.startSession() + session.startTransaction() + try { + // 1. Get order from orderId + const orderId = get(webhookData, 'orderId') + console.log('orderId', orderId, typeof orderId) + + const order = await this.orderRepository.findOne({ + conditions: { + orderId: String(orderId) + }, + projection: '+items' + }) + if (!order) throw new AppException(Errors.ORDER_NOT_FOUND) + this.logger.log('processWebhook: order', JSON.stringify(order)) + + const isPaymentSuccess = get(webhookData, 'resultCode') === MomoResultCode.SUCCESS + if (isPaymentSuccess) { + this.logger.log('processWebhook: payment SUCCESS') + // Payment success + // 1. Fetch product in cart items + const { _id: cartId, items, totalAmount: cartTotalAmount } = await this.cartService.getCart(order.customer._id) + if (items.length === 0) throw new AppException(Errors.CART_EMPTY) + let cartItems = items + let totalAmount = 0 + let orderItems = order.items + // array to process bulk update + const operations = [] + + orderItems = orderItems.map((orderItem) => { + // 2. Check valid dto with cartItems + const index = cartItems.findIndex((cartItem) => { + return cartItem.productId == orderItem.productId && cartItem.sku === orderItem.sku + }) + if (index === -1) throw new AppException(Errors.ORDER_ITEMS_INVALID) + + const { product, quantity } = cartItems[index] + const variant = product?.variants?.find((variant) => variant.sku === orderItem.sku) + if (!variant) throw new AppException(Errors.ORDER_ITEMS_INVALID) + + // 3. Check remain quantity in inventory + const { sku, quantity: remainQuantity, price } = variant + if (quantity > remainQuantity) throw new AppException(Errors.ORDER_ITEMS_INVALID) + totalAmount += price * quantity + + // 4. Subtract items in cart + cartItems.splice(index, 1) + + // 5. Push update quantity in product.variants to operation to execute later + operations.push({ + updateOne: { + filter: { 'variants.sku': sku }, + update: { $set: { 'variants.$.quantity': remainQuantity - quantity } }, + session + } + }) + + return { + ...orderItem, + quantity, + product: product.toJSON() + } + }) + + // 5. Update new cart + cartItems = cartItems.map((item) => { + delete item.product // remove product populate before update + return item + }) + await this.cartService.cartRepository.findOneAndUpdate( + { + _id: cartId + }, + { + items: cartItems, + totalAmount: cartTotalAmount - totalAmount + }, + { + session + } + ) + + // 6. Bulk write Update quantity in product.variants + await this.productRepository.model.bulkWrite(operations) + + // 7. Update payment transactionStatus, transaction + const transaction = webhookData + + const payment = await this.paymentRepository.findOneAndUpdate( + { + _id: order.payment._id + }, + { + $set: { + transactionStatus: TransactionStatus.CAPTURED, + transaction: transaction + }, + $push: { transactionHistory: transaction } + }, + { + session, + new: true + } + ) + + // 8. Update order transactionStatus + const orderHistory = new OrderHistoryDto( + OrderStatus.PENDING, + TransactionStatus.CAPTURED, + order.customer._id, + UserRole.CUSTOMER + ) + await this.orderRepository.findOneAndUpdate( + { + _id: order._id + }, + { + $set: { + transactionStatus: TransactionStatus.CAPTURED, + payment + }, + $push: { orderHistory } + }, + { + session + } + ) + // 9. Send email/notification to customer + await this.mailerService.sendMail({ + to: order.customer.email, + subject: `[Furnique] Đã nhận đơn hàng #${order.orderId}`, + template: 'order-created', + context: { + ...order.toJSON(), + _id: order._id, + orderId: order.orderId, + customer: order.customer, + items: order.items.map((item) => { + const variant = item.product.variants.find((variant) => variant.sku === item.sku) + return { + ...item, + product: { + ...item.product, + variant: { + ...variant, + price: Intl.NumberFormat('en-DE').format(variant.price) + } + } + } + }), + totalAmount: Intl.NumberFormat('en-DE').format(order.totalAmount) + } + }) + // 10. Send notification to staff + } else { + // Payment failed + this.logger.log('processWebhook: payment FAILED') + // 1. Update payment transactionStatus, transaction + const payment = await this.paymentRepository.findOneAndUpdate( + { + _id: order.payment._id + }, + { + $set: { + transactionStatus: TransactionStatus.ERROR, + transaction: webhookData, + transactionHistory: [webhookData] + } + }, + { + session, + new: true + } + ) + + // 1. Update order transactionStatus + const orderHistory = new OrderHistoryDto( + OrderStatus.PENDING, + TransactionStatus.ERROR, + order.customer._id, + UserRole.CUSTOMER + ) + await this.orderRepository.findOneAndUpdate( + { + _id: order._id + }, + { + $set: { + transactionStatus: TransactionStatus.ERROR, + payment: payment + }, + $push: { orderHistory } + }, + { + session + } + ) + } + + await session.commitTransaction() + this.logger.log('processWebhook: SUCCESS!!!') + return true + } catch (error) { + await session.abortTransaction() + this.logger.error('processWebhook: catch', JSON.stringify(error)) + throw error + } + } + verifyPaymentWebhookData(momoPaymentResponseDto: any): boolean { const { partnerCode, diff --git a/src/payment/strategies/payment-strategy.interface.ts b/src/payment/strategies/payment-strategy.interface.ts index b6ec56e..9797c33 100644 --- a/src/payment/strategies/payment-strategy.interface.ts +++ b/src/payment/strategies/payment-strategy.interface.ts @@ -3,5 +3,6 @@ export interface IPaymentStrategy { getTransaction(queryDto: any): any refundTransaction(refundDto: any): any getRefundTransaction(queryRefundDto: any): any + processWebhook(webhookData: any): any verifyPaymentWebhookData(webhookData: any): any } diff --git a/src/payment/strategies/payos.strategy.ts b/src/payment/strategies/payos.strategy.ts index 9e7d17f..0d6506b 100644 --- a/src/payment/strategies/payos.strategy.ts +++ b/src/payment/strategies/payos.strategy.ts @@ -1,6 +1,18 @@ +import { AIPricingPlan, AIPricingPlanCost, AIPricingPlanCredits } from '@ai-generation/contracts/constant' +import { CartService } from '@cart/services/cart.service' +import { OrderStatus, TransactionStatus, UserRole } from '@common/contracts/constant' +import { Errors } from '@common/contracts/error' +import { AppException } from '@common/exceptions/app.exception' +import { CustomerRepository } from '@customer/repositories/customer.repository' +import { MailerService } from '@nestjs-modules/mailer' import { Injectable, Logger, OnModuleInit } from '@nestjs/common' import { ConfigService } from '@nestjs/config' +import { InjectConnection } from '@nestjs/mongoose' +import { OrderRepository } from '@order/repositories/order.repository' +import { OrderHistoryDto } from '@order/schemas/order.schema' +import { PayOSResultCode, PaymentType } from '@payment/contracts/constant' import { PayOSRefundTransactionDto } from '@payment/dto/payos-payment.dto' +import { PaymentRepository } from '@payment/repositories/payment.repository' const PayOS = require('@payos/node') import { IPaymentStrategy } from '@payment/strategies/payment-strategy.interface' import { @@ -10,13 +22,25 @@ import { WebhookDataType, WebhookType } from '@payos/node/lib/type' +import { ProductRepository } from '@product/repositories/product.repository' +import { get } from 'lodash' +import { Connection } from 'mongoose' @Injectable() export class PayOSPaymentStrategy implements IPaymentStrategy, OnModuleInit { private readonly logger = new Logger(PayOSPaymentStrategy.name) private config private payOS - constructor(private readonly configService: ConfigService) { + constructor( + private readonly configService: ConfigService, + @InjectConnection() readonly connection: Connection, + private readonly orderRepository: OrderRepository, + private readonly cartService: CartService, + private readonly productRepository: ProductRepository, + private readonly paymentRepository: PaymentRepository, + private readonly customerRepository: CustomerRepository, + private readonly mailerService: MailerService + ) { this.config = this.configService.get('payment.payos') this.payOS = new PayOS(this.config.clientId, this.config.apiKey, this.config.checksumKey) } @@ -33,13 +57,13 @@ export class PayOSPaymentStrategy implements IPaymentStrategy, OnModuleInit { // const paymentLink = await this.payOS.getPaymentLinkInformation('1234567') // console.log('paymentLink', paymentLink) } catch (err) { - console.log(err) + this.logger.log(err) } } async verifyWebhookUrl() { const result = await this.payOS.confirmWebhook(`${this.configService.get('SERVER_URL')}/payment/webhook/payos`) - console.log(result) + this.logger.log(result) } async createTransaction(checkoutRequestType: CheckoutRequestType): Promise { @@ -56,7 +80,304 @@ export class PayOSPaymentStrategy implements IPaymentStrategy, OnModuleInit { async getRefundTransaction(queryDto: any) {} + async processWebhook(webhookData: WebhookType) { + // Execute in transaction + const session = await this.connection.startSession() + session.startTransaction() + try { + // 1. Get order from orderId + const orderId = get(webhookData, 'data.orderCode') + this.logger.log('processWebhook: orderId ', orderId) + + const payment = await this.paymentRepository.findOne({ + conditions: { + 'transaction.orderCode': orderId + } + }) + switch (payment?.paymentType) { + case PaymentType.ORDER: + await this.processWebhookOrder({ orderId, webhookData, session }) + break + case PaymentType.AI: + await this.processWebhookAI({ orderId, webhookData, customerId: payment?.customerId, session }) + break + } + await session.commitTransaction() + this.logger.log('processWebhook: SUCCESS!!!') + return true + } catch (error) { + await session.abortTransaction() + this.logger.error('processWebhook: catch', JSON.stringify(error)) + throw error + } + } + verifyPaymentWebhookData(webhookBody: WebhookType): WebhookDataType | null { return this.payOS.verifyPaymentWebhookData(webhookBody) } + + private async processWebhookOrder({ orderId, webhookData, session }) { + const order = await this.orderRepository.findOne({ + conditions: { + orderId: String(orderId) + }, + projection: '+items' + }) + if (!order) throw new AppException(Errors.ORDER_NOT_FOUND) + this.logger.log('processWebhook: order', JSON.stringify(order)) + + const isPaymentSuccess = get(webhookData, 'code') === PayOSResultCode.SUCCESS + if (isPaymentSuccess) { + this.logger.log('processWebhook: payment SUCCESS') + // Payment success + // 1. Fetch product in cart items + const { _id: cartId, items, totalAmount: cartTotalAmount } = await this.cartService.getCart(order.customer._id) + if (items.length === 0) throw new AppException(Errors.CART_EMPTY) + let cartItems = items + let totalAmount = 0 + let orderItems = order.items + // array to process bulk update + const operations = [] + + orderItems = orderItems.map((orderItem) => { + // 2. Check valid dto with cartItems + const index = cartItems.findIndex((cartItem) => { + return cartItem.productId == orderItem.productId && cartItem.sku === orderItem.sku + }) + if (index === -1) throw new AppException(Errors.ORDER_ITEMS_INVALID) + + const { product, quantity } = cartItems[index] + const variant = product?.variants?.find((variant) => variant.sku === orderItem.sku) + if (!variant) throw new AppException(Errors.ORDER_ITEMS_INVALID) + + // 3. Check remain quantity in inventory + const { sku, quantity: remainQuantity, price } = variant + if (quantity > remainQuantity) throw new AppException(Errors.ORDER_ITEMS_INVALID) + totalAmount += price * quantity + + // 4. Subtract items in cart + cartItems.splice(index, 1) + + // 5. Push update quantity in product.variants to operation to execute later + operations.push({ + updateOne: { + filter: { 'variants.sku': sku }, + update: { $set: { 'variants.$.quantity': remainQuantity - quantity } }, + session + } + }) + + return { + ...orderItem, + quantity, + product: product.toJSON() + } + }) + + // 5. Update new cart + cartItems = cartItems.map((item) => { + delete item.product // remove product populate before update + return item + }) + await this.cartService.cartRepository.findOneAndUpdate( + { + _id: cartId + }, + { + items: cartItems, + totalAmount: cartTotalAmount - totalAmount + }, + { + session + } + ) + + // 6. Bulk write Update quantity in product.variants + await this.productRepository.model.bulkWrite(operations) + + // 7. Update payment transactionStatus, transaction + const transaction = webhookData + + const payment = await this.paymentRepository.findOneAndUpdate( + { + _id: order.payment._id + }, + { + $set: { + transactionStatus: TransactionStatus.CAPTURED, + transaction: transaction + }, + $push: { transactionHistory: transaction } + }, + { + session, + new: true + } + ) + + // 8. Update order transactionStatus + const orderHistory = new OrderHistoryDto( + OrderStatus.PENDING, + TransactionStatus.CAPTURED, + order.customer._id, + UserRole.CUSTOMER + ) + await this.orderRepository.findOneAndUpdate( + { + _id: order._id + }, + { + $set: { + transactionStatus: TransactionStatus.CAPTURED, + payment + }, + $push: { orderHistory } + }, + { + session + } + ) + // 9. Send email/notification to customer + await this.mailerService.sendMail({ + to: order.customer.email, + subject: `[Furnique] Đã nhận đơn hàng #${order.orderId}`, + template: 'order-created', + context: { + ...order.toJSON(), + _id: order._id, + orderId: order.orderId, + customer: order.customer, + items: order.items.map((item) => { + const variant = item.product.variants.find((variant) => variant.sku === item.sku) + return { + ...item, + product: { + ...item.product, + variant: { + ...variant, + price: Intl.NumberFormat('en-DE').format(variant.price) + } + } + } + }), + totalAmount: Intl.NumberFormat('en-DE').format(order.totalAmount) + } + }) + // 10. Send notification to staff + } else { + // Payment failed + this.logger.log('processWebhook: payment FAILED') + // 1. Update payment transactionStatus, transaction + const payment = await this.paymentRepository.findOneAndUpdate( + { + _id: order.payment._id + }, + { + $set: { + transactionStatus: TransactionStatus.ERROR, + transaction: webhookData, + transactionHistory: [webhookData] + } + }, + { + session, + new: true + } + ) + + // 1. Update order transactionStatus + const orderHistory = new OrderHistoryDto( + OrderStatus.PENDING, + TransactionStatus.ERROR, + order.customer._id, + UserRole.CUSTOMER + ) + await this.orderRepository.findOneAndUpdate( + { + _id: order._id + }, + { + $set: { + transactionStatus: TransactionStatus.ERROR, + payment: payment + }, + $push: { orderHistory } + }, + { + session + } + ) + } + } + + private async processWebhookAI({ orderId, webhookData, customerId, session }) { + const isPaymentSuccess = get(webhookData, 'code') === PayOSResultCode.SUCCESS + if (isPaymentSuccess) { + this.logger.log('processWebhook: payment SUCCESS') + // Payment success + // 1. Update payment transactionStatus, transaction + const transaction = await this.getTransaction(get(webhookData, 'data.orderCode')) + await this.paymentRepository.findOneAndUpdate( + { + 'transaction.orderCode': orderId + }, + { + $set: { + transactionStatus: TransactionStatus.CAPTURED, + transaction: transaction + }, + $push: { transactionHistory: transaction } + }, + { + session, + new: true + } + ) + + // 2. Update credits for customer + let credits = 0 + switch (get(webhookData, 'data.amount')) { + case AIPricingPlanCost[AIPricingPlan.PERSONAL]: + credits = AIPricingPlanCredits[AIPricingPlan.PERSONAL] + break + case AIPricingPlanCost[AIPricingPlan.PREMIUM]: + credits = AIPricingPlanCredits[AIPricingPlan.PREMIUM] + break + } + this.logger.log('processWebhook: credits ', credits) + await this.customerRepository.findOneAndUpdate( + { _id: customerId }, + { + $inc: { + credits + } + } + ) + + // 3. Send email/notification to customer + // 4. Send notification to staff + } else { + // Payment failed + this.logger.log('processWebhook: payment FAILED') + // 1. Update payment transactionStatus, transaction + await this.paymentRepository.findOneAndUpdate( + { + 'transaction.orderCode': orderId + }, + { + $set: { + transactionStatus: TransactionStatus.ERROR, + transaction: webhookData, + transactionHistory: [webhookData] + } + }, + { + session, + new: true + } + ) + + // 2. No update credits for customer + } + } } diff --git a/src/payment/strategies/zalopay.strategy.ts b/src/payment/strategies/zalopay.strategy.ts index caa721b..4c1ea0c 100644 --- a/src/payment/strategies/zalopay.strategy.ts +++ b/src/payment/strategies/zalopay.strategy.ts @@ -7,5 +7,6 @@ export class ZaloPayPaymentStrategy implements IPaymentStrategy { getTransaction(queryDto: any): any {} refundTransaction(refundDto: any): any {} getRefundTransaction(queryRefundDto: any): any {} + processWebhook(webhookData: any) {} verifyPaymentWebhookData(webhookData: any): any {} }