feat(payment-providers): implement Waafi in provider package

This commit is contained in:
Abubeker Yasin
2026-06-10 09:14:18 +03:00
parent 1d11cbda4d
commit 66b45ce22c
5 changed files with 325 additions and 155 deletions

View File

@@ -39,12 +39,28 @@ export type {
TelebirrTradeStatus,
} from './providers/telebirr/telebirr.types';
// Waafi HPP request/response types (exported for apps that build/inspect requests directly)
export type {
WaafiState,
WaafiHppPurchaseRequest,
WaafiHppPurchaseResponse,
WaafiGetTranInfoRequest,
WaafiGetTranInfoResponse,
} from './providers/waafi/waafi.types';
// Webhook payload types
export type { TelebirrWebhookPayload } from './webhooks/telebirr-webhook.types';
export type { CbeBirrWebhookPayload } from './webhooks/cbe-birr-webhook.types';
export type { EBirrWebhookPayload } from './webhooks/ebirr-webhook.types';
export type { CardWebhookPayload } from './webhooks/card-webhook.types';
export type { WaafiWebhookPayload } from './webhooks/waafi-webhook.types';
export type {
WaafiWebhookPayload,
WaafiWebhookTransactionPayload,
WaafiWebhookTestPayload,
WaafiWebhookHeaders,
WaafiWebhookEvent,
WaafiWebhookStatus,
} from './webhooks/waafi-webhook.types';
// DI token for injecting all providers as an array (future multi-provider wiring)
export const PAYMENT_PROVIDERS = Symbol('PAYMENT_PROVIDERS');

View File

@@ -11,71 +11,18 @@ import {
} from '@edr/types';
import { AxiosError, AxiosRequestConfig } from 'axios';
import { firstValueFrom } from 'rxjs';
import * as crypto from 'node:crypto';
import {
WaafiGetTranInfoRequest,
WaafiGetTranInfoResponse,
WaafiHppPurchaseRequest,
WaafiHppPurchaseResponse,
} from './waafi.types';
const WAAFI_HTTP_TIMEOUT_MS = 10_000;
interface WaafiInitiateRequest {
schemaVersion: string;
requestId: string;
timestamp: string;
channelName: string;
serviceName: string;
serviceParams: {
merchantUid: string;
apiUserId: string;
apiKey: string;
paymentMethod: string;
payerInfo: {
accountNo: string;
};
transactionInfo: {
referenceId: string;
invoiceId: string;
amount: number;
currency: string;
description: string;
};
};
}
interface WaafiInitiateResponse {
responseCode: string;
responseMsg: string;
params?: {
state: string;
referenceId: string;
transactionId: string;
checkoutUrl?: string;
};
}
interface WaafiQueryRequest {
schemaVersion: string;
requestId: string;
timestamp: string;
channelName: string;
serviceName: string;
serviceParams: {
merchantUid: string;
apiUserId: string;
apiKey: string;
transactionId?: string;
referenceId?: string;
};
}
interface WaafiQueryResponse {
responseCode: string;
responseMsg: string;
params?: {
state: string;
referenceId: string;
transactionId: string;
amount: number;
currency: string;
paidAmount?: number;
};
}
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 {
@@ -88,31 +35,30 @@ export class WaafiProvider implements PaymentProvider {
) {}
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
const requestBody = this.buildInitiateRequest(input);
const response = await this.postJson<WaafiInitiateResponse>(
const requestBody = this.buildPurchaseRequest(input);
const response = await this.postJson<WaafiHppPurchaseResponse>(
`${this.baseUrl}/asm`,
requestBody,
);
if (response.responseCode !== '2001') {
if (response.responseCode !== WAAFI_SUCCESS_CODE) {
throw new Error(
`Waafi initiate failed: ${response.responseCode} - ${response.responseMsg}`,
`Waafi HPP_PURCHASE failed: responseCode=${response.responseCode} errorCode=${response.errorCode} msg=${response.responseMsg}`,
);
}
const transactionId = response.params?.transactionId;
const checkoutUrl = response.params?.checkoutUrl || `${this.baseUrl}/checkout?ref=${transactionId}`;
if (!transactionId) {
throw new Error(`Waafi returned no transactionId: ${JSON.stringify(response)}`);
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)}`,
);
}
const expiresAt = new Date(Date.now() + 15 * 60_000); // 15 minutes
return {
providerOrderId: transactionId,
providerOrderId: orderId,
clientAction: { type: 'REDIRECT', url: checkoutUrl },
expiresAt,
expiresAt: new Date(Date.now() + WAAFI_HPP_SESSION_MS),
rawInitiation: {
request: this.sanitize(requestBody),
response,
@@ -121,113 +67,144 @@ export class WaafiProvider implements PaymentProvider {
}
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
const requestBody = this.buildQueryRequest(merchantOrderId);
const response = await this.postJson<WaafiQueryResponse>(
const requestBody = this.buildGetTranInfoRequest(merchantOrderId);
const response = await this.postJson<WaafiGetTranInfoResponse>(
`${this.baseUrl}/asm`,
requestBody,
);
const state = response.params?.state;
const rawState = response.params?.status ?? response.params?.tranStatusDesc;
const transactionId = response.params?.transactionId;
const mapped = this.mapState(state);
const mapped = this.mapStatus(rawState);
return {
status: mapped,
providerTxnId: transactionId,
failureCode: mapped === ProviderPaymentStatus.FAILED && state ? state : undefined,
failureCode:
mapped === ProviderPaymentStatus.FAILED && rawState ? rawState : undefined,
rawResponse: response as unknown as Record<string, unknown>,
};
}
mapState(state: string | undefined): ProviderPaymentStatus {
switch (state) {
/** 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 'FAILED':
case 'DECLINED':
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;
case 'PROCESSING':
return ProviderPaymentStatus.PROCESSING;
default:
return ProviderPaymentStatus.PROCESSING;
}
}
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
// Waafi webhook signature verification
// Implementation depends on Waafi's webhook signature mechanism
const signature = payload.signature as string;
const apiKey = this.apiKey;
if (!signature || !apiKey) {
this.logger.error('Waafi webhook missing signature or API key not configured');
return false;
}
// TODO: Implement actual signature verification based on Waafi documentation
// For now, basic validation
return signature.length > 0;
}
private buildInitiateRequest(input: ProviderInitiationInput): WaafiInitiateRequest {
const amount = input.amountMinor / 100; // Convert minor units to major
private buildPurchaseRequest(input: ProviderInitiationInput): WaafiHppPurchaseRequest {
return {
schemaVersion: '1.0',
requestId: this.generateRequestId(),
timestamp: new Date().toISOString(),
requestId: crypto.randomUUID(),
timestamp: this.timestamp(),
channelName: 'WEB',
serviceName: 'API_PURCHASE',
serviceName: 'HPP_PURCHASE',
serviceParams: {
merchantUid: this.merchantUid,
apiUserId: this.apiUserId,
apiKey: this.apiKey,
paymentMethod: 'MWALLET_ACCOUNT',
payerInfo: {
accountNo: 'CUSTOMER', // Customer enters their number on Waafi page
},
storeId: this.storeId,
hppKey: this.hppKey,
paymentMethod: this.paymentMethod,
hppSuccessCallbackUrl: this.successUrl,
hppFailureCallbackUrl: 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,
invoiceId: input.orderRef,
amount,
currency: input.currency === 'ETB' ? 'DJF' : input.currency, // Convert ETB to DJF
amount: this.toAmount(input.amountMinor),
// Waafi has no ETB; `waafi.currency` overrides the booking currency when set.
currency: this.currency || input.currency,
description: `EDR ${input.orderRef}`,
},
},
};
}
private buildQueryRequest(merchantOrderId: string): WaafiQueryRequest {
private buildGetTranInfoRequest(merchantOrderId: string): WaafiGetTranInfoRequest {
return {
schemaVersion: '1.0',
requestId: this.generateRequestId(),
timestamp: new Date().toISOString(),
requestId: crypto.randomUUID(),
timestamp: this.timestamp(),
channelName: 'WEB',
serviceName: 'API_QUERY',
serviceName: 'HPP_GETTRANINFO',
serviceParams: {
merchantUid: this.merchantUid,
apiUserId: this.apiUserId,
apiKey: this.apiKey,
storeId: this.storeId,
hppKey: this.hppKey,
referenceId: merchantOrderId,
},
};
}
private generateRequestId(): string {
return `EDR-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
/** Convert integer minor units to a 2-decimal major amount (truncated, never rounded up). */
private toAmount(amountMinor: number): number {
return Math.trunc(amountMinor) / 100;
}
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',
},
headers: { 'Content-Type': 'application/json' },
timeout: WAAFI_HTTP_TIMEOUT_MS,
};
@@ -252,24 +229,41 @@ export class WaafiProvider implements PaymentProvider {
}
}
private sanitize(body: WaafiInitiateRequest): Record<string, unknown> {
const sanitized = { ...body };
if (sanitized.serviceParams?.apiKey) {
sanitized.serviceParams.apiKey = '***REDACTED***';
}
return sanitized as unknown as Record<string, unknown>;
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://api.waafipay.net';
return this.config.get<string>('waafi.baseUrl') ?? 'https://sandbox.waafipay.com';
}
private get merchantUid(): string {
return this.config.get<string>('waafi.merchantUid') ?? '';
}
private get apiUserId(): string {
return this.config.get<string>('waafi.apiUserId') ?? '';
private get storeId(): string {
return this.config.get<string>('waafi.storeId') ?? '';
}
private get apiKey(): string {
return this.config.get<string>('waafi.apiKey') ?? '';
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 currency(): string {
return this.config.get<string>('waafi.currency') ?? '';
}
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;
}
}

View File

@@ -0,0 +1,103 @@
/**
* WaafiPay (Hosted Payment Page) request/response types.
*
* WaafiPay multiplexes every operation through a single `POST /asm` endpoint, discriminated by
* `serviceName`. We use the HPP family (`HPP_PURCHASE`, `HPP_GETTRANINFO`) which returns a hosted
* redirect URL and supports webhooks — see docs/waffi/intro.md.
*/
/** Terminal/intermediate transaction states reported by Waafi (sync `state` / `HPP_GETTRANINFO`). */
export type WaafiState =
| 'APPROVED'
| 'DECLINED'
| 'FAILED'
| 'CANCELED'
| 'EXPIRED'
| 'TIMEOUT'
| string;
/** Common request envelope shared by every `/asm` call. */
export interface WaafiRequestEnvelope<TServiceParams> {
schemaVersion: '1.0';
requestId: string;
timestamp: string;
channelName: 'WEB';
serviceName: string;
serviceParams: TServiceParams;
}
/** Common response envelope. `responseCode === '2001'` means the request was processed (not paid). */
export interface WaafiResponseEnvelope<TParams> {
schemaVersion: string;
timestamp: string;
responseId: string;
responseCode: string;
errorCode: string;
responseMsg: string;
params?: TParams;
}
// --- HPP_PURCHASE -----------------------------------------------------------------------------
export interface WaafiHppPurchaseServiceParams {
merchantUid: string;
storeId: string;
hppKey: string;
paymentMethod: string;
hppSuccessCallbackUrl: string;
hppFailureCallbackUrl: string;
/** Callback data format: 1 = POST, 2 = GET, 4 = Result Token. */
hppRespDataFormat: number;
/** Required for MWALLET_ACCOUNT — pre-fills (and locks) the payer's phone on the hosted page. */
payerInfo?: {
subscriptionId: string;
};
transactionInfo: {
referenceId: string;
amount: number;
currency: string;
description?: string;
};
}
export type WaafiHppPurchaseRequest = WaafiRequestEnvelope<WaafiHppPurchaseServiceParams>;
export interface WaafiHppPurchaseParams {
hppUrl: string;
directPaymentLink?: string;
orderId: string;
referenceId: string;
}
export type WaafiHppPurchaseResponse = WaafiResponseEnvelope<WaafiHppPurchaseParams>;
// --- HPP_GETTRANINFO --------------------------------------------------------------------------
export interface WaafiGetTranInfoServiceParams {
merchantUid: string;
storeId: string;
hppKey: string;
/** Either the merchant referenceId or the Waafi transactionId may be supplied. */
referenceId?: string;
transactionId?: string;
}
export type WaafiGetTranInfoRequest = WaafiRequestEnvelope<WaafiGetTranInfoServiceParams>;
export interface WaafiGetTranInfoParams {
tranStatusDesc?: string;
amount?: string;
payerId?: string;
paymentMethod?: string;
description?: string;
tranDate?: string;
currency?: string;
invoiceId?: string;
referenceId?: string;
tranAmount?: string;
transactionId?: string;
tranStatusId?: string;
status?: WaafiState;
}
export type WaafiGetTranInfoResponse = WaafiResponseEnvelope<WaafiGetTranInfoParams>;

View File

@@ -1,15 +1,66 @@
export interface WaafiWebhookPayload {
schemaVersion: string;
requestId: string;
timestamp: string;
eventType: string;
params: {
state: string;
referenceId: string;
transactionId: string;
amount: number;
currency: string;
description?: string;
};
signature?: string;
/**
* WaafiPay webhook payloads (HPP authorization / refund / test).
*
* Webhooks are HMAC-SHA256 signed over `{timestamp}.{event_id}.{raw_body}` except `webhook.test`,
* which is unsigned and only sent to validate endpoint reachability. See docs/waffi/intro.md.
*/
export type WaafiWebhookEvent = 'authorization' | 'refund' | 'webhook.test';
export type WaafiWebhookStatus =
| 'APPROVED'
| 'FAILED'
| 'DECLINED'
| 'CANCELED'
| 'EXPIRED'
| 'TIMEOUT'
| string;
/** Headers Waafi sends alongside signed webhooks (lowercased, as exposed by NestJS). */
export interface WaafiWebhookHeaders {
'x-webhook-timestamp'?: string;
'x-webhook-event-id'?: string;
'x-webhook-signature'?: string;
'x-webhook-signature-alg'?: string;
}
/** Nested payment object present on `authorization` and `refund` events. */
export interface WaafiWebhookPayment {
transaction_id: string;
/** Present on authorization events (optional). */
order_id?: string;
transfer_code: string;
amount: number;
currency: string;
/** Present on authorization events. */
payment_method?: string;
status: WaafiWebhookStatus;
/** Our merchantOrderId. */
reference_id: string;
/** Present on authorization events. */
channel?: string;
description?: string;
date: string;
}
/** Unsigned validation ping sent on webhook registration/update. */
export interface WaafiWebhookTestPayload {
event: 'webhook.test';
message?: string;
merchant_uid: string;
}
/** Real transaction notification (authorization or refund). */
export interface WaafiWebhookTransactionPayload {
event: 'authorization' | 'refund';
merchant_id: number;
merchant_uid: string;
user_id: string;
/** Authorization only. */
customer_identity?: string;
/** Authorization only (optional). */
cardholder_name?: string;
payment: WaafiWebhookPayment;
}
export type WaafiWebhookPayload = WaafiWebhookTestPayload | WaafiWebhookTransactionPayload;

View File

@@ -42,6 +42,12 @@ export interface ProviderInitiationInput {
amountMinor: number;
currency: string;
platform?: PaymentPlatform;
/**
* Payer account identifier (e.g. mobile-wallet MSISDN in full international format).
* Optional and provider-specific: some wallet providers (e.g. Waafi HPP with
* MWALLET_ACCOUNT) require the payer's phone number up front to pre-fill the hosted page.
*/
payerAccount?: string;
}
export interface ProviderInitiationResult {