feat( waafi ): wire Waafi HPP into passenger API

This commit is contained in:
Abubeker Yasin
2026-06-10 16:23:33 +03:00
parent 3f1a673356
commit 3235567b41
6 changed files with 212 additions and 71 deletions

View File

@@ -65,13 +65,25 @@ CARD_WEBHOOK_SECRET=
CARD_WEBHOOK_URL=
CARD_RETURN_URL=
# Waafi (Djibouti Mobile Money)
WAAFI_BASE_URL=https://api.waafipay.net
# Waafi (Djibouti Mobile Money — Hosted Payment Page)
# Sandbox: https://sandbox.waafipay.net | Production: https://api.waafipay.net
WAAFI_BASE_URL=https://sandbox.waafipay.net
WAAFI_MERCHANT_UID=
WAAFI_API_USER_ID=
WAAFI_API_KEY=
WAAFI_STORE_ID=
WAAFI_HPP_KEY=
# HMAC secret returned once by WEBHOOK_REGISTER — verifies inbound webhooks
WAAFI_WEBHOOK_SECRET=
WAAFI_PAYMENT_METHOD=MWALLET_ACCOUNT
# Waafi has no ETB; overrides booking currency (USD/DJF/SLSH)
WAAFI_CURRENCY=DJF
WAAFI_HPP_SUCCESS_URL=
WAAFI_HPP_FAILURE_URL=
# 1 = POST, 2 = GET, 4 = Result Token
WAAFI_HPP_RESP_FORMAT=1
# Registered webhook URL (registration done out-of-band)
WAAFI_NOTIFY_URL=
WAAFI_RETURN_URL=
# DEV ONLY — disable TLS cert verification (sandbox serves a *.waafi.com cert). Never true in prod.
WAAFI_INSECURE_TLS=false
# Payment Configuration
PAYMENT_PROVIDERS_ENABLED=TELEBIRR,CBE_BIRR,EBIRR,CARD,WALLET,WAAFI

View File

@@ -1,10 +1,27 @@
import { registerAs } from '@nestjs/config';
export default registerAs('waafi', () => ({
baseUrl: process.env.WAAFI_BASE_URL ?? 'https://api.waafipay.net',
// `/asm` is appended in the provider; use sandbox by default, switch to
// https://api.waafipay.net in production.
baseUrl: process.env.WAAFI_BASE_URL ?? 'https://sandbox.waafipay.net',
// HPP credentials (Hosted Payment Page family).
merchantUid: process.env.WAAFI_MERCHANT_UID ?? '',
apiUserId: process.env.WAAFI_API_USER_ID ?? '',
apiKey: process.env.WAAFI_API_KEY ?? '',
storeId: process.env.WAAFI_STORE_ID ?? '',
hppKey: process.env.WAAFI_HPP_KEY ?? '',
// HMAC secret returned once by WEBHOOK_REGISTER; verifies inbound webhooks.
webhookSecret: process.env.WAAFI_WEBHOOK_SECRET ?? '',
// Wallet payment method (EVC/ZAAD/Sahal) — MWALLET_ACCOUNT requires the payer phone up front.
paymentMethod: process.env.WAAFI_PAYMENT_METHOD ?? 'MWALLET_ACCOUNT',
// Waafi has no ETB; when set this overrides the booking currency (USD/DJF/SLSH).
currency: process.env.WAAFI_CURRENCY ?? 'DJF',
// Browser redirect targets after the hosted page completes/fails (UX only; webhook is source of truth).
successUrl: process.env.WAAFI_HPP_SUCCESS_URL ?? '',
failureUrl: process.env.WAAFI_HPP_FAILURE_URL ?? '',
// Callback data format: 1 = POST, 2 = GET, 4 = Result Token.
respDataFormat: Number(process.env.WAAFI_HPP_RESP_FORMAT ?? '1'),
// Registered webhook URL (reference only; registration is performed out-of-band).
notifyUrl: process.env.WAAFI_NOTIFY_URL ?? '',
returnUrl: process.env.WAAFI_RETURN_URL ?? '',
// DEV ONLY: disable TLS cert verification. The Waafi sandbox serves a *.waafi.com cert that
// does not match sandbox.waafipay.net (ERR_TLS_CERT_ALTNAME_INVALID). Never enable in prod.
insecureTls: process.env.WAAFI_INSECURE_TLS === 'true',
}));

View File

@@ -8,7 +8,9 @@ import { ResponseTransformInterceptor } from "./common/interceptors/response-tra
import { SessionActivityInterceptor } from "./common/interceptors/session-activity.interceptor";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// rawBody: true buffers the unparsed request body onto req.rawBody so webhook handlers
// (e.g. Waafi HMAC verification) can sign over the exact bytes the provider signed.
const app = await NestFactory.create(AppModule, { rawBody: true });
app.enableCors({
origin: [

View File

@@ -1,93 +1,181 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
import {
WaafiProvider,
WaafiWebhookPayload,
WaafiWebhookHeaders,
WaafiWebhookTransactionPayload,
ProviderPaymentStatus,
} from '@edr/payment-providers';
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
import { PrismaService } from '../../../common/prisma.service';
import { PaymentsService } from '../payments.service';
/** Reject webhooks whose timestamp is older than this (replay protection). */
const WAAFI_REPLAY_WINDOW_SECONDS = 300;
@Injectable()
export class WaafiWebhookService {
private readonly logger = new Logger(WaafiWebhookService.name);
constructor(
private prisma: PrismaService,
private paymentsService: PaymentsService,
private waafiProvider: WaafiProvider,
private readonly prisma: PrismaService,
private readonly provider: WaafiProvider,
private readonly payments: PaymentsService,
) {}
async handleWebhook(payload: WaafiWebhookPayload): Promise<{ received: boolean }> {
this.logger.log(
`Waafi webhook received: event=${payload.eventType} ref=${payload.params?.referenceId}`,
);
async handleWebhook(
payload: WaafiWebhookPayload,
rawBody: string,
headers: WaafiWebhookHeaders,
): Promise<{ received: boolean }> {
const signatureValid = this.waafiProvider.verifyWebhookSignature(
payload as unknown as Record<string, unknown>,
);
console.log("Waafi Webhook Service");
// Unsigned validation ping sent on registration — acknowledge without verifying or persisting.
if (payload.event === 'webhook.test') {
this.logger.log('Waafi webhook.test ping received');
return { received: true };
}
const merchantOrderId = payload.params?.referenceId;
const transactionId = payload.params?.transactionId;
const state = payload.params?.state;
const { payment } = payload;
const merchantOrderId = payment.reference_id;
const providerTxnId = payment.transaction_id;
const eventId = headers['x-webhook-event-id'];
const timestamp = headers['x-webhook-timestamp'];
const signature = headers['x-webhook-signature'];
await this.prisma.paymentWebhookEvent.create({
data: {
provider: PaymentMethodType.WAAFI,
externalEventId: payload.requestId,
const signatureValid =
this.isFresh(timestamp) &&
this.provider.verifyWebhookSignature(rawBody, signature, timestamp, eventId);
// X-Webhook-Event-Id is unique per event; fall back to a derived id if absent.
const externalEventId = eventId ?? `${providerTxnId}_${payment.status}`;
const eventRow = await this.persistEvent({
externalEventId,
merchantOrderId,
providerTxnId: transactionId,
providerTxnId,
signatureValid,
status: state || 'UNKNOWN',
payload: payload as any,
},
status: payment.status,
payload,
});
if (!eventRow) {
this.logger.log(`Waafi webhook duplicate: ${externalEventId} — short-circuit OK`);
return { received: true };
}
if (!signatureValid) {
this.logger.warn(`Waafi webhook signature invalid for ref=${merchantOrderId}`);
this.logger.warn(`Waafi webhook signature invalid/stale for ref=${merchantOrderId}`);
await this.markProcessed(eventRow.id, 'signature-invalid');
return { received: true };
}
if (!merchantOrderId) {
this.logger.error('Waafi webhook missing referenceId');
return { received: true };
}
const intent = await this.prisma.paymentIntent.findFirst({
const intent = await this.prisma.paymentIntent.findUnique({
where: { merchantOrderId },
});
if (!intent) {
this.logger.warn(`No PaymentIntent found for merchantOrderId=${merchantOrderId}`);
this.logger.warn(`Waafi webhook: no PaymentIntent for ref=${merchantOrderId}`);
await this.markProcessed(eventRow.id, 'intent-not-found');
return { received: true };
}
const mappedStatus = this.waafiProvider.mapState(state);
const mapped = this.provider.mapWebhookStatus(payment.status);
if (mappedStatus === ProviderPaymentStatus.SUCCEEDED) {
await this.paymentsService.finalizePaymentSuccess({
try {
if (payload.event === 'refund') {
// Refund state is owned by PaymentsService.refund; just record the notification.
this.logger.log(
`Waafi refund webhook for ref=${merchantOrderId} status=${payment.status}`,
);
} else if (mapped === ProviderPaymentStatus.SUCCEEDED) {
await this.payments.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: transactionId,
providerTxnId,
paidAt: this.parseDate(payment.date),
});
this.logger.log(`Waafi payment succeeded: intent=${intent.id} txn=${transactionId}`);
} else if (mappedStatus === ProviderPaymentStatus.FAILED) {
await this.paymentsService.markPaymentFailed({
} else if (
mapped === ProviderPaymentStatus.FAILED ||
mapped === ProviderPaymentStatus.CANCELLED
) {
await this.payments.markPaymentFailed({
intentId: intent.id,
failureCode: state,
failureMessage: payload.params?.description,
failureCode: payment.status,
failureMessage: payment.description,
});
this.logger.log(`Waafi payment failed: intent=${intent.id} state=${state}`);
} else {
await this.prisma.paymentIntent.update({
where: { id: intent.id },
data: {
status: mappedStatus as unknown as PaymentIntentStatus,
providerTxnId: transactionId,
status: mapped as unknown as PaymentIntentStatus,
providerTxnId,
},
});
this.logger.log(`Waafi payment status updated: intent=${intent.id} status=${mappedStatus}`);
}
await this.markProcessed(eventRow.id);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(
`Waafi webhook processing failed for ${merchantOrderId}: ${message}`,
);
await this.markProcessed(eventRow.id, `processing-error: ${message}`);
throw err;
}
return { received: true };
}
private async persistEvent(input: {
externalEventId: string;
merchantOrderId: string;
providerTxnId?: string;
signatureValid: boolean;
status: string;
payload: WaafiWebhookTransactionPayload;
}): Promise<{ id: string } | null> {
try {
return await this.prisma.paymentWebhookEvent.create({
data: {
provider: PaymentMethodType.WAAFI,
externalEventId: input.externalEventId,
merchantOrderId: input.merchantOrderId,
providerTxnId: input.providerTxnId,
signatureValid: input.signatureValid,
status: input.status,
payload: input.payload as unknown as Prisma.InputJsonValue,
},
select: { id: true },
});
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === 'P2002'
) {
return null;
}
throw err;
}
}
private async markProcessed(eventId: string, processingError?: string): Promise<void> {
await this.prisma.paymentWebhookEvent.update({
where: { id: eventId },
data: { processedAt: new Date(), processingError },
});
}
/** True when the webhook timestamp (unix seconds) is within the replay window. */
private isFresh(timestamp: string | undefined): boolean {
if (!timestamp) return false;
const ts = parseInt(timestamp, 10);
if (Number.isNaN(ts)) return false;
const now = Math.floor(Date.now() / 1000);
return Math.abs(now - ts) <= WAAFI_REPLAY_WINDOW_SECONDS;
}
/** Parse Waafi's "YYYY-MM-DD HH:mm:ss" payment date; undefined when unparseable. */
private parseDate(raw: string | undefined): Date | undefined {
if (!raw) return undefined;
const d = new Date(raw);
return Number.isNaN(d.getTime()) ? undefined : d;
}
}

View File

@@ -1,10 +1,12 @@
import {All, Body, Controller, Headers, HttpCode, HttpStatus, Logger, Post} from '@nestjs/common';
import {All, Body, Controller, Headers, HttpCode, HttpStatus, Logger, Post, Req} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import {
TelebirrWebhookPayload,
CbeBirrWebhookPayload,
EBirrWebhookPayload,
CardWebhookPayload,
WaafiWebhookPayload,
WaafiWebhookHeaders,
} from '@edr/payment-providers';
import { TelebirrWebhookService } from './telebirr-webhook.service';
import { CbeBirrWebhookService } from './cbe-birr-webhook.service';
@@ -103,9 +105,18 @@ export class WebhooksController {
summary: 'Waafi payment notification callback (Djibouti)',
description: 'Webhook endpoint for Waafi mobile money payment status updates. Used by Djiboutian passengers.'
})
async receiveWaafi(@Body() payload: any) {
async receiveWaafi(
@Body() payload: WaafiWebhookPayload,
@Headers() headers: WaafiWebhookHeaders,
@Req() req: { rawBody?: Buffer },
) {
this.logger.log(
`Waafi webhook hit: event=${payload?.event ?? 'unknown'} eventId=${headers['x-webhook-event-id'] ?? 'n/a'}`,
);
try {
await this.waafi.handleWebhook(payload);
// HMAC verification must sign over the exact raw bytes Waafi sent, not re-serialized JSON.
const rawBody = req.rawBody?.toString('utf8') ?? '';
await this.waafi.handleWebhook(payload, rawBody, headers);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Waafi webhook handler threw: ${message}`);

View File

@@ -12,6 +12,7 @@ import {
import { AxiosError, AxiosRequestConfig } from 'axios';
import { firstValueFrom } from 'rxjs';
import * as crypto from 'node:crypto';
import * as https from 'node:https';
import {
WaafiGetTranInfoRequest,
WaafiGetTranInfoResponse,
@@ -28,11 +29,20 @@ const WAAFI_HPP_SESSION_MS = 5 * 60_000;
export class WaafiProvider implements PaymentProvider {
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 });
}
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
const requestBody = this.buildPurchaseRequest(input);
@@ -206,6 +216,7 @@ export class WaafiProvider implements PaymentProvider {
const config: AxiosRequestConfig = {
headers: { 'Content-Type': 'application/json' },
timeout: WAAFI_HTTP_TIMEOUT_MS,
httpsAgent: this.httpsAgent,
};
const started = Date.now();
@@ -237,7 +248,7 @@ export class WaafiProvider implements PaymentProvider {
}
private get baseUrl(): string {
return this.config.get<string>('waafi.baseUrl') ?? 'https://sandbox.waafipay.com';
return this.config.get<string>('waafi.baseUrl') ?? 'https://sandbox.waafipay.net';
}
private get merchantUid(): string {
return this.config.get<string>('waafi.merchantUid') ?? '';