mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 01:18:18 +00:00
362 lines
13 KiB
TypeScript
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;
|
|
}
|
|
}
|