Nationality, guest booking, waafi adapter, overall booking flow updates

This commit is contained in:
Stephanos A
2026-05-24 16:20:55 +03:00
parent 2f17f9c9ce
commit 87a423c859
37 changed files with 2284 additions and 486 deletions

View File

@@ -10,9 +10,44 @@ import { JwtGuard } from '../../common/jwt.guard';
@ApiBearerAuth('JWT-auth')
export class PaymentsController {
constructor(private service: PaymentsService) {}
@Post('initiate') @ApiOperation({ summary: 'Initiate payment for a booking' }) initiatePayment(@Body() dto: InitiatePaymentDto) { return this.service.initiatePayment(dto); }
@Get('intents/:bookingId') @ApiOperation({ summary: 'Get payment intent status for a booking' }) getIntent(@Param('bookingId') bookingId: string) { return this.service.getIntentByBookingId(bookingId); }
@Post('refund') @ApiOperation({ summary: 'Refund a confirmed booking' }) refund(@Body() dto: RefundDto) { return this.service.refund(dto); }
@Post('methods') @ApiOperation({ summary: 'Add a payment method' }) addMethod(@Body() dto: AddPaymentMethodDto) { return this.service.addPaymentMethod(dto); }
@Get('methods/:userId') @ApiOperation({ summary: 'Get payment methods for user' }) getMethods(@Param('userId') userId: string) { return this.service.getPaymentMethods(userId); }
@Post('initiate')
@ApiOperation({
summary: 'Initiate payment with nationality-based payment methods',
description: `Initiates payment for a booking with support for multiple payment providers:
**Ethiopian Payment Methods:**
- TELEBIRR - Ethiopia's leading mobile money
- CBE_BIRR - Commercial Bank of Ethiopia
- EBIRR - Electronic payment gateway
**Djiboutian Payment Methods:**
- WAAFI - Djibouti's mobile money service
**International Payment Methods:**
- CARD - Visa, Mastercard
- WALLET - Internal wallet balance
**Multi-Currency:**
- All transactions processed in ETB
- Display amounts in ETB, DJF, or USD
- Real-time exchange rate conversion`
})
initiatePayment(@Body() dto: InitiatePaymentDto) { return this.service.initiatePayment(dto); }
@Get('intents/:bookingId')
@ApiOperation({ summary: 'Get payment intent status for a booking' })
getIntent(@Param('bookingId') bookingId: string) { return this.service.getIntentByBookingId(bookingId); }
@Post('refund')
@ApiOperation({ summary: 'Refund a confirmed booking' })
refund(@Body() dto: RefundDto) { return this.service.refund(dto); }
@Post('methods')
@ApiOperation({ summary: 'Add a payment method' })
addMethod(@Body() dto: AddPaymentMethodDto) { return this.service.addPaymentMethod(dto); }
@Get('methods/:userId')
@ApiOperation({ summary: 'Get payment methods for user' })
getMethods(@Param('userId') userId: string) { return this.service.getPaymentMethods(userId); }
}

View File

@@ -2,12 +2,23 @@ import { IsString, IsEnum, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { PaymentIntentStatus } from '@prisma/client';
export enum PaymentMethodTypeEnum { TELEBIRR = 'TELEBIRR', CBE_BIRR = 'CBE_BIRR', EBIRR = 'EBIRR', CARD = 'CARD', WALLET = 'WALLET' }
export enum PaymentMethodTypeEnum {
TELEBIRR = 'TELEBIRR', // Ethiopia
CBE_BIRR = 'CBE_BIRR', // Ethiopia
EBIRR = 'EBIRR', // Ethiopia
WAAFI = 'WAAFI', // Djibouti
CARD = 'CARD', // International
WALLET = 'WALLET' // Internal
}
export class InitiatePaymentDto {
@ApiProperty() @IsString() bookingId: string;
@ApiProperty({ enum: PaymentMethodTypeEnum }) @IsEnum(PaymentMethodTypeEnum) method: PaymentMethodTypeEnum;
@ApiPropertyOptional() @IsOptional() @IsString() paymentMethodId?: string;
@ApiProperty({ example: 'booking-uuid' }) @IsString() bookingId: string;
@ApiProperty({
enum: PaymentMethodTypeEnum,
description: 'Payment method: TELEBIRR/CBE_BIRR/EBIRR (Ethiopia), WAAFI (Djibouti), CARD (International), WALLET (Internal)',
example: 'TELEBIRR'
}) @IsEnum(PaymentMethodTypeEnum) method: PaymentMethodTypeEnum;
@ApiPropertyOptional({ description: 'Saved payment method ID (optional)' }) @IsOptional() @IsString() paymentMethodId?: string;
}
export class RefundDto {

View File

@@ -8,11 +8,13 @@ import { TelebirrProvider } from './providers/telebirr.provider';
import { CbeBirrProvider } from './providers/cbe-birr.provider';
import { EBirrProvider } from './providers/ebirr.provider';
import { CardProvider } from './providers/card.provider';
import { WaafiProvider } from './providers/waafi.provider';
import { WebhooksController } from './webhooks/webhooks.controller';
import { TelebirrWebhookService } from './webhooks/telebirr-webhook.service';
import { CbeBirrWebhookService } from './webhooks/cbe-birr-webhook.service';
import { EBirrWebhookService } from './webhooks/ebirr-webhook.service';
import { CardWebhookService } from './webhooks/card-webhook.service';
import { WaafiWebhookService } from './webhooks/waafi-webhook.service';
@Module({
imports: [SeatsModule, TicketsModule, HttpModule.register({ timeout: 10_000 })],
@@ -23,10 +25,12 @@ import { CardWebhookService } from './webhooks/card-webhook.service';
CbeBirrProvider,
EBirrProvider,
CardProvider,
WaafiProvider,
TelebirrWebhookService,
CbeBirrWebhookService,
EBirrWebhookService,
CardWebhookService,
WaafiWebhookService,
],
})
export class PaymentsModule {}

View File

@@ -10,6 +10,7 @@ import { TelebirrProvider } from './providers/telebirr.provider';
import { CbeBirrProvider } from './providers/cbe-birr.provider';
import { EBirrProvider } from './providers/ebirr.provider';
import { CardProvider } from './providers/card.provider';
import { WaafiProvider } from './providers/waafi.provider';
import { createMerchantOrderId } from './providers/telebirr.crypto';
const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [
@@ -32,12 +33,14 @@ export class PaymentsService {
private cbeBirrProvider: CbeBirrProvider,
private eBirrProvider: EBirrProvider,
private cardProvider: CardProvider,
private waafiProvider: WaafiProvider,
) {
this.providers = new Map<PaymentMethodType, PaymentProvider>([
[PaymentMethodType.TELEBIRR, this.telebirrProvider],
[PaymentMethodType.CBE_BIRR, this.cbeBirrProvider],
[PaymentMethodType.EBIRR, this.eBirrProvider],
[PaymentMethodType.CARD, this.cardProvider],
[PaymentMethodType.WAAFI, this.waafiProvider],
]);
}

View File

@@ -1,8 +1,8 @@
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
export interface ClientAction {
type: 'REDIRECT';
url: string;
type: 'REDIRECT' | 'NONE';
url?: string;
}
export interface ProviderInitiationInput {

View File

@@ -0,0 +1,276 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
import { AxiosError, AxiosRequestConfig } from 'axios';
import { firstValueFrom } from 'rxjs';
import {
PaymentProvider,
ProviderInitiationInput,
ProviderInitiationResult,
ProviderStatus,
} from '../payments.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;
};
}
@Injectable()
export class WaafiProvider implements PaymentProvider {
readonly method = PaymentMethodType.WAAFI;
private readonly logger = new Logger(WaafiProvider.name);
constructor(
private readonly config: ConfigService,
private readonly http: HttpService,
) {}
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
const requestBody = this.buildInitiateRequest(input);
const response = await this.postJson<WaafiInitiateResponse>(
`${this.baseUrl}/asm`,
requestBody,
);
if (response.responseCode !== '2001') {
throw new Error(
`Waafi initiate failed: ${response.responseCode} - ${response.responseMsg}`,
);
}
const transactionId = response.params?.transactionId;
const checkoutUrl = response.params?.checkoutUrl;
if (!transactionId) {
throw new Error(`Waafi returned no transactionId: ${JSON.stringify(response)}`);
}
const expiresAt = new Date(Date.now() + 15 * 60_000); // 15 minutes
return {
providerOrderId: transactionId,
clientAction: checkoutUrl
? { type: 'REDIRECT', url: checkoutUrl }
: { type: 'NONE' },
expiresAt,
rawInitiation: {
request: this.sanitize(requestBody),
response,
},
};
}
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
const requestBody = this.buildQueryRequest(merchantOrderId);
const response = await this.postJson<WaafiQueryResponse>(
`${this.baseUrl}/asm`,
requestBody,
);
const state = response.params?.state;
const transactionId = response.params?.transactionId;
const mapped = this.mapState(state);
return {
status: mapped,
providerTxnId: transactionId,
failureCode: mapped === PaymentIntentStatus.FAILED && state ? state : undefined,
rawResponse: response as unknown as Record<string, unknown>,
};
}
mapState(state: string | undefined): PaymentIntentStatus {
switch (state) {
case 'APPROVED':
case 'SUCCESS':
return PaymentIntentStatus.SUCCEEDED;
case 'FAILED':
case 'DECLINED':
case 'CANCELLED':
case 'EXPIRED':
return PaymentIntentStatus.FAILED;
case 'PENDING':
case 'INITIATED':
return PaymentIntentStatus.REQUIRES_ACTION;
case 'PROCESSING':
return PaymentIntentStatus.PROCESSING;
default:
return PaymentIntentStatus.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
return {
schemaVersion: '1.0',
requestId: this.generateRequestId(),
timestamp: new Date().toISOString(),
channelName: 'WEB',
serviceName: 'API_PURCHASE',
serviceParams: {
merchantUid: this.merchantUid,
apiUserId: this.apiUserId,
apiKey: this.apiKey,
paymentMethod: 'MWALLET_ACCOUNT',
payerInfo: {
accountNo: 'CUSTOMER', // Customer enters their number on Waafi page
},
transactionInfo: {
referenceId: input.merchantOrderId,
invoiceId: input.bookingRef,
amount,
currency: input.currency === 'ETB' ? 'DJF' : input.currency, // Convert ETB to DJF
description: `EDR Train Booking ${input.bookingRef}`,
},
},
};
}
private buildQueryRequest(merchantOrderId: string): WaafiQueryRequest {
return {
schemaVersion: '1.0',
requestId: this.generateRequestId(),
timestamp: new Date().toISOString(),
channelName: 'WEB',
serviceName: 'API_QUERY',
serviceParams: {
merchantUid: this.merchantUid,
apiUserId: this.apiUserId,
apiKey: this.apiKey,
referenceId: merchantOrderId,
},
};
}
private generateRequestId(): string {
return `EDR-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
}
private async postJson<T>(url: string, body: unknown): Promise<T> {
const config: AxiosRequestConfig = {
headers: {
'Content-Type': 'application/json',
},
timeout: WAAFI_HTTP_TIMEOUT_MS,
};
const started = Date.now();
try {
const res = await firstValueFrom(this.http.post<T>(url, body, config));
this.logger.debug(
`Waafi POST ${url} status=${res.status} latency=${Date.now() - started}ms`,
);
return res.data;
} catch (err) {
if (err instanceof AxiosError) {
this.logger.error(
`Waafi POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)} code=${err.code} message=${err.message}`,
);
} else {
this.logger.error(
`Waafi POST ${url} threw: ${err instanceof Error ? err.message : err}`,
);
}
throw err;
}
}
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 get baseUrl(): string {
return this.config.get<string>('waafi.baseUrl') ?? 'https://api.waafipay.net';
}
private get merchantUid(): string {
return this.config.get<string>('waafi.merchantUid') ?? '';
}
private get apiUserId(): string {
return this.config.get<string>('waafi.apiUserId') ?? '';
}
private get apiKey(): string {
return this.config.get<string>('waafi.apiKey') ?? '';
}
}

View File

@@ -0,0 +1,105 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../../common/prisma.service';
import { PaymentsService } from '../payments.service';
import { WaafiProvider } from '../providers/waafi.provider';
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
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;
}
@Injectable()
export class WaafiWebhookService {
private readonly logger = new Logger(WaafiWebhookService.name);
constructor(
private prisma: PrismaService,
private paymentsService: PaymentsService,
private waafiProvider: WaafiProvider,
) {}
async handleWebhook(payload: WaafiWebhookPayload): Promise<{ received: boolean }> {
this.logger.log(
`Waafi webhook received: event=${payload.eventType} ref=${payload.params?.referenceId}`,
);
const signatureValid = this.waafiProvider.verifyWebhookSignature(
payload as unknown as Record<string, unknown>,
);
const merchantOrderId = payload.params?.referenceId;
const transactionId = payload.params?.transactionId;
const state = payload.params?.state;
await this.prisma.paymentWebhookEvent.create({
data: {
provider: PaymentMethodType.WAAFI,
externalEventId: payload.requestId,
merchantOrderId,
providerTxnId: transactionId,
signatureValid,
status: state || 'UNKNOWN',
payload: payload as any,
},
});
if (!signatureValid) {
this.logger.warn(`Waafi webhook signature invalid for ref=${merchantOrderId}`);
return { received: true };
}
if (!merchantOrderId) {
this.logger.error('Waafi webhook missing referenceId');
return { received: true };
}
const intent = await this.prisma.paymentIntent.findFirst({
where: { merchantOrderId },
});
if (!intent) {
this.logger.warn(`No PaymentIntent found for merchantOrderId=${merchantOrderId}`);
return { received: true };
}
const mappedStatus = this.waafiProvider.mapState(state);
if (mappedStatus === PaymentIntentStatus.SUCCEEDED) {
await this.paymentsService.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: transactionId,
});
this.logger.log(`Waafi payment succeeded: intent=${intent.id} txn=${transactionId}`);
} else if (mappedStatus === PaymentIntentStatus.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,
providerTxnId: transactionId,
},
});
this.logger.log(`Waafi payment status updated: intent=${intent.id} status=${mappedStatus}`);
}
return { received: true };
}
}

View File

@@ -16,6 +16,7 @@ import {
CardWebhookPayload,
CardWebhookService,
} from './card-webhook.service';
import { WaafiWebhookService } from './waafi-webhook.service';
@ApiTags('Payment Webhooks')
@Controller('payments/webhooks')
@@ -27,11 +28,15 @@ export class WebhooksController {
private readonly cbeBirr: CbeBirrWebhookService,
private readonly eBirr: EBirrWebhookService,
private readonly card: CardWebhookService,
private readonly waafi: WaafiWebhookService,
) {}
@Post('telebirr')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Telebirr payment notification callback' })
@ApiOperation({
summary: 'Telebirr payment notification callback (Ethiopia)',
description: 'Webhook endpoint for Telebirr payment status updates. Used by Ethiopian passengers.'
})
async receiveTelebirr(@Body() payload: TelebirrWebhookPayload) {
try {
await this.telebirr.handle(payload);
@@ -44,7 +49,10 @@ export class WebhooksController {
@Post('cbe-birr')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'CBE Birr payment notification callback' })
@ApiOperation({
summary: 'CBE Birr payment notification callback (Ethiopia)',
description: 'Webhook endpoint for Commercial Bank of Ethiopia payment status updates.'
})
async receiveCbeBirr(@Body() payload: CbeBirrWebhookPayload) {
try {
await this.cbeBirr.handle(payload);
@@ -57,7 +65,10 @@ export class WebhooksController {
@Post('ebirr')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'eBirr payment notification callback' })
@ApiOperation({
summary: 'eBirr payment notification callback (Ethiopia)',
description: 'Webhook endpoint for eBirr electronic payment gateway status updates.'
})
async receiveEBirr(@Body() payload: EBirrWebhookPayload) {
try {
await this.eBirr.handle(payload);
@@ -70,7 +81,10 @@ export class WebhooksController {
@Post('card')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Card payment notification callback' })
@ApiOperation({
summary: 'Card payment notification callback (International)',
description: 'Webhook endpoint for international card payments (Visa, Mastercard) via Stripe.'
})
async receiveCard(
@Body() payload: CardWebhookPayload,
@Headers('stripe-signature') signature: string,
@@ -83,4 +97,20 @@ export class WebhooksController {
}
return { received: true };
}
@Post('waafi')
@HttpCode(HttpStatus.OK)
@ApiOperation({
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) {
try {
await this.waafi.handleWebhook(payload);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Waafi webhook handler threw: ${message}`);
}
return { responseCode: '2001', responseMsg: 'Success' };
}
}