Files
edr-platform/packages/payment-providers/src/providers/waafi/waafi.provider.ts
2026-07-23 20:11:11 +03:00

362 lines
13 KiB
TypeScript

import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { HttpService } from "@nestjs/axios";
import {
PaymentProvider,
ProviderInitiationInput,
ProviderInitiationResult,
ProviderStatus,
ProviderPaymentStatus,
ProviderMethod,
} from "@edr/types";
import { AxiosError, AxiosRequestConfig } from "axios";
import { firstValueFrom } from "rxjs";
import * as crypto from "node:crypto";
import * as https from "node:https";
import {
WaafiGetTranInfoRequest,
WaafiGetTranInfoResponse,
WaafiHppPurchaseRequest,
WaafiHppPurchaseResponse,
} from "./waafi.types";
const WAAFI_HTTP_TIMEOUT_MS = 10_000;
const WAAFI_SUCCESS_CODE = "2001";
/** Waafi cancels an unprocessed HPP session after ~5 minutes (RCS_HPP_USERACTION_TIMEOUT). */
const WAAFI_HPP_SESSION_MS = 5 * 60_000;
@Injectable()
export class WaafiProvider implements PaymentProvider, OnModuleInit {
readonly method = ProviderMethod.WAAFI;
private readonly logger = new Logger(WaafiProvider.name);
private readonly httpsAgent: https.Agent;
constructor(
private readonly config: ConfigService,
private readonly http: HttpService,
) {
const insecure = this.config.get<boolean>("waafi.insecureTls");
if (insecure) {
this.logger.warn(
"WAAFI_INSECURE_TLS=true — TLS verification disabled for Waafi calls. DEV ONLY.",
);
}
this.httpsAgent = new https.Agent({ rejectUnauthorized: !insecure });
}
/** Log the effective Waafi config once at startup (secrets masked) so misconfig is visible. */
onModuleInit(): void {
this.logger.log(
`Waafi config resolved: ${JSON.stringify(this.effectiveConfig())}`,
);
}
/** Snapshot of every resolved Waafi env value; secret fields are masked, not printed raw. */
private effectiveConfig(): Record<string, unknown> {
return {
WAAFI_BASE_URL: this.baseUrl,
WAAFI_MERCHANT_UID: this.merchantUid || "(empty)",
WAAFI_STORE_ID: this.storeId || "(empty)",
WAAFI_HPP_KEY: this.mask(this.hppKey),
WAAFI_WEBHOOK_SECRET: this.mask(this.webhookSecret),
WAAFI_PAYMENT_METHOD: this.paymentMethod,
WAAFI_HPP_SUCCESS_URL: this.successUrl || "(empty)",
WAAFI_HPP_FAILURE_URL: this.failureUrl || "(empty)",
WAAFI_HPP_RESP_FORMAT: this.respDataFormat,
WAAFI_INSECURE_TLS: this.config.get<boolean>("waafi.insecureTls") ?? false,
};
}
/** Mask a secret to `set(len=N,…abcd)` / `(empty)` so presence & length are visible but not the value. */
private mask(value: string): string {
if (!value) return "(empty)";
const tail = value.length > 4 ? value.slice(-4) : "";
return `set(len=${value.length},…${tail})`;
}
async initiate(
input: ProviderInitiationInput,
): Promise<ProviderInitiationResult> {
const requestBody = this.buildPurchaseRequest(input);
const url = `${this.baseUrl}/asm`;
this.logger.log(
`Waafi HPP_PURCHASE → ${url} | currency=${input.currency} amount=${this.toAmount(input.amountMinor)} (amountMinorIn=${input.amountMinor}) ref=${input.merchantOrderId}`,
);
this.logger.debug(
`Waafi HPP_PURCHASE request body: ${JSON.stringify(this.sanitize(requestBody))}`,
);
this.logger.debug(
`Waafi effective config: ${JSON.stringify(this.effectiveConfig())}`,
);
const response = await this.postJson<WaafiHppPurchaseResponse>(
url,
requestBody,
);
if (response.responseCode !== WAAFI_SUCCESS_CODE) {
this.logger.error(
`Waafi HPP_PURCHASE rejected — sent currency=${input.currency} amount=${this.toAmount(input.amountMinor)} paymentMethod=${this.paymentMethod} | full response: ${JSON.stringify(response)}`,
);
throw new Error(
`Waafi HPP_PURCHASE failed: responseCode=${response.responseCode} errorCode=${response.errorCode} msg=${response.responseMsg}`,
);
}
const checkoutUrl =
response.params?.hppUrl ?? response.params?.directPaymentLink;
const orderId = response.params?.orderId;
if (!checkoutUrl || !orderId) {
throw new Error(
`Waafi HPP_PURCHASE succeeded but returned no hppUrl/orderId: ${JSON.stringify(response)}`,
);
}
return {
providerOrderId: orderId,
clientAction: { type: "REDIRECT", url: checkoutUrl },
expiresAt: new Date(Date.now() + WAAFI_HPP_SESSION_MS),
rawInitiation: {
request: this.sanitize(requestBody),
response,
},
};
}
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
const requestBody = this.buildGetTranInfoRequest(merchantOrderId);
const response = await this.postJson<WaafiGetTranInfoResponse>(
`${this.baseUrl}/asm`,
requestBody,
);
this.logger.log(
`Waafi HPP_GETTRANINFO ref=${merchantOrderId} response: ${JSON.stringify(response)}`,
);
// Waafi returns transaction info (params.status) ONLY when responseCode is 2001. For an
// unpaid or not-yet-existing transaction it returns an error envelope (e.g. 5001 / E10206
// "Failed to get transaction info") with no status — i.e. the payer hasn't done anything at
// the hosted page yet. That's REQUIRES_ACTION (still awaiting the payer), NOT PROCESSING:
// returning PROCESSING here would let the reconciliation sweep persist that guess and block
// the payer from switching providers on a session they never touched (see cac-bank.provider's
// queryStatus for the same convention). The intent still resolves correctly either way — via
// the webhook on a genuine payment, or via expiresAt once the 5-minute HPP session lapses.
if (response.responseCode !== WAAFI_SUCCESS_CODE) {
this.logger.warn(
`Waafi HPP_GETTRANINFO ${merchantOrderId}: ${response.responseCode}/${response.errorCode} ${response.responseMsg} — treating as still awaiting the payer`,
);
return {
status: ProviderPaymentStatus.REQUIRES_ACTION,
rawResponse: response as unknown as Record<string, unknown>,
};
}
const rawState = response.params?.status ?? response.params?.tranStatusDesc;
const transactionId = response.params?.transactionId;
const mapped = this.mapStatus(rawState);
return {
status: mapped,
providerTxnId: transactionId,
failureCode:
mapped === ProviderPaymentStatus.FAILED && rawState
? rawState
: undefined,
rawResponse: response as unknown as Record<string, unknown>,
};
}
/** Map a webhook `payment.status` to the shared status enum. */
mapWebhookStatus(status: string | undefined): ProviderPaymentStatus {
return this.mapStatus(status);
}
/**
* Verify an HMAC-SHA256 webhook signature.
*
* Signing string is `{timestamp}.{eventId}.{rawBody}` over the *raw* request body bytes — the
* caller must pass the unparsed body string. Returns false (never throws) on any mismatch so
* callers can treat verification as a boolean gate.
*/
verifyWebhookSignature(
rawBody: string,
signature: string | undefined,
timestamp: string | undefined,
eventId: string | undefined,
): boolean {
if (!this.webhookSecret) {
this.logger.error(
"WAAFI_WEBHOOK_SECRET not configured; rejecting all webhooks",
);
return false;
}
if (!signature || !timestamp || !eventId) {
this.logger.warn(
"Waafi webhook missing signature/timestamp/event-id headers",
);
return false;
}
const signingString = `${timestamp}.${eventId}.${rawBody}`;
const expected = crypto
.createHmac("sha256", this.webhookSecret)
.update(signingString)
.digest("hex");
const provided = Buffer.from(signature, "utf8");
const computed = Buffer.from(expected, "utf8");
if (provided.length !== computed.length) return false;
return crypto.timingSafeEqual(provided, computed);
}
private mapStatus(raw: string | undefined): ProviderPaymentStatus {
switch (raw?.toUpperCase()) {
case "APPROVED":
case "SUCCESS":
return ProviderPaymentStatus.SUCCEEDED;
case "CANCELED":
case "CANCELLED":
return ProviderPaymentStatus.CANCELLED;
case "DECLINED":
case "FAILED":
case "EXPIRED":
case "TIMEOUT":
return ProviderPaymentStatus.FAILED;
case "PENDING":
case "INITIATED":
return ProviderPaymentStatus.REQUIRES_ACTION;
default:
return ProviderPaymentStatus.PROCESSING;
}
}
private buildPurchaseRequest(
input: ProviderInitiationInput,
): WaafiHppPurchaseRequest {
return {
schemaVersion: "1.0",
requestId: crypto.randomUUID(),
timestamp: this.timestamp(),
channelName: "WEB",
serviceName: "HPP_PURCHASE",
serviceParams: {
merchantUid: this.merchantUid,
storeId: this.storeId,
hppKey: this.hppKey,
paymentMethod: this.paymentMethod,
// Browser bounce-back is per-transaction (each calling app has its own UI), so the
// caller-supplied URLs win; the static config is only a fallback. UX-only — the
// webhook remains the single source of truth for payment state.
hppSuccessCallbackUrl: input.returnUrl ?? this.successUrl,
hppFailureCallbackUrl: input.failureUrl ?? this.failureUrl,
hppRespDataFormat: this.respDataFormat,
// MWALLET_ACCOUNT requires the payer phone up front; omit if the caller did not supply it
// and let the hosted page collect it. See docs/waffi open question on payer-phone sourcing.
...(input.payerAccount
? { payerInfo: { subscriptionId: input.payerAccount } }
: {}),
transactionInfo: {
referenceId: input.merchantOrderId,
amount: this.toAmount(input.amountMinor),
// Charge exactly the currency the caller already converted to (passenger/freight resolve
// the method's settlement currency). The provider never relabels the currency.
currency: input.currency,
description: `${input.orderRef}`,
},
},
};
}
private buildGetTranInfoRequest(
merchantOrderId: string,
): WaafiGetTranInfoRequest {
return {
schemaVersion: "1.0",
requestId: crypto.randomUUID(),
timestamp: this.timestamp(),
channelName: "WEB",
serviceName: "HPP_GETTRANINFO",
serviceParams: {
merchantUid: this.merchantUid,
storeId: this.storeId,
hppKey: this.hppKey,
referenceId: merchantOrderId,
},
};
}
/** Convert integer minor units to a 2-decimal major amount (truncated, never rounded up). */
private toAmount(amountMinor: number): number {
return Math.trunc(amountMinor);
}
private timestamp(): string {
return Math.round(Date.now() / 1000).toString();
}
private async postJson<T>(url: string, body: unknown): Promise<T> {
const config: AxiosRequestConfig = {
headers: { "Content-Type": "application/json" },
timeout: WAAFI_HTTP_TIMEOUT_MS,
httpsAgent: this.httpsAgent,
};
const started = Date.now();
try {
const res = await firstValueFrom(this.http.post<T>(url, body, config));
this.logger.debug(
`Waafi POST ${url} status=${res.status} latency=${Date.now() - started}ms`,
);
return res.data;
} catch (err) {
if (err instanceof AxiosError) {
this.logger.error(
`Waafi POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)} code=${err.code} message=${err.message}`,
);
} else {
this.logger.error(
`Waafi POST ${url} threw: ${err instanceof Error ? err.message : err}`,
);
}
throw err;
}
}
private sanitize(body: WaafiHppPurchaseRequest): Record<string, unknown> {
return {
...body,
serviceParams: { ...body.serviceParams, hppKey: "***REDACTED***" },
};
}
private get baseUrl(): string {
return (
this.config.get<string>("waafi.baseUrl") ?? "https://sandbox.waafipay.net"
);
}
private get merchantUid(): string {
return this.config.get<string>("waafi.merchantUid") ?? "";
}
private get storeId(): string {
return this.config.get<string>("waafi.storeId") ?? "";
}
private get hppKey(): string {
return this.config.get<string>("waafi.hppKey") ?? "";
}
private get webhookSecret(): string {
return this.config.get<string>("waafi.webhookSecret") ?? "";
}
private get paymentMethod(): string {
return this.config.get<string>("waafi.paymentMethod") ?? "MWALLET_ACCOUNT";
}
private get successUrl(): string {
return this.config.get<string>("waafi.successUrl") ?? "";
}
private get failureUrl(): string {
return this.config.get<string>("waafi.failureUrl") ?? "";
}
private get respDataFormat(): number {
return this.config.get<number>("waafi.respDataFormat") ?? 1;
}
}