diff --git a/example.env b/example.env index a966341..d5d34a6 100644 --- a/example.env +++ b/example.env @@ -38,3 +38,8 @@ ZALOPAY_APP_ID= ZALOPAY_KEY1= ZALOPAY_KEY2= ZALOPAY_ENDPOINT= + +#PAYOS +PAYOS_CLIENT_ID= +PAYOS_API_KEY= +PAYOS_CHECKSUM_KEY= \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7f69142..d32dab3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "efurniture-api", - "version": "0.0.1", + "name": "furnique-api", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "efurniture-api", - "version": "0.0.1", + "name": "furnique-api", + "version": "1.0.0", "license": "UNLICENSED", "dependencies": { "@nestjs-modules/mailer": "^1.10.3", @@ -19,6 +19,7 @@ "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.0.0", "@nestjs/swagger": "^7.2.0", + "@payos/node": "^1.0.6", "@types/moment": "^2.13.0", "axios": "^1.6.5", "bcrypt": "^5.1.1", @@ -2119,6 +2120,15 @@ "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==" }, + "node_modules/@payos/node": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@payos/node/-/node-1.0.6.tgz", + "integrity": "sha512-AdcQ0oldvVAFcz09w50HPr1T9Jv3yzqxO8nQXtpHTMYjoA+GSik6fsmfVSEExW/u4ijcCvfOsCNAlVnV4fWRYQ==", + "dependencies": { + "axios": "^1.5.0", + "crypto": "^1.0.1" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -4230,6 +4240,12 @@ "node": ">= 8" } }, + "node_modules/crypto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/crypto/-/crypto-1.0.1.tgz", + "integrity": "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==", + "deprecated": "This package is no longer supported. It's now a built-in Node module. If you've depended on crypto, you should switch to the one that's built-in." + }, "node_modules/css-inline": { "version": "0.11.2", "resolved": "https://registry.npmjs.org/css-inline/-/css-inline-0.11.2.tgz", diff --git a/package.json b/package.json index fa9574d..547e4ef 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "efurniture-api", - "version": "0.0.1", - "description": "efurniture API", + "name": "furnique-api", + "version": "1.0.0", + "description": "furnique API", "author": "Deco-Team", "license": "UNLICENSED", "scripts": { @@ -29,6 +29,7 @@ "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.0.0", "@nestjs/swagger": "^7.2.0", + "@payos/node": "^1.0.6", "@types/moment": "^2.13.0", "axios": "^1.6.5", "bcrypt": "^5.1.1", diff --git a/src/app.service.ts b/src/app.service.ts index d260dc6..23be18b 100644 --- a/src/app.service.ts +++ b/src/app.service.ts @@ -6,7 +6,7 @@ export class AppService { constructor(private readonly i18nService: I18nService) {} getHello(): string { - return 'Welcome to eFurniture!' + return 'Welcome to Furnique!' } getI18nText(): string { diff --git a/src/config/index.ts b/src/config/index.ts index 47f6c11..bac401d 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -24,6 +24,11 @@ export default () => ({ key1: process.env.ZALOPAY_KEY1, key2: process.env.ZALOPAY_KEY2, endpoint: process.env.ZALOPAY_ENDPOINT + }, + payos: { + clientId: process.env.PAYOS_CLIENT_ID, + apiKey: process.env.PAYOS_API_KEY, + checksumKey: process.env.PAYOS_CHECKSUM_KEY, } }, JWT_ACCESS_SECRET: process.env.JWT_ACCESS_SECRET || 'accessSecret', diff --git a/src/main.ts b/src/main.ts index a82e5aa..d1dc79b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -23,7 +23,7 @@ async function bootstrap() { // add api-docs if (process.env.NODE_ENV === 'development') { const config = new DocumentBuilder() - .setTitle('eFurniture Swagger') + .setTitle('Furnique Swagger') .setDescription('Nestjs API documentation') .setVersion(process.env.npm_package_version || '1.0.0') .addBearerAuth() @@ -58,6 +58,6 @@ async function bootstrap() { const port = process.env.PORT || 5000 await app.listen(port) - logger.debug(`🚕 ==>> eFurniture Server is running on port ${port} <<== 🚖`) + logger.debug(`🚕 ==>> Furnique Server is running on port ${port} <<== 🚖`) } bootstrap() diff --git a/src/order/controllers/customer.controller.ts b/src/order/controllers/customer.controller.ts index 5f713a1..2075bbb 100644 --- a/src/order/controllers/customer.controller.ts +++ b/src/order/controllers/customer.controller.ts @@ -12,7 +12,7 @@ import { OrderService } from '@order/services/order.service' import { OrderHistoryDto } from '@order/schemas/order.schema' import { Pagination, PaginationParams } from '@common/decorators/pagination.decorator' import { DataResponse } from '@common/contracts/openapi-builder' -import { CreateMomoPaymentResponseDto } from '@payment/dto/momo-payment.dto' +import { CreatePayOSPaymentResponseDto } from '@payment/dto/payos-payment.dto' @ApiTags('Order - Customer') @ApiBearerAuth() @@ -26,7 +26,7 @@ export class OrderCustomerController { @ApiOperation({ summary: 'Create new order(orderStatus: PENDING, transactionStatus: DRAFT)' }) - @ApiOkResponse({ type: CreateMomoPaymentResponseDto }) + @ApiOkResponse({ type: CreatePayOSPaymentResponseDto }) @ApiBadRequestResponse({ type: ErrorResponse }) async createOrder(@Req() req, @Body() createOrderDto: CreateOrderDto) { const { _id, role } = _.get(req, 'user') diff --git a/src/order/services/order.service.ts b/src/order/services/order.service.ts index 5524d1a..a54523f 100644 --- a/src/order/services/order.service.ts +++ b/src/order/services/order.service.ts @@ -14,14 +14,10 @@ import { ProductRepository } from '@product/repositories/product.repository' import { PaymentRepository } from '@payment/repositories/payment.repository' import { PaymentMethod } from '@payment/contracts/constant' import { PaymentService } from '@payment/services/payment.service' -import { - CreateMomoPaymentDto, - CreateMomoPaymentResponse, - QueryMomoPaymentDto, - RefundMomoPaymentDto -} from '@payment/dto/momo-payment.dto' +import { CreateMomoPaymentResponse, QueryMomoPaymentDto } from '@payment/dto/momo-payment.dto' import { ConfigService } from '@nestjs/config' import { MailerService } from '@nestjs-modules/mailer' +import { CheckoutRequestType, CheckoutResponseDataType, PaymentLinkDataType } from '@payos/node/lib/type' @Injectable() export class OrderService { @@ -132,29 +128,46 @@ export class OrderService { }) // 4. Process transaction - let createMomoPaymentResponse: CreateMomoPaymentResponse - const orderId = `FUR${new Date().getTime()}${Math.floor(Math.random() * 100)}` + 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)) + createOrderDto['paymentMethod'] = PaymentMethod.PAY_OS switch (createOrderDto.paymentMethod) { - case PaymentMethod.ZALO_PAY: + // case PaymentMethod.MOMO: + // this.paymentService.setStrategy(PaymentMethod.MOMO) + // const createMomoPaymentDto: CreateMomoPaymentDto = { + // partnerName: 'FURNIQUE', + // orderInfo: `Furnique - Thanh toán đơn hàng #${orderCode}`, + // redirectUrl: `${this.configService.get('WEB_URL')}/customer/orders`, + // ipnUrl: `${this.configService.get('SERVER_URL')}/payment/webhook/momo`, + // requestType: 'payWithMethod', + // amount: totalAmount, + // orderId: orderCode.toString(), + // requestId: orderCode.toString(), + // extraData: '', + // autoCapture: true, + // lang: 'vi', + // orderExpireTime: 15 + // } + // paymentResponseData = checkoutData = await this.paymentService.createTransaction(createMomoPaymentDto) + // break + // case PaymentMethod.ZALO_PAY: // implement later - case PaymentMethod.MOMO: + case PaymentMethod.PAY_OS: default: - this.paymentService.setStrategy(this.paymentService.momoPaymentStrategy) - const createMomoPaymentDto: CreateMomoPaymentDto = { - partnerName: 'FURNIQUE', - orderInfo: `Furnique - Thanh toán đơn hàng #${orderId}`, - redirectUrl: `${this.configService.get('WEB_URL')}/customer/orders`, - ipnUrl: `${this.configService.get('SERVER_URL')}/payment/webhook`, - requestType: 'payWithMethod', + this.paymentService.setStrategy(PaymentMethod.PAY_OS) + const checkoutRequestType: CheckoutRequestType = { + orderCode: orderCode, amount: totalAmount, - orderId, - requestId: orderId, - extraData: '', - autoCapture: true, - lang: 'vi', - orderExpireTime: 15 + description: `FUR-Thanh toán đơn hàng`, + // TODO: Update link below + cancelUrl: `${this.configService.get('WEB_URL')}/customer/orders`, + returnUrl: `${this.configService.get('WEB_URL')}/customer/orders` } - createMomoPaymentResponse = await this.paymentService.createTransaction(createMomoPaymentDto) + checkoutData = await this.paymentService.createTransaction(checkoutRequestType) + paymentResponseData = await this.paymentService.getTransaction(checkoutData['orderCode']) break } @@ -162,7 +175,8 @@ export class OrderService { const payment = await this.paymentRepository.create( { transactionStatus: TransactionStatus.DRAFT, - transaction: createMomoPaymentResponse, + transaction: paymentResponseData, + transactionHistory: [paymentResponseData], paymentMethod: createOrderDto.paymentMethod, amount: totalAmount }, @@ -175,7 +189,7 @@ export class OrderService { await this.orderRepository.create( { ...createOrderDto, - orderId, + orderId: orderCode.toString(), items: orderItems, totalAmount, payment @@ -185,7 +199,7 @@ export class OrderService { } ) await session.commitTransaction() - return createMomoPaymentResponse + return checkoutData } catch (error) { await session.abortTransaction() console.error(error) @@ -296,65 +310,66 @@ export class OrderService { }) await this.productRepository.model.bulkWrite(operations) - // 3. Refund payment via MOMO - this.logger.log(`3. Refund payment via MOMO::`) - const refundOrderId = `FUR${new Date().getTime()}${Math.floor(Math.random() * 100)}` - this.paymentService.setStrategy(this.paymentService.momoPaymentStrategy) - const refundMomoPaymentDto: RefundMomoPaymentDto = { - orderId: refundOrderId, - requestId: refundOrderId, - amount: order.payment?.amount, - transId: order.payment?.transaction['transId'], - lang: 'vi', - description: `Furnique - Hoàn tiền đơn hàng #${orderId}` - } - const refundedTransaction = await this.paymentService.refundTransaction(refundMomoPaymentDto) - this.logger.log(JSON.stringify(refundedTransaction)) + // TODO: check if PaymentMethod === MOMO + // // 3. Refund payment via MOMO + // this.logger.log(`3. Refund payment via MOMO::`) + // const refundOrderId = `FUR${new Date().getTime()}${Math.floor(Math.random() * 100)}` + // this.paymentService.setStrategy(PaymentMethod.MOMO) + // const refundMomoPaymentDto: RefundMomoPaymentDto = { + // orderId: refundOrderId, + // requestId: refundOrderId, + // amount: order.payment?.amount, + // transId: order.payment?.transaction['transId'], + // lang: 'vi', + // description: `Furnique - Hoàn tiền đơn hàng #${orderId}` + // } + // const refundedTransaction = await this.paymentService.refundTransaction(refundMomoPaymentDto) + // this.logger.log(JSON.stringify(refundedTransaction)) // 4. Fetch newest transaction of order - this.logger.log(`4. Fetch newest transaction of order`) - const queryMomoPaymentDto: QueryMomoPaymentDto = { - orderId: order.orderId, - requestId: order.orderId, - lang: 'vi' - } - const transaction = await this.paymentService.getTransaction(queryMomoPaymentDto) - this.logger.log(JSON.stringify(transaction)) + // this.logger.log(`4. Fetch newest transaction of order`) + // const queryMomoPaymentDto: QueryMomoPaymentDto = { + // orderId: order.orderId, + // requestId: order.orderId, + // lang: 'vi' + // } + // const transaction = await this.paymentService.getTransaction(queryMomoPaymentDto) + // this.logger.log(JSON.stringify(transaction)) // 5. Update payment transactionStatus, transaction - this.logger.log(`5. Update payment transactionStatus, transaction`) - const payment = await this.paymentRepository.findOneAndUpdate( - { - _id: order.payment._id - }, - { - $set: { - transactionStatus: TransactionStatus.REFUNDED, - transaction: transaction - }, - $push: { transactionHistory: transaction } - }, - { - session, - new: true - } - ) + // this.logger.log(`5. Update payment transactionStatus, transaction`) + // const payment = await this.paymentRepository.findOneAndUpdate( + // { + // _id: order.payment._id + // }, + // { + // $set: { + // transactionStatus: TransactionStatus.REFUNDED, + // transaction: transaction + // }, + // // $push: { transactionHistory: transaction } + // }, + // { + // session, + // new: true + // } + // ) // 6. Update order transactionStatus, payment - this.logger.log(`6. Update order transactionStatus, payment`) - await this.orderRepository.findOneAndUpdate( - { - _id: order._id - }, - { - $set: { - transactionStatus: TransactionStatus.REFUNDED, - payment: payment - } - }, - { - session - } - ) + // this.logger.log(`6. Update order transactionStatus, payment`) + // await this.orderRepository.findOneAndUpdate( + // { + // _id: order._id + // }, + // { + // $set: { + // transactionStatus: TransactionStatus.REFUNDED, + // payment: payment + // } + // }, + // { + // session + // } + // ) // 7. Send email/notification to customer this.logger.log(`7. Send email/notification to customer`) diff --git a/src/payment/contracts/constant.ts b/src/payment/contracts/constant.ts index bcb6d65..5b577b0 100644 --- a/src/payment/contracts/constant.ts +++ b/src/payment/contracts/constant.ts @@ -1,4 +1,5 @@ export enum PaymentMethod { + PAY_OS = 'PAY_OS', MOMO = 'MOMO', ZALO_PAY = 'ZALO_PAY' } @@ -8,3 +9,16 @@ export enum MomoResultCode { AUTHORIZED = 9000, FAILED = 'FAILED' } + +export enum PayOSResultCode { + SUCCESS = '00', + FAILED = '01', + INVALID_PARAM = '02' +} + +export enum PayOSStatus { + PENDING = 'PENDING', + PROCESSING = 'PROCESSING', + PAID = 'PAID', + CANCELLED = 'CANCELLED' +} diff --git a/src/payment/controllers/payment.controller.ts b/src/payment/controllers/payment.controller.ts index 44c1555..7275f51 100644 --- a/src/payment/controllers/payment.controller.ts +++ b/src/payment/controllers/payment.controller.ts @@ -9,21 +9,14 @@ import { RolesGuard } from '@auth/guards/roles.guard' import { PaymentPaginateResponseDto } from '@payment/dto/payment.dto' import { Pagination, PaginationParams } from '@common/decorators/pagination.decorator' import { JwtAuthGuard } from '@auth/guards/jwt-auth.guard' -import { HelperService } from '@common/services/helper.service' -import { ConfigService } from '@nestjs/config' +import { WebhookType } from '@payos/node/lib/type' +import { PaymentMethod } from '@payment/contracts/constant' @ApiTags('Payment') @ApiBearerAuth() @Controller('payment') export class PaymentController { - private config - constructor( - private readonly paymentService: PaymentService, - private readonly helperService: HelperService, - private readonly configService: ConfigService - ) { - this.config = this.configService.get('payment.momo') - } + constructor(private readonly paymentService: PaymentService) {} @ApiOperation({ summary: 'Get transaction list of payment' @@ -41,99 +34,45 @@ export class PaymentController { summary: 'Webhook Handler for Instant Payment Notification (MOMO)' }) @HttpCode(HttpStatus.NO_CONTENT) - @Post('webhook') - webhook(@Body() momoPaymentResponseDto) { - console.log('Handling webhook', JSON.stringify(momoPaymentResponseDto)) - - // TODO: 1. Validate signature with other data => implement later - const { - partnerCode, - amount, - extraData, - message, - orderId, - orderInfo, - orderType, - requestId, - payType, - responseTime, - resultCode, - transId - } = momoPaymentResponseDto - const rawSignature = `accessKey=${this.config.accessKey}&amount=${amount}&extraData=${extraData}&message=${message}&orderId=${orderId}&orderInfo=${orderInfo}&orderType=${orderType}&partnerCode=${partnerCode}&payType=${payType}&requestId=${requestId}&responseTime=${responseTime}&resultCode=${resultCode}&transId=${transId}` - const signature = this.helperService.createSignature(rawSignature, this.config.secretKey) + @Post('webhook/momo') + webhookMomo(@Body() momoPaymentResponseDto) { + console.log('Handling MOMO webhook', JSON.stringify(momoPaymentResponseDto)) + this.paymentService.setStrategy(PaymentMethod.MOMO) - console.log(`1. ${momoPaymentResponseDto.signature}`) - console.log(`2. ${signature}`) - if (momoPaymentResponseDto.signature !== signature) return false - return this.paymentService.processWebhook(momoPaymentResponseDto) + //1. Validate signature with other data + const result = this.paymentService.verifyPaymentWebhookData(momoPaymentResponseDto) + if (!result) return false + + //2. Process webhook + return this.paymentService.processWebhook(PaymentMethod.MOMO, momoPaymentResponseDto) } - // @ApiOperation({ - // summary: 'Query payment order' - // }) - // @Post('query') - // @ApiBadRequestResponse({ type: ErrorResponse }) - // @ApiOkResponse({ type: CustomerResponseDto }) - // query(@Req() req) { - // this.paymentService.setStrategy(this.momoPaymentStrategy) - // const queryMomoPaymentDto: QueryMomoPaymentDto = { - // orderId: 'MOMO1709564985022', - // requestId: 'MOMO1709564985022', - // lang: 'vi' - // } - // // { - // // "partnerCode": "MOMO", - // // "orderId": "MOMO1709553563870", - // // "requestId": "MOMO1709553563870", - // // "extraData": "", - // // "amount": 123456, - // // "transId": 3996000196, - // // "payType": "credit", - // // "resultCode": 0, - // // "refundTrans": [ - // // { - // // "orderId": "MOMO1709556182884", - // // "amount": 50000, - // // "resultCode": 0, - // // "transId": 3996022041, - // // "createdTime": 1709556185900 - // // } - // // ], - // // "message": "Thành công.", - // // "responseTime": 1709562845129, - // // "lastUpdated": 1709556185892, - // // "signature": null - // // } - // return this.paymentService.getTransaction(queryMomoPaymentDto) - // } + @ApiOperation({ + summary: 'Webhook Handler for PAYOS' + }) + @Post('webhook/payos') + webhook(@Body() webhookData: WebhookType) { + console.log('Handling PAYOS webhook', JSON.stringify(webhookData)) + this.paymentService.setStrategy(PaymentMethod.PAY_OS) + + //1. Validate signature with other data + const result = this.paymentService.verifyPaymentWebhookData(webhookData) + if (!result) return false + + // // just skip for confirmWebhook + // if (webhookData.data.orderCode == 123) return true + + //2. Process webhook + return this.paymentService.processWebhook(PaymentMethod.PAY_OS, webhookData) + } // @ApiOperation({ - // summary: 'Refund payment order' + // summary: 'Confirm Webhook URL for PAYOS' // }) - // @Post('refund') - // @ApiBadRequestResponse({ type: ErrorResponse }) - // @ApiOkResponse({ type: CustomerResponseDto }) - // refund(@Req() req) { - // this.paymentService.setStrategy(this.momoPaymentStrategy) - // const refundMomoPaymentDto: RefundMomoPaymentDto = { - // orderId: 'MOMO' + new Date().getTime(), - // requestId: 'MOMO' + new Date().getTime(), - // amount: 50000, - // transId: 3996000196, - // lang: 'vi', - // description: 'Hoàn tiền đơn hàng' - // } - // return this.paymentService.refundTransaction(refundMomoPaymentDto) - // // { - // // "partnerCode": "MOMO", - // // "orderId": "MOMO1709556182884", - // // "requestId": "MOMO1709556182884", - // // "amount": 50000, - // // "transId": 3996022041, - // // "resultCode": 0, - // // "message": "Thành công.", - // // "responseTime": 1709556185927 - // // } + // @Post('webhook/payos-confirm') + // async verifyWebhook() { + // console.log('Handling Confirm Webhook URL for PAYOS') + + // await this.paymentService.payOSPaymentStrategy.verifyWebhookUrl() // } } diff --git a/src/payment/dto/payment.dto.ts b/src/payment/dto/payment.dto.ts index a2d0dcd..5d59d90 100644 --- a/src/payment/dto/payment.dto.ts +++ b/src/payment/dto/payment.dto.ts @@ -1,8 +1,8 @@ import { ApiProperty } from '@nestjs/swagger' import { TransactionStatus } from '@common/contracts/constant' import { PaymentMethod } from '@payment/contracts/constant' -import { MomoPaymentResponseDto } from '@payment/dto/momo-payment.dto' import { DataResponse, PaginateResponse } from '@common/contracts/openapi-builder' +import { PayOSPaymentResponseDto } from './payos-payment.dto' export class PaymentDto { @ApiProperty() @@ -12,12 +12,14 @@ export class PaymentDto { transactionStatus: TransactionStatus @ApiProperty() - transaction: MomoPaymentResponseDto + transaction: PayOSPaymentResponseDto - @ApiProperty({ isArray: true, type: MomoPaymentResponseDto }) - transactionHistory: MomoPaymentResponseDto[] + @ApiProperty({ isArray: true, type: PayOSPaymentResponseDto }) + transactionHistory: PayOSPaymentResponseDto[] - @ApiProperty() + @ApiProperty({ + enum: PaymentMethod + }) paymentMethod: PaymentMethod @ApiProperty() @@ -26,4 +28,4 @@ export class PaymentDto { export class PaymentPaginateResponseDto extends DataResponse( class PaymentPaginateResponse extends PaginateResponse(PaymentDto) {} -) {} \ No newline at end of file +) {} diff --git a/src/payment/dto/payos-payment.dto.ts b/src/payment/dto/payos-payment.dto.ts new file mode 100644 index 0000000..cfb335a --- /dev/null +++ b/src/payment/dto/payos-payment.dto.ts @@ -0,0 +1,99 @@ +import { ApiProperty } from '@nestjs/swagger' +import { DataResponse } from '@common/contracts/openapi-builder' +import { PayOSStatus } from '@payment/contracts/constant' + +export class CreatePayOSPaymentResponse { + @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 CreatePayOSPaymentResponseDto extends DataResponse(CreatePayOSPaymentResponse) {} + +export class PayOSPaymentResponseDto { + @ApiProperty() + id: string + + @ApiProperty() + orderCode: string + + @ApiProperty() + amount: number + + @ApiProperty() + amountPaid: number + + @ApiProperty() + amountRemaining: number + + @ApiProperty({ + enum: PayOSStatus + }) + status: PayOSStatus + + @ApiProperty() + createdAt: string + + @ApiProperty() + transactions: TransactionType[] + + @ApiProperty() + cancellationReason: string | null + + @ApiProperty() + canceledAt: string | null +} + +export class TransactionType { + @ApiProperty({ + description: 'Mã tham chiếu của giao dịch' + }) + reference: string + @ApiProperty() + amount: number + @ApiProperty() + accountNumber: string + @ApiProperty() + description: string + @ApiProperty() + transactionDateTime: string + @ApiProperty() + virtualAccountName: string | null + @ApiProperty() + virtualAccountNumber: string | null + @ApiProperty() + counterAccountBankId: string | null + @ApiProperty() + counterAccountBankName: string | null + @ApiProperty() + counterAccountName: string | null + @ApiProperty() + counterAccountNumber: string | null +} + +export class PayOSRefundTransactionDto { + @ApiProperty() + orderId: string + @ApiProperty() + cancellationReason?: string +} diff --git a/src/payment/payment.module.ts b/src/payment/payment.module.ts index cac5461..9f2e195 100644 --- a/src/payment/payment.module.ts +++ b/src/payment/payment.module.ts @@ -10,6 +10,7 @@ import { MomoPaymentStrategy } from '@payment/strategies/momo.strategy' 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' @Global() @Module({ @@ -21,7 +22,7 @@ import { ProductModule } from '@product/product.module' ProductModule ], controllers: [PaymentController], - providers: [PaymentService, PaymentRepository, ZaloPayPaymentStrategy, MomoPaymentStrategy], + providers: [PaymentService, PaymentRepository, ZaloPayPaymentStrategy, MomoPaymentStrategy, PayOSPaymentStrategy], exports: [PaymentService, PaymentRepository] }) export class PaymentModule {} diff --git a/src/payment/services/payment.service.ts b/src/payment/services/payment.service.ts index 8519046..ab4999c 100644 --- a/src/payment/services/payment.service.ts +++ b/src/payment/services/payment.service.ts @@ -1,11 +1,7 @@ import { Injectable, Logger } from '@nestjs/common' +import { get } from 'lodash' import { IPaymentStrategy } from '@payment/strategies/payment-strategy.interface' -import { - CreateMomoPaymentDto, - MomoPaymentResponseDto, - QueryMomoPaymentDto, - RefundMomoPaymentDto -} from '@payment/dto/momo-payment.dto' +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' @@ -17,10 +13,13 @@ 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 } from '@payment/contracts/constant' +import { MomoResultCode, PayOSResultCode, 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' @Injectable() export class PaymentService { @@ -32,15 +31,32 @@ export class PaymentService { private readonly cartService: CartService, private readonly productRepository: ProductRepository, private readonly paymentRepository: PaymentRepository, - readonly momoPaymentStrategy: MomoPaymentStrategy, + private readonly momoPaymentStrategy: MomoPaymentStrategy, + private readonly zaloPayPaymentStrategy: ZaloPayPaymentStrategy, + readonly payOSPaymentStrategy: PayOSPaymentStrategy, private readonly mailerService: MailerService ) {} - public setStrategy(strategy: IPaymentStrategy) { - this.strategy = strategy + public setStrategy(paymentMethod: PaymentMethod) { + switch (paymentMethod) { + case PaymentMethod.MOMO: + this.strategy = this.momoPaymentStrategy + break + case PaymentMethod.ZALO_PAY: + this.strategy = this.zaloPayPaymentStrategy + break + case PaymentMethod.PAY_OS: + default: + this.strategy = this.payOSPaymentStrategy + break + } + } + + public verifyPaymentWebhookData(webhookData: any) { + return this.strategy.verifyPaymentWebhookData(webhookData) } - public createTransaction(createPaymentDto: CreateMomoPaymentDto) { + public createTransaction(createPaymentDto: any) { return this.strategy.createTransaction(createPaymentDto) } @@ -72,24 +88,29 @@ export class PaymentService { return result } - public async processWebhook(momoPaymentResponseDto: MomoPaymentResponseDto) { - this.logger.log('processWebhook: momoPaymentResponseDto', JSON.stringify(momoPaymentResponseDto)) + public async processWebhook(paymentMethod: PaymentMethod, 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: momoPaymentResponseDto.orderId + orderId: String(orderId) }, projection: '+items' }) if (!order) throw new AppException(Errors.ORDER_NOT_FOUND) this.logger.log('processWebhook: order', JSON.stringify(order)) - if (momoPaymentResponseDto.resultCode === MomoResultCode.SUCCESS) { - this.logger.log('processWebhook: resultCode SUCCESS', momoPaymentResponseDto.resultCode) + 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) @@ -156,42 +177,51 @@ export class PaymentService { // 6. Bulk write Update quantity in product.variants await this.productRepository.model.bulkWrite(operations) - // 7. Update order transactionStatus - const orderHistory = new OrderHistoryDto( - OrderStatus.PENDING, - TransactionStatus.CAPTURED, - order.customer._id, - UserRole.CUSTOMER - ) - await this.orderRepository.findOneAndUpdate( + // 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._id + _id: order.payment._id }, { $set: { transactionStatus: TransactionStatus.CAPTURED, - 'payment.transactionStatus': TransactionStatus.CAPTURED, - 'payment.transaction': momoPaymentResponseDto, - 'payment.transactionHistory': [momoPaymentResponseDto] + transaction: transaction }, - $push: { orderHistory } + $push: { transactionHistory: transaction } }, { - session + session, + new: true } ) - // 8. Update payment transactionStatus, transaction - await this.paymentRepository.findOneAndUpdate( + // 8. Update order transactionStatus + const orderHistory = new OrderHistoryDto( + OrderStatus.PENDING, + TransactionStatus.CAPTURED, + order.customer._id, + UserRole.CUSTOMER + ) + await this.orderRepository.findOneAndUpdate( { - _id: order.payment._id + _id: order._id }, { $set: { transactionStatus: TransactionStatus.CAPTURED, - transaction: momoPaymentResponseDto, - transactionHistory: [momoPaymentResponseDto] - } + payment + }, + $push: { orderHistory } }, { session @@ -226,7 +256,25 @@ export class PaymentService { // 10. Send notification to staff } else { // Payment failed - this.logger.log('processWebhook: resultCode FAILED', momoPaymentResponseDto.resultCode) + 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, @@ -241,9 +289,7 @@ export class PaymentService { { $set: { transactionStatus: TransactionStatus.ERROR, - 'payment.transactionStatus': TransactionStatus.ERROR, - 'payment.transaction': momoPaymentResponseDto, - 'payment.transactionHistory': [momoPaymentResponseDto] + payment: payment }, $push: { orderHistory } }, @@ -251,23 +297,6 @@ export class PaymentService { session } ) - - // 2. Update payment transactionStatus, transaction - await this.paymentRepository.findOneAndUpdate( - { - _id: order.payment._id - }, - { - $set: { - transactionStatus: TransactionStatus.ERROR, - transaction: momoPaymentResponseDto, - transactionHistory: [momoPaymentResponseDto] - } - }, - { - session - } - ) } await session.commitTransaction() this.logger.log('processWebhook: SUCCESS!!!') diff --git a/src/payment/strategies/momo.strategy.ts b/src/payment/strategies/momo.strategy.ts index bc7c4ec..74f1221 100644 --- a/src/payment/strategies/momo.strategy.ts +++ b/src/payment/strategies/momo.strategy.ts @@ -102,4 +102,27 @@ export class MomoPaymentStrategy implements IPaymentStrategy { console.log(data) return data } + + verifyPaymentWebhookData(momoPaymentResponseDto: any): boolean { + const { + partnerCode, + amount, + extraData, + message, + orderId, + orderInfo, + orderType, + requestId, + payType, + responseTime, + resultCode, + transId + } = momoPaymentResponseDto + const rawSignature = `accessKey=${this.config.accessKey}&amount=${amount}&extraData=${extraData}&message=${message}&orderId=${orderId}&orderInfo=${orderInfo}&orderType=${orderType}&partnerCode=${partnerCode}&payType=${payType}&requestId=${requestId}&responseTime=${responseTime}&resultCode=${resultCode}&transId=${transId}` + const signature = this.helperService.createSignature(rawSignature, this.config.secretKey) + + console.log(`1. ${momoPaymentResponseDto.signature}`) + console.log(`2. ${signature}`) + return momoPaymentResponseDto.signature !== signature + } } diff --git a/src/payment/strategies/payment-strategy.interface.ts b/src/payment/strategies/payment-strategy.interface.ts index 968d346..b6ec56e 100644 --- a/src/payment/strategies/payment-strategy.interface.ts +++ b/src/payment/strategies/payment-strategy.interface.ts @@ -3,4 +3,5 @@ export interface IPaymentStrategy { getTransaction(queryDto: any): any refundTransaction(refundDto: any): any getRefundTransaction(queryRefundDto: any): any + verifyPaymentWebhookData(webhookData: any): any } diff --git a/src/payment/strategies/payos.strategy.ts b/src/payment/strategies/payos.strategy.ts new file mode 100644 index 0000000..9e7d17f --- /dev/null +++ b/src/payment/strategies/payos.strategy.ts @@ -0,0 +1,62 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { PayOSRefundTransactionDto } from '@payment/dto/payos-payment.dto' +const PayOS = require('@payos/node') +import { IPaymentStrategy } from '@payment/strategies/payment-strategy.interface' +import { + CheckoutRequestType, + CheckoutResponseDataType, + PaymentLinkDataType, + WebhookDataType, + WebhookType +} from '@payos/node/lib/type' + +@Injectable() +export class PayOSPaymentStrategy implements IPaymentStrategy, OnModuleInit { + private readonly logger = new Logger(PayOSPaymentStrategy.name) + private config + private payOS + constructor(private readonly configService: ConfigService) { + this.config = this.configService.get('payment.payos') + this.payOS = new PayOS(this.config.clientId, this.config.apiKey, this.config.checksumKey) + } + + async onModuleInit() { + try { + this.logger.log(`The ${PayOSPaymentStrategy.name} has been initialized.`) + + // success => redirect https://f06e-116-98-183-38.ngrok-free.app/return?code=00&id=887fb96024fc4c4eb1dd28ca11e59d9c&cancel=false&status=PAID&orderCode=1234567 + // Query params: https://payos.vn/docs/du-lieu-tra-ve/return-url/ + + // const paymentLinkRes = await this.payOS.createPaymentLink(body) + // console.log('paymentLinkRes', paymentLinkRes) + // const paymentLink = await this.payOS.getPaymentLinkInformation('1234567') + // console.log('paymentLink', paymentLink) + } catch (err) { + console.log(err) + } + } + + async verifyWebhookUrl() { + const result = await this.payOS.confirmWebhook(`${this.configService.get('SERVER_URL')}/payment/webhook/payos`) + console.log(result) + } + + async createTransaction(checkoutRequestType: CheckoutRequestType): Promise { + const paymentLinkRes = await this.payOS.createPaymentLink(checkoutRequestType) + return paymentLinkRes + } + + async getTransaction(orderCode: string): Promise { + const paymentLink = await this.payOS.getPaymentLinkInformation(orderCode) + return paymentLink + } + + async refundTransaction(refundDto: PayOSRefundTransactionDto) {} + + async getRefundTransaction(queryDto: any) {} + + verifyPaymentWebhookData(webhookBody: WebhookType): WebhookDataType | null { + return this.payOS.verifyPaymentWebhookData(webhookBody) + } +} diff --git a/src/payment/strategies/zalopay.strategy.ts b/src/payment/strategies/zalopay.strategy.ts index d09c808..caa721b 100644 --- a/src/payment/strategies/zalopay.strategy.ts +++ b/src/payment/strategies/zalopay.strategy.ts @@ -7,4 +7,5 @@ export class ZaloPayPaymentStrategy implements IPaymentStrategy { getTransaction(queryDto: any): any {} refundTransaction(refundDto: any): any {} getRefundTransaction(queryRefundDto: any): any {} + verifyPaymentWebhookData(webhookData: any): any {} }