One-time Payment QRIS Menggunakan Xendit API dengan Nest js

thumbnail - Implementasi Pembayaran QRIS dengan menggunakan Xendit QR Payment API

Dengan kasus studi membeli ebook pada website penjualan buku website ini hanya melayani pembayaran dengan qr

User Story

User ingin membeli sebuah produk digital berupa ebook, user pergi ke halaman ebook tersebuyt, user klik tombol bayar dengan qr, lalu qr muncul user membayar melalui qr lalu klik cek pembayaran, pembayaran berhasil lalu user secara otomatis diarahkan ke halaman sukses. ebook sudah ditambahkan ke katalog user.

Alur Pembayaran dari Generate QR Hingga Redirect User ke Success Page

get qr akan melayani kedua buah user story diatas karena akan dicek dahulu apakah ada qr yang dimiliki user pada produk tersebut jika ada tampilkan qr lama jika tidak ada request qr baru ke xendit api

Flow Chart endpoint POST /get-qr

Flow Chart Endpoint GET /check-qr-payment

Perisapan sebelum implementasi

Berikut adalah beberapa persiapan yang harus dilakukan seperti setup Nest js dan menghubungkan backend dengan database serta.

Langkah 1: API Key Xendit

Kita memerlukan API Xendit dengan permission Money-In Write dan Money-In Read agar bisa menggunakan API yang seperti Create QR dan Get QR.

Untuk mendapatkan API key pergi ke halaman Dashbaord -> Settings -> Developer -> API Keys.

Klik tombol "Generate secret key" dan berikan permission Money-In Write dan Money-In Read.

Langkah 2: Setup Nest js

Ikuti langkah-langkah berikut untuk setup nest js dan menghubungkannya ke database

  1. Inisialisasi proyek Nest js
npm i -g @nestjs/cli
nest new xendit-qr-nest
  1. Inisialisasi modul QR Selanjutnya jalankan perintah berikut untuk menginisialisasi modul, controller, dan service qr.
nest g module qr
nest g controller qr
nest g service qr

Langkah 3: Hubungkan ke Database

  1. Install Dependensi: Pertama, instal TypeORM dan driver PostgreSQL:
npm install --save @nestjs/typeorm typeorm pg
  1. Konfigurasi TypeORM: Buka file app.module.ts dan tambahkan konfigurasi TypeORM. Anda perlu mengimpor TypeOrmModule dan mengonfigurasinya dengan detail koneksi database PostgreSQL Anda.
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { QrModule } from "./qr/qr.module";

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: "postgres",
      host: "localhost",
      port: 5432,
      username: "your_username",
      password: "your_password",
      database: "your_database",
      entities: [__dirname + "/**/*.entity{.ts,.js}"],
      synchronize: true, // true hanya di development
    }),
    QrModule,
  ],
})
export class AppModule {}

Implementasi QR entity

Kita akan mendefinisikan Entity karena akan menyimpan data QR yang dimiliki oleh user di dalam database.

Menyimpan data didatabase ini juga penting agar kita dapat menyimpan riwayat pembelian dari user.

import { Entity, Column, PrimaryGeneratedColumn } from "typeorm";

@Entity("qr_codes") // Specify the table name in the database
export class QRCode {
  @PrimaryGeneratedColumn("uuid") // Automatically generate a unique ID
  id: string;

  @Column()
  userId: string;

  @Column()
  productId: string;

  @Column()
  qrCodeId: string;

  @Column()
  expirationDate: Date;
}

Implementasi QR DTO (Data Transfer Object)

DTO digunakan untuk mendefinisikan struktur data yang akan diterima atau dikirim oleh aplikasi. Struktur data ini kemudian akan digunakan untuk validasi dan sebagai interface untuk autocomplete (Typescript).

Ada dua DTO yang akan dibuat yaitu Create QR dan Xendir QR DTO

  1. Create QR DTO

Digunakan untuk validasi data yang akan digunakan untuk create QR Code

qr/dto/create-qr.dto.ts
export class CreateQRCodeDto {
  userId: string;
  productId: string;
  amount: number;
  basket: {
    reference_id: string;
    name: string;
    category: string;
    currency: string;
    price: number;
    quantity: number;
    type: "PRODUCT" | "SERVICE";
    url?: string;
    description?: string;
    sub_category?: string;
  }[];
  metadata?: object;
}
  1. Xendit QR DTO

Digunakan untuk validasi data QR yang datang dari Xendit

qr/dto/create-qr.dto.ts
export class XenditQRCodeDto {
  id: string;
  reference_id: string;
  business_id: string;
  type: string;
  currency: string;
  amount: number;
  channel_code: string;
  status: string;
  qr_string: string;
  expires_at: string;
  created: string;
  updated: string;
  basket: object;
  metadata: object;
}

Implementasi Controller

qr/qr.controller.ts
import { Controller, Post, Body, Get, Param } from "@nestjs/common";
import { QrService } from "./qr.service";
import { CreateQRCodeDto } from "./dto/create-qr.dto";
import { XenditQRCodeDto } from "./dto/xendit-qr-code.dto";

@Controller("qr")
export class QrController {
  constructor(private readonly qrService: QrService) {}

  @Post("get-qr")
  async createQRCode(@Body() createQRCodeDto: CreateQRCodeDto) {
    console.log("Incoming get-qr request:", createQRCodeDto);
    // Memeriksa apakah QR code sudah ada di database
    const existingQRCode = await this.qrService.getQRCode(
      createQRCodeDto.userId,
      createQRCodeDto.productId
    );

    let isPaid = false;

    if (existingQRCode) {
      // Check if the existing QR code is expired
      const isExpired = await this.qrService.isExpired(existingQRCode);
      if (isExpired) {
        // If expired, delete the existing QR code
        await this.qrService.deleteQRCode(existingQRCode.id);
        console.log(`Expired QR code deleted: ${existingQRCode.id}`);
      } else {
        // If QR code is not expired, return the existing QR code
        isPaid = await this.qrService.isQRCodePaid(existingQRCode);
        return { qrCode: existingQRCode, isPaid };
      }
    }

    // If no QR code exists or the existing one was deleted, create a new QR code
    const newQRCode = await this.qrService.createQRCode(createQRCodeDto);
    return { qrCode: newQRCode, isPaid };
  }

  @Get("check-qr-payment/:id")
  async checkPayment(@Param("id") qrId: string) {
    // Fetch the QR code using the service
    const qrCode = await this.qrService.getQRCodeFromXendit(qrId);

    if (qrCode) {
      // If the QR code is found, return its status
      const isPaid = await this.qrService.isQRCodePaid(qrCode);
      return {
        qrCode,
        isPaid,
      };
    } else {
      // If the QR code is not found, return a not found status
      return {
        message: "NOT_FOUND",
      };
    }
  }
}

Implementasi Service

qr/qr.service.ts
import axios from "axios";
import { Injectable } from "@nestjs/common";
import { CreateQRCodeDto } from "./dto/create-qr.dto";
import { QRCode } from "./entities/qr.entity";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { PaymentConfig } from "src/config";
import { XenditQRCodeDto } from "./dto/xendit-qr-code.dto";

@Injectable()
export class QrService {
  constructor(
    @InjectRepository(QRCode)
    private readonly qrCodeRepository: Repository<QRCode>
  ) {}

  async getQRCode(
    userId: string,
    productId: string
  ): Promise<XenditQRCodeDto | null> {
    console.log(
      `Fetching QR code for userId: ${userId}, productId: ${productId}`
    );

    // Fetch the existing QR code from the database
    const existingQrCode = await this.qrCodeRepository.findOne({
      where: { userId, productId },
    });

    if (existingQrCode) {
      console.log(`Found existing QR code: ${existingQrCode.qrCodeId}`);

      // Use the qrCodeId from the database to fetch the QR code data from Xendit
      const qrCodeDataFromXendit = await this.getQRCodeFromXendit(
        existingQrCode.qrCodeId
      );

      // Return the QR code data fetched from Xendit
      return qrCodeDataFromXendit;
    }

    console.log("No existing QR code found.");
    return null; // Return null if no QR code is found
  }

  async getQRCodeFromXendit(qrCodeId: string): Promise<XenditQRCodeDto | null> {
    console.log(`Fetching QR code from Xendit with ID: ${qrCodeId}`);
    const xenditApiUrl = `https://api.xendit.co/qr_codes/${qrCodeId}`;
    const apiKey = PaymentConfig.XENDIT_API_KEY;
    const headers = {
      "Content-Type": "application/json",
      "api-version": "2022-07-31",
      Authorization: `Basic ${Buffer.from(apiKey).toString("base64")}`,
    };

    try {
      const response = await axios.get(xenditApiUrl, { headers });
      console.log("Successfully fetched QR code from Xendit:", response.data);
      return response.data;
    } catch (error) {
      console.error("Error fetching QR code from Xendit:", error);
      throw new Error("Failed to fetch QR code from Xendit");
    }
  }

  async isExpired(qrCode: XenditQRCodeDto): Promise<boolean> {
    const expired = new Date(qrCode.expires_at) < new Date();
    console.log(`QR code with ID ${qrCode.id} is expired: ${expired}`);
    return expired;
  }

  async isQRCodePaid(qrCode: XenditQRCodeDto): Promise<boolean> {
    // Check if the QR code status is "INACTIVE" and not expired
    const isExpired = await this.isExpired(qrCode);
    const isPaid = qrCode.status === "INACTIVE" && !isExpired;
    console.log(`QR code with ID ${qrCode.id} is paid: ${isPaid}`);
    return isPaid;
  }

  async createQRCode(
    createQRCodeDto: CreateQRCodeDto
  ): Promise<XenditQRCodeDto> {
    console.log("Creating QR code with data:", createQRCodeDto);
    const qrData = await this.prepareQRData(createQRCodeDto);

    try {
      const qrCodeData = await this.requestQRCodeFromXendit(qrData);
      console.log("QR code created successfully:", qrCodeData);

      // Save the QR code to the database
      await this.saveQRCodeToDatabase(createQRCodeDto, qrCodeData);

      // Return the QR code data fetched from Xendit
      return qrCodeData;
    } catch (error) {
      console.error("Error creating QR code:", error);
      throw new Error("Failed to create QR code");
    }
  }

  async deleteQRCode(qrCodeId: string): Promise<void> {
    console.log(`Deleting QR code with ID: ${qrCodeId}`);
    await this.qrCodeRepository.delete({ qrCodeId });
    console.log(`QR code with ID ${qrCodeId} has been deleted.`);
  }

  private async requestQRCodeFromXendit(qrData: any): Promise<any> {
    console.log("Requesting QR code from Xendit with data:", qrData);
    const xenditApiUrl = "https://api.xendit.co/qr_codes";
    const apiKey = PaymentConfig.XENDIT_API_KEY;
    const headers = {
      "Content-Type": "application/json",
      "api-version": "2022-07-31",
      Authorization: `Basic ${Buffer.from(apiKey).toString("base64")}`,
    };

    const response = await axios.post(xenditApiUrl, qrData, { headers });
    console.log("Received response from Xendit:", response.data);
    return response.data;
  }

  private async saveQRCodeToDatabase(
    createQRCodeDto: CreateQRCodeDto,
    qrCodeData: any
  ): Promise<QRCode> {
    console.log("Saving QR code to database:", {
      userId: createQRCodeDto.userId,
      productId: createQRCodeDto.productId,
      qrCodeId: qrCodeData.id,
      expirationDate: qrCodeData.expires_at,
    });
    const newQRCode = this.qrCodeRepository.create({
      userId: createQRCodeDto.userId,
      productId: createQRCodeDto.productId,
      qrCodeId: qrCodeData.id,
      expirationDate: qrCodeData.expires_at,
    });
    return this.qrCodeRepository.save(newQRCode);
  }

  private async prepareQRData(createQRCodeDto: CreateQRCodeDto): Promise<any> {
    const qrData = {
      reference_id: `order-id-${Date.now()}`,
      type: "DYNAMIC",
      currency: "IDR",
      amount: createQRCodeDto.amount,
      expires_at: new Date(
        Date.now() + PaymentConfig.QR_EXPIRES_AT
      ).toISOString(),
      metadata: {
        userId: createQRCodeDto.userId, // Include userId in metadata
        productId: createQRCodeDto.productId, // Include productId in metadata
        ...createQRCodeDto.metadata, // Spread any additional metadata if provided
      },
      basket: createQRCodeDto.basket,
    };
    console.log("Prepared QR data:", qrData);
    return qrData;
  }
}