From 3235567b41515b12186a79ecc3581f55b201b83c Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Wed, 10 Jun 2026 16:23:33 +0300 Subject: [PATCH] feat( waafi ): wire Waafi HPP into passenger API --- apps/edr-passenger-api/.env.example | 22 +- .../src/config/waafi.config.ts | 25 ++- apps/edr-passenger-api/src/main.ts | 4 +- .../webhooks/waafi-webhook.service.ts | 200 +++++++++++++----- .../payments/webhooks/webhooks.controller.ts | 17 +- .../src/providers/waafi/waafi.provider.ts | 15 +- 6 files changed, 212 insertions(+), 71 deletions(-) diff --git a/apps/edr-passenger-api/.env.example b/apps/edr-passenger-api/.env.example index aaf37c3e4..5ea89f4e5 100644 --- a/apps/edr-passenger-api/.env.example +++ b/apps/edr-passenger-api/.env.example @@ -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 diff --git a/apps/edr-passenger-api/src/config/waafi.config.ts b/apps/edr-passenger-api/src/config/waafi.config.ts index c3afab7ce..639e66e2b 100644 --- a/apps/edr-passenger-api/src/config/waafi.config.ts +++ b/apps/edr-passenger-api/src/config/waafi.config.ts @@ -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', })); diff --git a/apps/edr-passenger-api/src/main.ts b/apps/edr-passenger-api/src/main.ts index fe853ef76..a7bd85f81 100644 --- a/apps/edr-passenger-api/src/main.ts +++ b/apps/edr-passenger-api/src/main.ts @@ -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: [ diff --git a/apps/edr-passenger-api/src/modules/payments/webhooks/waafi-webhook.service.ts b/apps/edr-passenger-api/src/modules/payments/webhooks/waafi-webhook.service.ts index 972b09b3d..3b9110920 100644 --- a/apps/edr-passenger-api/src/modules/payments/webhooks/waafi-webhook.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/webhooks/waafi-webhook.service.ts @@ -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, - ); + 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, - merchantOrderId, - providerTxnId: transactionId, - signatureValid, - status: state || 'UNKNOWN', - payload: payload as any, - }, + 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, + signatureValid, + 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({ - intentId: intent.id, - providerTxnId: transactionId, - }); - this.logger.log(`Waafi payment succeeded: intent=${intent.id} txn=${transactionId}`); - } else if (mappedStatus === ProviderPaymentStatus.FAILED) { - await this.paymentsService.markPaymentFailed({ - intentId: intent.id, - failureCode: state, - failureMessage: payload.params?.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, - }, - }); - this.logger.log(`Waafi payment status updated: intent=${intent.id} status=${mappedStatus}`); + 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, + paidAt: this.parseDate(payment.date), + }); + } else if ( + mapped === ProviderPaymentStatus.FAILED || + mapped === ProviderPaymentStatus.CANCELLED + ) { + await this.payments.markPaymentFailed({ + intentId: intent.id, + failureCode: payment.status, + failureMessage: payment.description, + }); + } 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 }; } + + 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 { + 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; + } } diff --git a/apps/edr-passenger-api/src/modules/payments/webhooks/webhooks.controller.ts b/apps/edr-passenger-api/src/modules/payments/webhooks/webhooks.controller.ts index 4dd340d06..4e56c7c04 100644 --- a/apps/edr-passenger-api/src/modules/payments/webhooks/webhooks.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/webhooks/webhooks.controller.ts @@ -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}`); diff --git a/packages/payment-providers/src/providers/waafi/waafi.provider.ts b/packages/payment-providers/src/providers/waafi/waafi.provider.ts index 515de1786..caabdf251 100644 --- a/packages/payment-providers/src/providers/waafi/waafi.provider.ts +++ b/packages/payment-providers/src/providers/waafi/waafi.provider.ts @@ -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('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 { 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('waafi.baseUrl') ?? 'https://sandbox.waafipay.com'; + return this.config.get('waafi.baseUrl') ?? 'https://sandbox.waafipay.net'; } private get merchantUid(): string { return this.config.get('waafi.merchantUid') ?? '';