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_WEBHOOK_URL=
CARD_RETURN_URL= CARD_RETURN_URL=
# Waafi (Djibouti Mobile Money) # Waafi (Djibouti Mobile Money — Hosted Payment Page)
WAAFI_BASE_URL=https://api.waafipay.net # Sandbox: https://sandbox.waafipay.net | Production: https://api.waafipay.net
WAAFI_BASE_URL=https://sandbox.waafipay.net
WAAFI_MERCHANT_UID= WAAFI_MERCHANT_UID=
WAAFI_API_USER_ID= WAAFI_STORE_ID=
WAAFI_API_KEY= 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_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 Configuration
PAYMENT_PROVIDERS_ENABLED=TELEBIRR,CBE_BIRR,EBIRR,CARD,WALLET,WAAFI PAYMENT_PROVIDERS_ENABLED=TELEBIRR,CBE_BIRR,EBIRR,CARD,WALLET,WAAFI

View File

@@ -1,10 +1,27 @@
import { registerAs } from '@nestjs/config'; import { registerAs } from '@nestjs/config';
export default registerAs('waafi', () => ({ 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 ?? '', merchantUid: process.env.WAAFI_MERCHANT_UID ?? '',
apiUserId: process.env.WAAFI_API_USER_ID ?? '', storeId: process.env.WAAFI_STORE_ID ?? '',
apiKey: process.env.WAAFI_API_KEY ?? '', 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 ?? '', 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"; import { SessionActivityInterceptor } from "./common/interceptors/session-activity.interceptor";
async function bootstrap() { 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({ app.enableCors({
origin: [ origin: [

View File

@@ -1,93 +1,181 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
import { import {
WaafiProvider, WaafiProvider,
WaafiWebhookPayload, WaafiWebhookPayload,
WaafiWebhookHeaders,
WaafiWebhookTransactionPayload,
ProviderPaymentStatus, ProviderPaymentStatus,
} from '@edr/payment-providers'; } from '@edr/payment-providers';
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
import { PrismaService } from '../../../common/prisma.service'; import { PrismaService } from '../../../common/prisma.service';
import { PaymentsService } from '../payments.service'; import { PaymentsService } from '../payments.service';
/** Reject webhooks whose timestamp is older than this (replay protection). */
const WAAFI_REPLAY_WINDOW_SECONDS = 300;
@Injectable() @Injectable()
export class WaafiWebhookService { export class WaafiWebhookService {
private readonly logger = new Logger(WaafiWebhookService.name); private readonly logger = new Logger(WaafiWebhookService.name);
constructor( constructor(
private prisma: PrismaService, private readonly prisma: PrismaService,
private paymentsService: PaymentsService, private readonly provider: WaafiProvider,
private waafiProvider: WaafiProvider, private readonly payments: PaymentsService,
) {} ) {}
async handleWebhook(payload: WaafiWebhookPayload): Promise<{ received: boolean }> { async handleWebhook(
this.logger.log( payload: WaafiWebhookPayload,
`Waafi webhook received: event=${payload.eventType} ref=${payload.params?.referenceId}`, rawBody: string,
); headers: WaafiWebhookHeaders,
): Promise<{ received: boolean }> {
const signatureValid = this.waafiProvider.verifyWebhookSignature( console.log("Waafi Webhook Service");
payload as unknown as Record<string, unknown>, // 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 { payment } = payload;
const transactionId = payload.params?.transactionId; const merchantOrderId = payment.reference_id;
const state = payload.params?.state; 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({ const signatureValid =
data: { this.isFresh(timestamp) &&
provider: PaymentMethodType.WAAFI, this.provider.verifyWebhookSignature(rawBody, signature, timestamp, eventId);
externalEventId: payload.requestId,
merchantOrderId, // X-Webhook-Event-Id is unique per event; fall back to a derived id if absent.
providerTxnId: transactionId, const externalEventId = eventId ?? `${providerTxnId}_${payment.status}`;
signatureValid,
status: state || 'UNKNOWN', const eventRow = await this.persistEvent({
payload: payload as any, externalEventId,
}, merchantOrderId,
providerTxnId,
signatureValid,
status: payment.status,
payload,
}); });
if (!eventRow) {
this.logger.log(`Waafi webhook duplicate: ${externalEventId} — short-circuit OK`);
return { received: true };
}
if (!signatureValid) { 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 }; return { received: true };
} }
if (!merchantOrderId) { const intent = await this.prisma.paymentIntent.findUnique({
this.logger.error('Waafi webhook missing referenceId');
return { received: true };
}
const intent = await this.prisma.paymentIntent.findFirst({
where: { merchantOrderId }, where: { merchantOrderId },
}); });
if (!intent) { 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 }; return { received: true };
} }
const mappedStatus = this.waafiProvider.mapState(state); const mapped = this.provider.mapWebhookStatus(payment.status);
if (mappedStatus === ProviderPaymentStatus.SUCCEEDED) { try {
await this.paymentsService.finalizePaymentSuccess({ if (payload.event === 'refund') {
intentId: intent.id, // Refund state is owned by PaymentsService.refund; just record the notification.
providerTxnId: transactionId, this.logger.log(
}); `Waafi refund webhook for ref=${merchantOrderId} status=${payment.status}`,
this.logger.log(`Waafi payment succeeded: intent=${intent.id} txn=${transactionId}`); );
} else if (mappedStatus === ProviderPaymentStatus.FAILED) { } else if (mapped === ProviderPaymentStatus.SUCCEEDED) {
await this.paymentsService.markPaymentFailed({ await this.payments.finalizePaymentSuccess({
intentId: intent.id, intentId: intent.id,
failureCode: state, providerTxnId,
failureMessage: payload.params?.description, paidAt: this.parseDate(payment.date),
}); });
this.logger.log(`Waafi payment failed: intent=${intent.id} state=${state}`); } else if (
} else { mapped === ProviderPaymentStatus.FAILED ||
await this.prisma.paymentIntent.update({ mapped === ProviderPaymentStatus.CANCELLED
where: { id: intent.id }, ) {
data: { await this.payments.markPaymentFailed({
status: mappedStatus as unknown as PaymentIntentStatus, intentId: intent.id,
providerTxnId: transactionId, failureCode: payment.status,
}, failureMessage: payment.description,
}); });
this.logger.log(`Waafi payment status updated: intent=${intent.id} status=${mappedStatus}`); } else {
await this.prisma.paymentIntent.update({
where: { id: intent.id },
data: {
status: mapped as unknown as PaymentIntentStatus,
providerTxnId,
},
});
}
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 }; 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 { ApiOperation, ApiTags } from '@nestjs/swagger';
import { import {
TelebirrWebhookPayload, TelebirrWebhookPayload,
CbeBirrWebhookPayload, CbeBirrWebhookPayload,
EBirrWebhookPayload, EBirrWebhookPayload,
CardWebhookPayload, CardWebhookPayload,
WaafiWebhookPayload,
WaafiWebhookHeaders,
} from '@edr/payment-providers'; } from '@edr/payment-providers';
import { TelebirrWebhookService } from './telebirr-webhook.service'; import { TelebirrWebhookService } from './telebirr-webhook.service';
import { CbeBirrWebhookService } from './cbe-birr-webhook.service'; import { CbeBirrWebhookService } from './cbe-birr-webhook.service';
@@ -103,9 +105,18 @@ export class WebhooksController {
summary: 'Waafi payment notification callback (Djibouti)', summary: 'Waafi payment notification callback (Djibouti)',
description: 'Webhook endpoint for Waafi mobile money payment status updates. Used by Djiboutian passengers.' 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 { 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) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Waafi webhook handler threw: ${message}`); this.logger.error(`Waafi webhook handler threw: ${message}`);

View File

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