Configuration & Credentials
1. Overview
The Payment service manages encrypted payment provider configurations and per-merchant credentials stored in the
Configurationtable (publicschema). Configurations are loaded at startup to initialize MQ-Pay, while credentials are fetched on-demand per payment request. Both are encrypted with AES-256-GCM usingAPP_ENV_APPLICATION_SECRETand decrypted at the service layer — raw encrypted data is never exposed.
2. PaymentConfigurationService
Source: src/services/payment-configuration.service.tsExtends: BaseServiceDI Dependencies: ConfigurationRepository (via @inject) Internal: CryptoUtility.getInstance() — singleton, initialized in constructor
2.1. Constructor
constructor(
@inject({
key: BindingKeys.build({
namespace: BindingNamespaces.REPOSITORY,
key: ConfigurationRepository.name,
}),
})
private readonly _configurationRepository: ConfigurationRepository,
) {
super({ scope: PaymentConfigurationService.name });
this._crypto = CryptoUtility.getInstance();
}2.2. getPaymentConfiguration()
Signature:
async getPaymentConfiguration<ReturnType = AnyObject>(opts: {
code: typeof SystemConfigurations.VNPAY_QR_MMS
| typeof SystemConfigurations.VNPAY_PHONE_POS
| string;
}): Promise<ReturnType>Purpose: Load a provider configuration (e.g., VNPAY QR MMS connection settings) from the Configuration table, decrypt tValue, and return parsed JSON.
Flow:
Query parameters:
| Filter | Value | Source |
|---|---|---|
group | 200_INTEGRATION | ConfigurationGroups.INTEGRATION |
status | ACTIVATED | ConfigurationStatuses.ACTIVATED |
code | e.g., VNPAY_QR_MMS | opts.code |
environment | DEVELOPMENT or PRODUCTION | Auto-detected via applicationEnvironment.isDevelopment() |
Error handling:
| Condition | Status Code | Message |
|---|---|---|
Config not found or no tValue | 404 NotFound | Payment configuration not found | code: {code} |
| Decryption fails | 500 InternalServerError | Failed to decrypt payment configuration | code: {code} |
Called during startup in ApplicationPaymentComponent.setupMQPay() for:
SystemConfigurations.VNPAY_QR_MMS→IMQPayOptions.vnpayQrMMSSystemConfigurations.VNPAY_PHONE_POS→IMQPayOptions.vnpayPhonePos
Both calls are made in parallel via Promise.all().
2.3. getPaymentCredential()
Signature:
async getPaymentCredential(opts: {
provider: TMQPayProvider;
action: TMQPayCredentialAction;
types: TMQPayCredentialType[];
context: IMQPayCredentialContext;
}): Promise<IMQPayCredentialResult[]>Purpose: Query per-merchant payment credentials from Configuration table, bypassing the repository's hidden property filter to access the encrypted credential column directly via Drizzle ORM.
Flow:
IMPORTANT
This method bypasses the standard repository API by using this._configurationRepository.getConnector() to get the raw Drizzle query builder. This is necessary because credential is a hidden property in the Configuration model (hiddenProperties: ['credential', 'createdAt', 'modifiedAt', 'deletedAt']), meaning .find() and .findOne() exclude it from results.
Raw Drizzle query:
const connector = this._configurationRepository.getConnector();
const typesArray = sql.raw(`ARRAY[${types.map(t => `'${t}'`).join(', ')}]`);
const configs = await connector
.select({
id: ConfigurationSchema.id,
metadata: ConfigurationSchema.metadata,
credential: ConfigurationSchema.credential,
})
.from(ConfigurationSchema)
.where(
and(
eq(ConfigurationSchema.group, ConfigurationGroups.INTEGRATION),
eq(ConfigurationSchema.status, ConfigurationStatuses.ACTIVATED),
eq(ConfigurationSchema.principalType, Merchant.name), // 'Merchant'
eq(ConfigurationSchema.principalId, context.merchant.id),
eq(ConfigurationSchema.environment, environment),
isNull(ConfigurationSchema.deletedAt), // Explicit soft-delete check
sql`${ConfigurationSchema.metadata}->>'type' = ${ConfigurationMetadataTypes.PAYMENT_PROVIDER}`,
sql`${ConfigurationSchema.metadata}->>'provider' = ${provider}`,
sql`${ConfigurationSchema.metadata}->'credential'->>'action' = ${action}`,
sql`${ConfigurationSchema.metadata}->'credential'->>'type' = ANY(${typesArray})`,
),
);Query filter breakdown:
| Filter | Column/Expression | Value | Purpose |
|---|---|---|---|
| Group | group | 200_INTEGRATION | Only integration configs |
| Status | status | ACTIVATED | Only active configs |
| Principal type | principalType | Merchant | Per-merchant credentials |
| Principal ID | principalId | context.merchant.id | Specific merchant |
| Environment | environment | DEVELOPMENT / PRODUCTION | Env-specific |
| Soft delete | deletedAt IS NULL | — | Explicit check (bypasses SoftDeletableRepository) |
| Metadata type | metadata->>'type' | PAYMENT_PROVIDER | Discriminator |
| Provider | metadata->>'provider' | e.g., VNPAY_QR_MMS | Provider filter |
| Credential action | metadata->'credential'->>'action' | e.g., CREATE_PAYMENT | Action filter |
| Credential type | metadata->'credential'->>'type' | ANY(ARRAY['request', 'ipn']) | Type filter (multi-match) |
Return type:
interface IMQPayCredentialResult<CredentialType = TNullable<SecretKeyType>> {
provider: TMQPayProvider; // e.g., 'VNPAY_QR_MMS'
action: TMQPayCredentialAction; // e.g., 'CREATE_PAYMENT'
type: TMQPayCredentialType; // e.g., 'request'
credential: CredentialType; // Decrypted credential string (nullable)
}Error handling:
- Missing merchant ID → throws generic error
- Decryption failure → logs error per credential, pushes result with
nullcredential (does not throw) - No results found → logs warning, returns empty array
[]
2.4. Credential Getter Binding
In ApplicationPaymentComponent.setupMQPay(), the service is wrapped as a credential getter callback:
this.application.bind<IMQPayOptions>({ key: MQPayBindingKeys.MQ_PAY_CLIENT_OPTIONS }).toValue({
credentialGetter: opts => paymentConfigService.getPaymentCredential(opts),
// ... other options
});MQ-Pay calls this getter at runtime when it needs credentials for a specific provider action (e.g., creating a VNPAY QR payment).
3. AES-256-GCM Encryption
3.1. CryptoUtility
Source: packages/core/src/utilities/crypto.utility.tsPattern: Singleton (CryptoUtility.getInstance()) Key source: APP_ENV_APPLICATION_SECRET environment variable
export class CryptoUtility {
private static _instance: CryptoUtility;
private readonly _aes: AES;
private readonly _encryptionKey: string;
private constructor() {
this._aes = AES.withAlgorithm('aes-256-gcm');
this._encryptionKey = applicationEnvironment.get<string>(
EnvironmentKeys.APP_ENV_APPLICATION_SECRET,
);
}
encrypt(text: string): string {
return this._aes.encrypt({ message: text, secret: this._encryptionKey });
}
decrypt(encryptedText: string): string {
return this._aes.decrypt({ message: encryptedText, secret: this._encryptionKey });
}
sign(opts: ISignOptions): string {
const { timestamp, eventType, parts, secret } = opts;
const rawPayload = [timestamp, eventType, ...parts].join('|');
return hash(rawPayload, { algorithm: 'SHA256', outputType: 'base64', secret });
}
}3.2. Encryption Details
| Property | Value |
|---|---|
| Algorithm | AES-256-GCM (authenticated encryption) |
| Key source | APP_ENV_APPLICATION_SECRET |
| Key derivation | Used directly by IGNIS AES.withAlgorithm('aes-256-gcm') |
| Encrypted fields | Configuration.tValue (provider config), Configuration.credential (provider secrets) |
| Additional method | sign() — HMAC SHA-256 for webhook signature verification |
3.3. Which Fields Are Encrypted
| Entity | Field | Contains | When Decrypted |
|---|---|---|---|
Configuration | tValue | Provider connection settings (JSON string) | At startup via getPaymentConfiguration() |
Configuration | credential | Provider secret keys / tokens | On-demand via getPaymentCredential() |
WARNING
APP_ENV_APPLICATION_SECRET must be identical across all services that read payment configurations (Payment, Sale, etc.). A mismatch will cause AES.decrypt() to fail with a decryption error. There is no key rotation mechanism — changing the secret requires re-encrypting all stored data.
4. ApplicationPaymentComponent Init Sequence
Full initialization sequence when RUN_MODE=startup:
| Phase | Step | Action | DI Key |
|---|---|---|---|
| Repositories | 1 | Register ConfigurationRepository | repositories.ConfigurationRepository |
| 2 | Register WebhookConfigRepository | repositories.WebhookConfigRepository | |
| 3 | Register MigrationRepository | repositories.MigrationRepository | |
| Services | 4 | Register PaymentConfigurationService | services.PaymentConfigurationService |
| 5 | Register WebhookDispatcherService | services.WebhookDispatcherService | |
| Controllers | 6 | Register WebhookConfigController | — |
| MQ-Pay | 7 | Resolve PaymentConfigurationService from DI | — |
| 8 | Resolve WebhookConfigRepository from DI | — | |
| 9 | Resolve WebhookDispatcherService from DI | — | |
| 10 | Create WebhookEventHandlerHelper (manual new) | — | |
| 11 | Promise.all(): Load VNPAY_QR_MMS + VNPAY_PHONE_POS configs | — | |
| 12 | Bind IMQPayOptions to DI | @nx-3rd/mq-pay/client-options | |
| 13 | Mount MQPayComponent | — |
NOTE
When RUN_MODE is not startup (e.g., RUN_MODE=migrate), steps 7–13 are skipped entirely. Only repositories, services, and controllers are registered. This ensures migrations can run without requiring MQ-Pay infrastructure (Redis, BullMQ, VNPAY credentials).
5. Seeded Data
Source: src/migrations/processes/migration-process.ts
export const getMigrationProcesses = createMigrationProcessLoader({
seedPaths: [
'payment-0001-seed-vnpay-qr-mms-configuration',
'payment-0002-seed-vnpay-phone-pos-configuration',
],
importFn: path => import(`../processes/${path}.js`),
});5.1. VNPAY QR MMS Seed
File: payment-0001-seed-vnpay-qr-mms-configuration
| Setting | Value | Notes |
|---|---|---|
| Provider | VNPAY_QR_MMS | Dynamic QR code payments |
| Code | VNPAY_QR_MMS | Matches SystemConfigurations.VNPAY_QR_MMS |
| Group | 200_INTEGRATION | ConfigurationGroups.INTEGRATION |
| Status | ACTIVATED | Active immediately |
| Default | true | Default payment provider |
| Production | false | Development environment only |
| App ID | MERCHANT | VNPAY application identifier |
| Master Merchant Code | A000000775 | VNPAY master merchant |
5.2. VNPAY Phone POS Seed
File: payment-0002-seed-vnpay-phone-pos-configuration
| Setting | Value | Notes |
|---|---|---|
| Provider | VNPAY_PHONE_POS | NFC card payment |
| Code | VNPAY_PHONE_POS | Matches SystemConfigurations.VNPAY_PHONE_POS |
| Group | 200_INTEGRATION | ConfigurationGroups.INTEGRATION |
| Status | ACTIVATED | Active immediately |
| Default | false | Not the default provider |
| Production | false | Development environment only |
6. Configuration Model Reference
6.1. Entity Definition
Source: packages/core/src/models/schemas/public/configuration/model.ts
@model({
type: 'entity',
settings: {
hiddenProperties: ['credential', 'createdAt', 'modifiedAt', 'deletedAt'],
defaultFilter: { where: { deletedAt: null } },
},
})
export class Configuration extends BaseEntity<typeof ConfigurationSchema> {
static override TABLE_NAME = 'Configuration';
static override schema = ConfigurationSchema;
}Key model settings:
hiddenProperties: These fields are excluded from all repository.find()/.findOne()resultsdefaultFilter: Automatically addsWHERE deletedAt IS NULLto all queries (soft-delete)
6.2. ConfigurationMetadataTypes
Discriminated union system for the metadata JSONB column:
| Type | Constant | Interface | Description |
|---|---|---|---|
PAYMENT_PROVIDER | ConfigurationMetadataTypes.PAYMENT_PROVIDER | IPaymentProviderMetadata | Payment provider credentials |
interface IPaymentProviderMetadata {
type: 'PAYMENT_PROVIDER';
provider: string; // TMQPayProvider (e.g., 'VNPAY_QR_MMS')
credential: {
action: string; // TMQPayCredentialAction (e.g., 'CREATE_PAYMENT')
type: string; // TMQPayCredentialType (e.g., 'request')
};
}6.3. ConfigurationGroups
| Group | Constant | Usage |
|---|---|---|
000_SYSTEM | ConfigurationGroups.SYSTEM | System-level settings |
100_TABLE | ConfigurationGroups.TABLE | UI table configurations |
200_INTEGRATION | ConfigurationGroups.INTEGRATION | Payment providers, external integrations |
6.4. ConfigurationStatuses
| Status | Constant | Description |
|---|---|---|
ACTIVATED | ConfigurationStatuses.ACTIVATED | Active — used in queries |
DEACTIVATED | ConfigurationStatuses.DEACTIVATED | Temporarily disabled |
ARCHIVED | ConfigurationStatuses.ARCHIVED | Permanently disabled |
7. Related Documentation
| Document | Description |
|---|---|
| Overview & Setup | Architecture, domain model, components, services |
| Webhook Dispatch | Event handling, retry logic, payload format |
| Deployment | Traefik routing, Docker, health checks |
| Core Package | Configuration schema, CryptoUtility source |