Skip to content

MQ-Pay Integration

MQ-Pay (Multi-provider Quick Payment System) is a payment integration component that provides a unified API for connecting multiple payment providers.

Overview

AttributeValue
Package@nx/mq-pay
StatusProduction
ProvidersVNPAY QR MMS, VNPAY Phone POS, VNPAY Smart POS, System (Cash/Bank Transfer)

Deployment Modes

MQ-Pay supports mode-based deployment via APP_ENV_MQ_PAY_MODE for independent scaling of API and worker components:

ModeControllersQueue ProducersBullMQ WorkersEvent HandlerUse case
fullYesYesYesYesDefault — single instance handles everything
apiYesYesNoNoREST API only — enqueues jobs, no processing
workerNoYesYesYesBullMQ workers only — processes jobs, no HTTP

Production deployment pattern: 1 API instance (mode=api, Snowflake ID=8) + N worker instances (mode=worker, unique Snowflake IDs 91, 92, ...).

Key Features

  • Multi-provider - Support multiple payment gateways in one place
  • Quick integration - Simple setup, start accepting payments in minutes
  • Unified API - Same interface for all providers
  • Background processing - BullMQ queues for reliable payment processing
  • Event notifications - Callback-based event system for payment lifecycle events

Quick Start

1. Configure your application

typescript
// application.ts
import { MQPayComponent, MQPayBindingKeys, IMQPayOptions } from '@nx/mq-pay';

class MyApplication extends BaseApplication {
  preConfigure() {
    // Configure payment providers
    this.bind<IMQPayOptions>({ key: MQPayBindingKeys.MQ_PAY_CLIENT_OPTIONS })
      .toValue({
        mode: process.env.APP_ENV_MQ_PAY_MODE ?? 'full',  // 'full' | 'api' | 'worker'
        vnpayQrMMS: {
          enable: true,
          isDefault: true,
          enableController: true,
          appId: process.env.VNPAY_APP_ID,
          secretKey: process.env.VNPAY_SECRET_KEY,
          masterMerchantCode: process.env.VNPAY_MERCHANT_CODE,
          isProduction: process.env.NODE_ENV === 'production',
        },
      });

    // Load MQ-Pay component
    this.component(MQPayComponent);
  }
}

2. Set environment variables

bash
# Deployment Mode (full | api | worker)
APP_ENV_MQ_PAY_MODE=full

# Database (PostgreSQL)
APP_ENV_MQ_PAY_POSTGRES_HOST=localhost
APP_ENV_MQ_PAY_POSTGRES_PORT=5432
APP_ENV_MQ_PAY_POSTGRES_DATABASE=mq_pay
APP_ENV_MQ_PAY_POSTGRES_USERNAME=postgres
APP_ENV_MQ_PAY_POSTGRES_PASSWORD=secret

# Redis (for queues)
APP_ENV_MQ_PAY_REDIS_QUEUE_HOST=localhost
APP_ENV_MQ_PAY_REDIS_QUEUE_PORT=6379

3. Done!

MQ-Pay will automatically:

  • Connect to your database and Redis
  • Register payment providers you enabled
  • Set up background job queues for payment processing
  • Register IPN/webhook endpoints (if enableController: true)

Supported Providers

ProviderStatusDescription
SYSTEMAvailableCash, Manual Bank Transfer
VNPAY_QR_MMSAvailableVNPAY Dynamic QR Code Payment
VNPAY_PHONE_POSAvailableVNPAY Phone POS Integration
VNPAY_SMART_POSAvailableVNPAY Smart POS Terminal
MOMOComing Soon-
ZaloPayComing Soon-

Architecture

Component Initialization Flow

When MQPayComponent.binding() is called, the following initialization sequence occurs:

Domain Model

Transaction - One order → one transaction

  • Tracks total amount (net, tax, fee, discount, total, paid) and status
  • uid is the human-readable unique identifier (not number)
  • Unique constraint prevents duplicate active transactions per source

TransactionItem - Line items snapshot at payment time

  • Captures item pricing at the moment of transaction creation

PaymentAttempt - Each payment method creates an attempt

  • Links to transaction, tracks provider and method used
  • remaining is auto-calculated by PostgreSQL (amount - tendered)
  • parentId links cancel/refund attempts to the original make-payment attempt

PaymentResult - Provider response/callback data

  • Stores raw IPN/webhook payloads with deduplication via payloadHash
  • Unique index on (paymentAttemptId, type, payloadHash) prevents duplicate processing

AppRegistry Pattern

MQ-Pay uses a singleton registry (AppRegistry) separate from the IGNIS DI container to manage runtime resources. This is necessary because BullMQ workers and Redis connections have lifecycles that don't fit the request-scoped DI model.

RegistryPrefixPurpose
RedisredisConnection pool for BullMQ queues
BullMQbullmqQueue and worker instances
Payment Providerspayment-providerVNPAY, System, etc.
Event Handler(singleton)IPaymentEventHandler for event dispatch

IMPORTANT

AppRegistry cannot use @inject() decorators. Always access via AppRegistry.getInstance().

Queue System

MQ-Pay uses BullMQ for reliable background job processing. Jobs are distributed across partitions for load balancing.

Queue TypePartitionsJob TypePurpose
schedulerP01, P02, P03PAYMENT_EXPIRATIONExpire unpaid QR codes after timeout
confirmationP01, P02, P03PROCESS_IPNProcess IPN/webhook callbacks from providers

Total per instance: full/worker mode: 6 queues + 6 workers. api mode: 6 queue producers only (no workers). 3 partitions per type, 10 concurrent jobs per worker by default.

Partition Selection: Jobs are assigned to partitions using a hash of the transactionId, ensuring all jobs for the same transaction go to the same partition.

Status Lifecycles

Transaction Status Lifecycle

NOTE

SETTLED is a terminal state — once a transaction is fully paid, it cannot be modified. Refunds create new REFUND_PAYMENT attempts but do not change the transaction status.

PaymentAttempt Status Lifecycle

Payment Flows

QR Code Payment Flow

Cash Payment Flow

Split Payment Flow

IPN Processing Flow

Cancel Payment Flow

Refund Payment Flow

API Reference

Create Payment

http
POST /api/payments/checkout
Content-Type: application/json

{
  "source": {
    "type": "Order",
    "id": "order-uuid-123",
    "uid": "ORD-2024-001"
  },
  "payment": {
    "provider": "VNPAY_QR_MMS",
    "method": "100_QR_CODE",
    "total": 100000,
    "currency": "VND"
  },
  "expiration": {
    "mode": "duration",
    "milliseconds": 300000
  }
}

Response:

json
{
  "transaction": {
    "id": "txn-uuid-001",
    "number": "TXN001",
    "status": "100_NEW",
    "total": "100000.0000",
    "paid": "0.0000"
  },
  "attempt": {
    "id": "att-uuid-001",
    "code": "ATT001",
    "status": "204_SENT",
    "provider": "VNPAY_QR_MMS",
    "method": "100_QR_CODE"
  },
  "payment": {
    "qrCode": "00020101021238...",
    "qrUrl": "https://...",
    "expiresAt": "2024-01-15T10:35:00Z"
  }
}

Confirm Cash Payment

http
POST /api/payments/system/ipn
Content-Type: application/json

{
  "attempt": { "id": "att-uuid-001" },
  "tendered": 100000,
  "note": "Cash payment received"
}

Response:

json
{
  "transaction": {
    "id": "txn-uuid-001",
    "status": "304_SETTLED",
    "paid": "100000.0000",
    "settledAt": "2024-01-15T10:30:00Z"
  },
  "attempt": {
    "id": "att-uuid-001",
    "status": "302_SUCCESS",
    "tendered": "100000.0000",
    "remaining": "0.0000"
  }
}

Continue Partial Payment

http
POST /api/payments/checkout
Content-Type: application/json

{
  "transactionId": "txn-uuid-001",
  "source": {
    "type": "Order",
    "id": "order-uuid-123"
  },
  "payment": {
    "provider": "SYSTEM",
    "method": "000_CASH",
    "total": 40000,
    "currency": "VND"
  }
}

Provider Configuration

VNPAY QR MMS

typescript
vnpayQrMMS: {
  enable: true,
  isDefault: true,
  enableController: true,
  appId: 'your-app-id',
  secretKey: 'your-secret-key',
  masterMerchantCode: 'your-merchant-code',
  isProduction: false,

  // Optional callback
  onPaymentNotification: async (ipnData) => {
    console.log('Payment received:', ipnData);
    // Trigger your business logic
  },
}

VNPAY Phone POS

typescript
vnpayPhonePOS: {
  enable: true,
  enableController: true,
  appId: 'your-phone-pos-app-id',
  secretKey: 'your-secret-key',
  merchantCode: 'your-merchant-code',
  terminalId: 'your-terminal-id',
  isProduction: false,
}

System (Cash / Bank Transfer)

No configuration needed - always available.

Status Reference

Transaction Statuses

StatusCodeDescription
NEW100_NEWCreated, no payment yet
PARTIAL300_PARTIALPartially paid
SETTLED304_SETTLEDFully paid (terminal, immutable)
CANCELLED505_CANCELLEDAuto-cancelled when all attempts cancelled
BLOCKED403_BLOCKEDTemporarily blocked by admin
CLOSED404_CLOSEDPermanently closed by admin

PaymentAttempt Statuses

StatusCodeDescription
NEW100_NEWCreated, not sent to provider
SENT204_SENTSent to provider, QR available
SUCCESS302_SUCCESSPayment confirmed via IPN
FAIL500_FAILProvider call failed
EXPIRED501_EXPIREDQR expired, no payment received

PaymentAttempt Types

TypeCodeDescription
MAKE_PAYMENT100_MAKE_PAYMENTPayment attempt
CANCEL_PAYMENT200_CANCEL_PAYMENTCancellation attempt (links to original via parentId)
REFUND_PAYMENT300_REFUND_PAYMENTRefund attempt (links to original via parentId)

Payment Methods

MethodCodeDescription
CASH000_CASHCash payment
QR_CODE100_QR_CODEQR code payment
BANK_TRANSFER200_BANK_TRANSFERBank transfer
E_WALLET300_E_WALLETE-wallet payment
CARD400_CARDCard payment

Services

PaymentExecutionService

Source: services/payment/execution.service.ts | Extends: BasePaymentService

DI Dependencies: TransactionRepository, TransactionItemRepository, PaymentAttemptRepository, QueueProcessorService

MethodSignatureDescription
makePayment(opts: { payload: TMakePaymentRequest }) → Promise<TMakePaymentResponse>Create transaction + attempt, call provider or prepare manual payment

Handles both new transactions and adding attempts to existing transactions (split payment). Validates item totals, calculates expiration, and adds scheduler jobs for third-party providers.

PaymentCancellationService

Source: services/payment/cancellation.service.ts | Extends: BasePaymentService

DI Dependencies: TransactionRepository, PaymentAttemptRepository, QueueProcessorService

MethodSignatureDescription
cancelPayment(opts: { payload: TCancelPaymentRequest }) → Promise<TCancelPaymentResponse>Cancel specific attempt or all attempts for a transaction

Creates CANCEL_PAYMENT child attempts linked via parentId. For third-party providers, calls provider.cancelPayment(). Removes scheduler expiration jobs.

PaymentConfirmationService

Source: services/payment/confirmation.service.ts | Extends: BasePaymentService

DI Dependencies: TransactionRepository, PaymentAttemptRepository, PaymentResultRepository

MethodSignatureDescription
confirmPayment(opts: TConfirmPaymentRequest) → Promise<TConfirmPaymentResponse>Confirm manual payment (CASH/BANK_TRANSFER) received

Only works for SYSTEM provider with CASH or BANK_TRANSFER methods. Updates attempt to SUCCESS, calculates if transaction is SETTLED or PARTIAL.

PaymentRefundService

Source: services/payment/refund.service.ts | Extends: BasePaymentService

DI Dependencies: PaymentAttemptRepository

MethodSignatureDescription
refund(opts: TRefundRequest) → Promise<TRefundResponse>Refund a successful MAKE_PAYMENT attempt

Validates the original attempt is SUCCESS, checks total already refunded via getTotalRefundedAmount(), ensures refund amount does not exceed remaining.

PaymentVerificationService

Source: services/payment/verification.service.ts | Extends: BasePaymentService

DI Dependencies: TransactionRepository, PaymentAttemptRepository, PaymentResultRepository

MethodSignatureDescription
verifyAttempt(opts: TCheckTransactionRequest) → Promise<TCheckTransactionResponse>Verify payment attempt status against provider

For third-party providers, calls provider.checkTransaction(). For manual payments, returns local status.

QueueProcessorService

Source: services/queue-processor.service.ts | Extends: BaseService

DI Dependencies: PaymentAttemptRepository, TransactionRepository, PaymentResultRepository

MethodSignatureDescription
initialize() → voidCreate all queues and workers — calls initializeQueues() + initializeWorkers()
initializeQueues() → voidCreate 6 queue producer instances only (used by api mode)
initializeWorkers() → voidCreate 6 worker instances only (used internally by initialize())
addSchedulerJob(opts: { data, jobOptions? }) → Promise<{ queue, job } | null>Add payment expiration job
removeSchedulerJob(opts: { transactionId, jobId? }) → Promise<void>Remove expiration job
addConfirmationJob(opts: { data, jobOptions? }) → Promise<Job>Add IPN processing job
createSchedulerWorkers(opts?: { handler?, concurrency? }) → BullMQHelper[]Create scheduler partition workers
createConfirmationWorkers(opts?: { handler?, concurrency? }) → BullMQHelper[]Create confirmation partition workers
getQueue(opts: { type, partition }) → BullMQHelperGet queue by type and partition
getQueueByKey(opts: { type, partitionKey }) → BullMQHelperGet queue by partition key (auto-resolved)

Repositories

RepositoryEntityBase ClassCustom Methods
TransactionRepositoryTransactionDefaultCRUDRepositorygetTotalPaidAmount({ transactionId }) — Sum of tendered from SUCCESS MAKE_PAYMENT attempts
PaymentAttemptRepositoryPaymentAttemptDefaultCRUDRepositoryisAttemptCancelled({ attemptId }) — Check if cancelled via child attempt
findActiveAttempts({ transactionId }) — Find NEW/SENT attempts not cancelled (CTE query)
getTotalRefundedAmount({ parentAttemptId }) — Sum of SUCCESS refund amounts
resolveAttemptForIPN({ attemptId }) — Get attempt + transaction + cancellation status in one query
PaymentResultRepositoryPaymentResultDefaultCRUDRepository(CRUD only)
TransactionItemRepositoryTransactionItemDefaultCRUDRepository(CRUD only)

NOTE

All repositories use the mqpay PostgreSQL schema (configurable via APP_ENV_MQ_PAY_SCHEMA).

Error Handling

Common Errors

ErrorCauseSolution
Invalid transaction statusAdding payment to completed transactionCheck transaction status first
Only CASH/BANK_TRANSFER can be confirmedConfirming QR payment via system endpointUse provider's IPN
PaymentAttempt is not in NEW statusDouble confirmationCheck attempt status first
Missing binding keyComponent not configuredBind options before loading component
Refund amount exceeds remainingRefund > (original - already refunded)Check getTotalRefundedAmount() first

Environment Variables Reference

Database (Required)

VariableDescriptionDefault
APP_ENV_MQ_PAY_POSTGRES_HOSTDatabase host-
APP_ENV_MQ_PAY_POSTGRES_PORTDatabase port5432
APP_ENV_MQ_PAY_POSTGRES_DATABASEDatabase name-
APP_ENV_MQ_PAY_POSTGRES_USERNAMEDatabase user-
APP_ENV_MQ_PAY_POSTGRES_PASSWORDDatabase password-
APP_ENV_MQ_PAY_SCHEMAPostgreSQL schema namemqpay

Redis Queue (Required)

VariableDescriptionDefault
APP_ENV_MQ_PAY_REDIS_QUEUE_HOSTRedis host (single mode)localhost
APP_ENV_MQ_PAY_REDIS_QUEUE_PORTRedis port6379
APP_ENV_MQ_PAY_REDIS_QUEUE_PASSWORDRedis password-
APP_ENV_MQ_PAY_REDIS_QUEUE_MODEsingle or clustersingle
APP_ENV_MQ_PAY_REDIS_QUEUE_MAX_RETRYMax connection retries-
APP_ENV_MQ_PAY_REDIS_QUEUE_CLUSTER_NODESComma-separated host:port (cluster mode)-

Snowflake ID

VariableDescriptionDefault
APP_ENV_MQ_PAY_WORKER_IDSnowflake worker ID0
APP_ENV_MQ_PAY_EPOCH_CHECKPOINTSnowflake epoch timestamp1735689600000

Deployment Mode

VariableDescriptionDefault
APP_ENV_MQ_PAY_MODEDeployment mode: full, api, or workerfull
  • full — Registers controllers, creates queue producers and BullMQ workers, binds event handler. Default for development and simple deployments.
  • api — Registers controllers and queue producers only. No workers, no event handler. Use for scaling API independently.
  • worker — Creates queue producers, BullMQ workers, and binds event handler. No controllers. Use for scaling job processing independently.

Code Statistics

MetricCount
Source Files25+ TypeScript files
Entities4 (Transaction, TransactionItem, PaymentAttempt, PaymentResult)
Repositories4 (all with DefaultCRUDRepository)
Services7 (6 payment + QueueProcessor)
Controllers5 (PaymentController + 4 CRUD)
REST Endpoints5 custom + 4×CRUD
Payment Providers4 (System, VNPAY QR MMS, Phone POS, Smart POS)
BullMQ Queues6 (2 types × 3 partitions)
Redis Connections1 (queue)
PostgreSQL Schemamqpay

Proprietary and Confidential. Unauthorized copying, distribution, or use of this software is strictly prohibited.