mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat( waafi ): wire Waafi HPP into passenger API
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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',
|
||||
}));
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
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<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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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') ?? '';
|
||||
|
||||
Reference in New Issue
Block a user