[POC] Tích hợp cổng thanh toán VietQR | payos

The Brown BoxThe Brown Box
5 min read

Link sourcecode và documents ở phần dưới cùng.

B0: Thiết kế

Tôi tạm thiết kế flow thanh toán như sau. Trong bài này chúng ta sẽ tích hợp với gateway, phần được khoanh đỏ.

B1: Đăng ký tài khoản và xác thực KYC với payos

Đăng ký tài khoản và xác thực danh tính. Một bước khá quen thuộc với các dịch vụ fintech.

Chú ý mua thêm gói nếu ko có sẵn gói miễn phí!

B2: Tạo kênh thanh toán

Tại bước này ta liên kết với tài khoản ngân hàng, tạo các kênh thanh toán cho từng sản phẩm của mình. Phần quan trọng ở đây chỉ là liên kết với ngân hàng phù hợp, còn với mỗi sản phẩm chúng ta sẽ tạo ra một kênh để có thể track và thống kê.

B3: Test webhook

Bước đầu tiên chúng ta có thể test hệ thống là tạo thử link thanh toán trên portal và nhận webhook bắn về service của chúng ta.

  1. Cài đặt webhook của chúng ta trên portal.

Sử dụng ngrok để có thể public local port.
Webhook đơn giản là một http endpoint chúng ta đưa cho người để họ gọi với một format được định sẵn.

  @Post('webhook')
  handleWebhook(@Body() body: unknown) {
    console.log(body);
  }

  1. Tạo link thanh toán

Bước này sẽ tạo cho chúng ta 1 QR code để người dùng có thể thanh toán.
(về sau bước này sẽ được làm trên product của chúng ta)

  1. Lấy điện thoại và quẹt thanh toán mã được tạo ra.

  2. Kiệm tra webhook đã được bắn về hay chưa.

Dưới đây là payload của payos gửi về cho chúng ta.

B4: Validate payload from webhook

Bước này vô cùng quan trọng, vì webhook vẫn chỉ là một endpoint tới service chúng ta, rất có thể bị lộ và người khác hoàn toàn có thể chúng động gọi webhook của chúng ta.
Tại bước này chúng ta sẽ validate payload nhận được để chắc chắn rằng nó được gửi từ payos.

Bước này cũng đã được hướng dẫn chi tiết tại document của payos: https://payos.vn/docs/tich-hop-webhook/kiem-tra-du-lieu-voi-signature/

  1. Lưu các thông tin từ gateway lại, các thông tin này sẽ được sử dụng để validate payload

Cái sẽ dùng để check payload đó là CHECKSUM key.

  1. Implement

Trong doc đã có ví dụ và code mẫu rất cụ thể, có thể dọc qua để hiểu rồi có thể copy code của họ rồi modify theo hệ thống của mình là được.

Ở đây mình tạo ra một Guard mới vào để logic validate tại đây.

import {
  CanActivate,
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PayosWebhookBodyPayload } from '../dto/payos-webhook-body.payload';
import { createHmac } from 'node:crypto';

@Injectable()
export class PaymentWebhookGuard implements CanActivate {
  constructor(private readonly configService: ConfigService) {}

  sortObjDataByKey(object: Record<string, unknown>) {
    const orderedObject = Object.keys(object)
      .sort()
      .reduce((obj, key) => {
        obj[key] = object[key];
        return obj;
      }, {});
    return orderedObject;
  }

  convertObjToQueryStr(object: Record<string, unknown>) {
    return Object.keys(object)
      .filter((key) => object[key] !== undefined)
      .map((key) => {
        let value = object[key];
        // Sort nested object
        if (value && Array.isArray(value)) {
          value = JSON.stringify(
            value.map((val) => this.sortObjDataByKey(val)),
          );
        }
        // Set empty string if null
        if ([null, undefined, 'undefined', 'null'].includes(value as string)) {
          value = '';
        }

        return `${key}=${value}`;
      })
      .join('&');
  }

  isValidData(
    data: Record<string, unknown>,
    currentSignature: string,
    checksumKey: string,
  ) {
    const sortedDataByKey = this.sortObjDataByKey(data);
    const dataQueryStr = this.convertObjToQueryStr(sortedDataByKey);
    const dataToSignature = createHmac('sha256', checksumKey)
      .update(dataQueryStr)
      .digest('hex');
    return dataToSignature == currentSignature;
  }

  canActivate(context: ExecutionContext): boolean {
    try {
      const req = context.switchToHttp().getRequest<Request>();
      const CHECKSUM_KEY =
        this.configService.getOrThrow<string>('PAYOS_CHECKSUM_KEY');

      const body = req.body as unknown as PayosWebhookBodyPayload;

      const isValidPayload = this.isValidData(
        body.data as unknown as Record<string, unknown>,
        body.signature,
        CHECKSUM_KEY,
      );
      console.log({ CHECKSUM_KEY, isValidPayload, body });
      if (!isValidPayload) {
        throw new UnauthorizedException('Invalid payload');
      }

      return true;
    } catch (error) {
      console.error(error);
      throw new UnauthorizedException('Invalid payload');
    }
  }
}

Test lại bằng cách copy một body chuẩn rồi thử lại bằng các giá trị khác rồi tự gửi vào webhook của mình.

Ok, đã xong đoạn xác nhận thanh toán, giờ chúng ta quay ngược lại phần đầu đó là tạo link thanh toán.

Ở trên ta demo bằng cách tạo link thanh toán trên portal của gateway. Trên thực tế link đó sẽ được tạo trên product của chúng ta (nếu chúng ta chọn tự tích hợp), sau đây chúng ta sẽ đi generate mã QR trên.

Doc của phần này: https://payos.vn/docs/api/#tag/payment-request

Phần này thì chúng ta sẽ gửi request đến gateway để lấy được QR code.
Phần này chỉ cần chú ý cách chúng ta generate signature (giống hệt phần trước) để gửi đi là được.

  async createPayment(body: CreatePaymentDto): Promise<any> {
    const url = `https://api-merchant.payos.vn/v2/payment-requests`;
    const config = {
      headers: {
        'x-client-id': this.configService.getOrThrow<string>('PAYOS_CLIENT_ID'),
        'x-api-key': this.configService.getOrThrow<string>('PAYOS_API_KEY'),
      },
    };
    const dataForSignature = {
      orderCode: Number(body.orderId),
      amount: body.amount,
      description: body.description,
      cancelUrl: 'https://example.com/cancel',
      returnUrl: 'https://example.com/return',
    };
    const signature = generateSignature(
      dataForSignature,
      this.configService.getOrThrow<string>('PAYOS_CHECKSUM_KEY'),
    );
    const payload: PayosRequestPaymentPayload = {
      ...dataForSignature,
      signature,
    };
    const response = await firstValueFrom(
      this.httpService.post(url, payload, config),
    );
    return response.data;
  }

Khi thiết lập Payload đúng quy định, payos sẽ trả về cho ta payload có chứa QR code có dạng:

00020101021238570010A000000727012700069704220113VQRQADSEO71010208QRIBFTTA5303704540420005802VN62080804DESC6304F23A

Đây là raw QR, chúng ta sẽ handle ở frontend để hiển thị.
Để test chúng ta có thể sử dụng tool để hiển thị qr để quét: https://qrcode.show

Khi quét thành công thì nó lại trở về flow mà chúng ta đã implement từ trước.

Vậy là đã xong phần cơ bản nhất của payment, giờ chỉ cần làm việc với các phần logic còn lại và integrate với các phần khác của hệ thống của bạn!

Refs:

0
Subscribe to my newsletter

Read articles from The Brown Box directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

The Brown Box
The Brown Box