diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index a0d6c988b..3ff2f8b62 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -21,7 +21,6 @@ }, "dependencies": { - "@edr/payment-providers": "workspace:*", "@edr/types": "workspace:*", "@nestjs/axios": "^4.0.1", "@nestjs/common": "^11.0.0", diff --git a/apps/edr-passenger-api/src/common/guards/service-auth.guard.ts b/apps/edr-passenger-api/src/common/guards/service-auth.guard.ts new file mode 100644 index 000000000..b47876d4c --- /dev/null +++ b/apps/edr-passenger-api/src/common/guards/service-auth.guard.ts @@ -0,0 +1,54 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + Logger, + UnauthorizedException, +} from "@nestjs/common"; +import { timingSafeEqual } from "node:crypto"; +import { Request } from "express"; + +/** + * Shared-secret guard for endpoints only the payment microservice may call + * (e.g. /internal/payments/mark-paid). The secret is the same SERVICE_AUTH_TOKEN the + * payment service enforces on its own internal surface. A forged mark-paid must not be able + * to confirm a booking without a real payment. + * TODO: integrate @tria-plc IAM / mTLS as the long-term mechanism. + */ +@Injectable() +export class ServiceAuthGuard implements CanActivate { + private readonly logger = new Logger(ServiceAuthGuard.name); + private readonly token = process.env.SERVICE_AUTH_TOKEN ?? ""; + private warned = false; + + constructor() { + if (!this.token && process.env.NODE_ENV === "production") { + throw new Error("SERVICE_AUTH_TOKEN must be set in production"); + } + } + + canActivate(context: ExecutionContext): boolean { + if (!this.token) { + if (!this.warned) { + this.logger.warn( + "SERVICE_AUTH_TOKEN unset — internal endpoints are UNGUARDED (dev only)", + ); + this.warned = true; + } + return true; + } + + const request = context.switchToHttp().getRequest(); + const header = request.headers["x-service-token"]; + const bearer = request.headers.authorization?.replace(/^Bearer\s+/i, ""); + const presented = + (Array.isArray(header) ? header[0] : header) ?? bearer ?? ""; + + const expected = Buffer.from(this.token); + const actual = Buffer.from(presented); + const valid = + expected.length === actual.length && timingSafeEqual(expected, actual); + if (!valid) throw new UnauthorizedException("Invalid service token"); + return true; + } +} diff --git a/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts new file mode 100644 index 000000000..98262c4c3 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts @@ -0,0 +1,35 @@ +import { + Body, + Controller, + HttpCode, + HttpStatus, + Post, + UseGuards, +} from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; +import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto"; +import { PaymentsService } from "./payments.service"; + +/** + * Consumer side of the payment microservice's outbox relay (docs/payment-service §7.3). + * Only the payment service may call this (shared service token). Idempotent by design: + * the relay delivers at-least-once, so duplicates must be harmless. Becomes a queue + * consumer when RabbitMQ lands — the handler logic is transport-agnostic. + */ +@ApiTags("Internal Payments") +@UseGuards(ServiceAuthGuard) +@Controller("internal/payments") +export class InternalPaymentsController { + constructor(private readonly paymentsService: PaymentsService) {} + + @Post("mark-paid") + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: + "Apply a payment.succeeded/payment.failed event from the payment service (idempotent)", + }) + async markPaid(@Body() event: PaymentEventDto): Promise { + return this.paymentsService.handlePaymentEvent(event); + } +} diff --git a/apps/edr-passenger-api/src/modules/payments/internal-payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/internal-payments.dto.ts new file mode 100644 index 000000000..ce421f78a --- /dev/null +++ b/apps/edr-passenger-api/src/modules/payments/internal-payments.dto.ts @@ -0,0 +1,57 @@ +import { + IsEnum, + IsIn, + IsInt, + IsISO8601, + IsOptional, + IsPositive, + IsString, + IsUUID, +} from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { + PaymentEventType, + PaymentReferenceType, + PaymentService, + ProviderMethod, +} from "@edr/types"; + +/** + * Wire shape of the `PaymentEvent` envelope (@edr/types) delivered by the payment + * microservice's outbox relay. Delivery is at-least-once — the consumer is idempotent. + */ +export class PaymentEventDto { + @ApiProperty({ enum: [1] }) @IsIn([1]) version!: 1; + @ApiProperty() @IsUUID() eventId!: string; + @ApiProperty({ enum: ["payment.succeeded", "payment.failed"] }) + @IsIn(["payment.succeeded", "payment.failed"]) + eventType!: PaymentEventType; + + @ApiProperty() @IsISO8601() occurredAt!: string; + @ApiProperty({ enum: PaymentService }) + @IsEnum(PaymentService) + service!: PaymentService; + @ApiProperty() @IsUUID() intentId!: string; + @ApiProperty({ enum: PaymentReferenceType }) + @IsEnum(PaymentReferenceType) + referenceType!: PaymentReferenceType; + + @ApiProperty() @IsString() referenceId!: string; + @ApiProperty() @IsString() merchantOrderId!: string; + @ApiProperty({ enum: ProviderMethod }) + @IsEnum(ProviderMethod) + provider!: ProviderMethod; + @ApiProperty() @IsInt() @IsPositive() amountMinor!: number; + @ApiProperty() @IsString() currency!: string; + + @ApiPropertyOptional() @IsOptional() @IsString() providerTxnId?: string; + @ApiPropertyOptional() @IsOptional() @IsISO8601() paidAt?: string; + @ApiPropertyOptional() @IsOptional() @IsString() failureCode?: string; + @ApiPropertyOptional() @IsOptional() @IsString() failureMessage?: string; +} + +export class MarkPaidResponseDto { + @ApiProperty() processed!: boolean; + @ApiPropertyOptional() alreadyFinalized?: boolean; + @ApiPropertyOptional() reason?: string; +} diff --git a/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts b/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts new file mode 100644 index 000000000..dc52cff6f --- /dev/null +++ b/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts @@ -0,0 +1,92 @@ +import { BadGatewayException, Injectable, Logger } from "@nestjs/common"; +import { HttpService } from "@nestjs/axios"; +import { AxiosError } from "axios"; +import { firstValueFrom } from "rxjs"; +import { + InitiatePaymentRequest, + PaymentIntentSnapshot, + PaymentReferenceType, + PaymentService, +} from "@edr/types"; + +/** + * Thin HTTP client for the payment microservice (apps/edr-payment-api) — the passenger app's + * side of the Phase 6 cutover (docs/payment-service §10). Domain validation stays here; + * provider calls, intents, and webhooks live in the payment service. + */ +@Injectable() +export class PaymentClientService { + private readonly logger = new Logger(PaymentClientService.name); + private readonly baseUrl = ( + process.env.PAYMENT_API_URL ?? "http://localhost:3003" + ).replace(/\/$/, ""); + private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? ""; + + constructor(private readonly http: HttpService) {} + + /** POST /payments/initiate — idempotent per (service, referenceType, referenceId). */ + async initiate( + request: InitiatePaymentRequest, + ): Promise { + return this.call("POST", "/payments/initiate", request); + } + + /** GET /payments/intents?… — active intent by domain reference; null when none exists. */ + async getIntentByReference( + referenceType: PaymentReferenceType, + referenceId: string, + ): Promise { + const query = new URLSearchParams({ + service: PaymentService.PASSENGER, + referenceType, + referenceId, + }); + try { + return await this.call("GET", `/payments/intents?${query.toString()}`); + } catch (err) { + if (err instanceof AxiosError && err.response?.status === 404) + return null; + throw err; + } + } + + private async call( + method: "GET" | "POST", + path: string, + body?: unknown, + ): Promise { + const url = `${this.baseUrl}${path}`; + try { + const response = await firstValueFrom( + this.http.request({ + method, + url, + data: body, + headers: this.serviceToken + ? { "x-service-token": this.serviceToken } + : {}, + }), + ); + return response.data; + } catch (err) { + if (err instanceof AxiosError && err.response) { + // 4xx/5xx from the payment service: propagate 404 to callers that handle it; + // everything else is a gateway-level failure from the client's perspective. + if (err.response.status === 404) throw err; + const detail = + (err.response.data as { message?: string | string[] })?.message ?? + err.message; + this.logger.error( + `payment service ${method} ${path} → ${err.response.status}: ${detail}`, + ); + throw new BadGatewayException(`Payment service error: ${detail}`); + } + const message = + err instanceof Error && err.message ? err.message : String(err); + this.logger.error( + `payment service unreachable (${method} ${path}): ${message}`, + ); + throw new BadGatewayException("Payment service unreachable"); + } + } +} diff --git a/apps/edr-passenger-api/src/modules/payments/payments.adapters.ts b/apps/edr-passenger-api/src/modules/payments/payments.adapters.ts index b686269c5..0397e62ab 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.adapters.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.adapters.ts @@ -1,10 +1,50 @@ -export interface GatewayResult { success: boolean; providerRef: string; clientAction?: { type: string; url?: string }; } - -export async function telebirrAdapter(_a: number, ref: string): Promise { - await new Promise((r) => setTimeout(r, 200)); - return { success: true, providerRef: `TB-${ref}-${Date.now()}`, clientAction: { type: 'REDIRECT', url: `https://telebirr.sandbox.com/pay/${ref}` } }; +export interface GatewayResult { + success: boolean; + providerRef: string; + clientAction?: { type: string; url?: string }; +} + +export async function telebirrAdapter( + _a: number, + ref: string, +): Promise { + await new Promise((r) => setTimeout(r, 200)); + return { + success: true, + providerRef: `TB-${ref}-${Date.now()}`, + clientAction: { + type: "REDIRECT", + url: `https://telebirr.sandbox.com/pay/${ref}`, + }, + }; +} +export async function cbeBirrAdapter( + _a: number, + ref: string, +): Promise { + await new Promise((r) => setTimeout(r, 150)); + return { success: true, providerRef: `CBE-${ref}-${Date.now()}` }; +} +export async function eBirrAdapter( + _a: number, + ref: string, +): Promise { + await new Promise((r) => setTimeout(r, 150)); + return { success: true, providerRef: `EB-${ref}-${Date.now()}` }; +} +export async function cardAdapter( + _a: number, + ref: string, +): Promise { + await new Promise((r) => setTimeout(r, 150)); + return { + success: !ref.startsWith("FAIL"), + providerRef: `CARD-${ref}-${Date.now()}`, + }; +} +export async function walletAdapter( + amount: number, + balance: number, +): Promise { + return { success: balance >= amount, providerRef: `WALLET-${Date.now()}` }; } -export async function cbeBirrAdapter(_a: number, ref: string): Promise { await new Promise((r) => setTimeout(r, 150)); return { success: true, providerRef: `CBE-${ref}-${Date.now()}` }; } -export async function eBirrAdapter(_a: number, ref: string): Promise { await new Promise((r) => setTimeout(r, 150)); return { success: true, providerRef: `EB-${ref}-${Date.now()}` }; } -export async function cardAdapter(_a: number, ref: string): Promise { await new Promise((r) => setTimeout(r, 150)); return { success: !ref.startsWith('FAIL'), providerRef: `CARD-${ref}-${Date.now()}` }; } -export async function walletAdapter(amount: number, balance: number): Promise { return { success: balance >= amount, providerRef: `WALLET-${Date.now()}` }; } diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 93fd901df..373e10513 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -1,34 +1,59 @@ -import { Body, Controller, Get, HttpStatus, Param, Post, Query, Res, UseGuards } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiOkResponse, ApiProduces } from '@nestjs/swagger'; -import { Response } from 'express'; -import { PaymentsService } from './payments.service'; -import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, PaymentRegionEnum, SupportedPaymentMethodDto, PaymentMethodTypeEnum, PaymentPlatformDto } from './payments.dto'; -import { JwtGuard } from '../../common/jwt.guard'; -import { RolesGuard } from '../../common/roles.guard'; -import { Roles } from '../../common/roles.decorator'; -import { UserRole } from '@prisma/client'; +import { + Body, + Controller, + Get, + HttpStatus, + Param, + Post, + Query, + Res, + UseGuards, +} from "@nestjs/common"; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiQuery, + ApiOkResponse, + ApiProduces, +} from "@nestjs/swagger"; +import { Response } from "express"; +import { PaymentsService } from "./payments.service"; +import { + InitiatePaymentDto, + RefundDto, + AddPaymentMethodDto, + PaymentRegionEnum, + SupportedPaymentMethodDto, + PaymentMethodTypeEnum, + PaymentPlatformDto, +} from "./payments.dto"; +import { JwtGuard } from "../../common/jwt.guard"; +import { RolesGuard } from "../../common/roles.guard"; +import { Roles } from "../../common/roles.decorator"; +import { UserRole } from "@prisma/client"; -@ApiTags('Payment') -@Controller('payments') +@ApiTags("Payment") +@Controller("payments") export class PaymentsController { constructor(private service: PaymentsService) {} - @Get('all') + @Get("all") @UseGuards(JwtGuard, RolesGuard) @Roles(UserRole.ADMIN, UserRole.SUPERVISOR, UserRole.STAFF) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Get all payments with filters (staff/admin only)' }) - @ApiQuery({ name: 'search', required: false }) - @ApiQuery({ name: 'status', required: false }) - @ApiQuery({ name: 'method', required: false }) - @ApiQuery({ name: 'page', required: false }) - @ApiQuery({ name: 'pageSize', required: false }) + @ApiBearerAuth("JWT-auth") + @ApiOperation({ summary: "Get all payments with filters (staff/admin only)" }) + @ApiQuery({ name: "search", required: false }) + @ApiQuery({ name: "status", required: false }) + @ApiQuery({ name: "method", required: false }) + @ApiQuery({ name: "page", required: false }) + @ApiQuery({ name: "pageSize", required: false }) async getAll( - @Query('search') search?: string, - @Query('status') status?: string, - @Query('method') method?: string, - @Query('page') page?: string, - @Query('pageSize') pageSize?: string, + @Query("search") search?: string, + @Query("status") status?: string, + @Query("method") method?: string, + @Query("page") page?: string, + @Query("pageSize") pageSize?: string, ) { return this.service.getAll({ search, @@ -38,80 +63,121 @@ export class PaymentsController { pageSize: pageSize ? parseInt(pageSize) : 10, }); } - - @Post('initiate') - @ApiOperation({ - summary: 'Initiate payment with nationality-based payment methods', - description: `Initiates payment for a booking with support for multiple payment providers:\n\n**Ethiopian Payment Methods:**\n- TELEBIRR - Ethiopia's leading mobile money\n- CBE_BIRR - Commercial Bank of Ethiopia\n- EBIRR - Electronic payment gateway\n\n**Djiboutian Payment Methods:**\n- WAAFI - Djibouti's mobile money service\n\n**International Payment Methods:**\n- CARD - Visa, Mastercard\n- WALLET - Internal wallet balance\n\n**Multi-Currency:**\n- All transactions processed in ETB\n- Display amounts in ETB, DJF, or USD\n- Real-time exchange rate conversion` + + @Post("initiate") + @ApiOperation({ + summary: "Initiate payment with nationality-based payment methods", + description: `Initiates payment for a booking with support for multiple payment providers:\n\n**Ethiopian Payment Methods:**\n- TELEBIRR - Ethiopia's leading mobile money\n- CBE_BIRR - Commercial Bank of Ethiopia\n- EBIRR - Electronic payment gateway\n\n**Djiboutian Payment Methods:**\n- WAAFI - Djibouti's mobile money service\n\n**International Payment Methods:**\n- CARD - Visa, Mastercard\n- WALLET - Internal wallet balance\n\n**Multi-Currency:**\n- All transactions processed in ETB\n- Display amounts in ETB, DJF, or USD\n- 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') + 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") @UseGuards(JwtGuard, RolesGuard) @Roles(UserRole.ADMIN, UserRole.STAFF, UserRole.AGENT) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Refund a confirmed booking (staff/agent only)' }) - refund(@Body() dto: RefundDto) { return this.service.refund(dto); } + @ApiBearerAuth("JWT-auth") + @ApiOperation({ summary: "Refund a confirmed booking (staff/agent only)" }) + refund(@Body() dto: RefundDto) { + return this.service.refund(dto); + } - @Post('methods') + @Post("methods") @UseGuards(JwtGuard, RolesGuard) @Roles(UserRole.ADMIN, UserRole.STAFF) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Add a payment system to the platform catalog (admin only)' }) - addMethod(@Body() dto: AddPaymentMethodDto) { return this.service.addPaymentMethod(dto); } - - @Get('methods') + @ApiBearerAuth("JWT-auth") @ApiOperation({ - summary: 'List payment systems supported by the platform', - description: 'Returns the global catalog of accepted payment systems. Not user-specific. Optionally filter by region to match a passenger\'s nationality.', + summary: "Add a payment system to the platform catalog (admin only)", }) - @ApiQuery({ name: 'region', enum: PaymentRegionEnum, required: false }) - @ApiOkResponse({ type: [SupportedPaymentMethodDto] }) - getMethods(@Query('region') region?: PaymentRegionEnum) { return this.service.getSupportedPaymentMethods(region); } + addMethod(@Body() dto: AddPaymentMethodDto) { + return this.service.addPaymentMethod(dto); + } - @Get('checkout') + @Get("methods") @ApiOperation({ - summary: 'Browser checkout redirect', - description: 'Initiates payment and returns an HTML page that auto-redirects the browser to the provider checkout URL. Designed to be opened directly in a browser tab.', + summary: "List payment systems supported by the platform", + description: + "Returns the global catalog of accepted payment systems. Not user-specific. Optionally filter by region to match a passenger's nationality.", }) - @ApiQuery({ name: 'bookingId', required: true }) - @ApiQuery({ name: 'method', enum: PaymentMethodTypeEnum, required: true }) - @ApiQuery({ name: 'platform', enum: ['web', 'mobile'], required: false }) - @ApiProduces('text/html') + @ApiQuery({ name: "region", enum: PaymentRegionEnum, required: false }) + @ApiOkResponse({ type: [SupportedPaymentMethodDto] }) + getMethods(@Query("region") region?: PaymentRegionEnum) { + return this.service.getSupportedPaymentMethods(region); + } + + @Get("checkout") + @ApiOperation({ + summary: "Browser checkout redirect", + description: + "Initiates payment and returns an HTML page that auto-redirects the browser to the provider checkout URL. Designed to be opened directly in a browser tab.", + }) + @ApiQuery({ name: "bookingId", required: true }) + @ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true }) + @ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false }) + @ApiProduces("text/html") async checkout( - @Query('bookingId') bookingId: string, - @Query('method') method: PaymentMethodTypeEnum, - @Query('platform') platform: PaymentPlatformDto = 'web', + @Query("bookingId") bookingId: string, + @Query("method") method: PaymentMethodTypeEnum, + @Query("platform") platform: PaymentPlatformDto = "web", @Res() res: Response, ) { if (!bookingId) { - return res.status(HttpStatus.BAD_REQUEST).type('html').send(this.buildErrorHtml('Missing required query parameter: bookingId')); + return res + .status(HttpStatus.BAD_REQUEST) + .type("html") + .send( + this.buildErrorHtml("Missing required query parameter: bookingId"), + ); } if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) { - return res.status(HttpStatus.BAD_REQUEST).type('html').send(this.buildErrorHtml('Missing or invalid query parameter: method')); + return res + .status(HttpStatus.BAD_REQUEST) + .type("html") + .send( + this.buildErrorHtml("Missing or invalid query parameter: method"), + ); } try { - const result = await this.service.initiatePayment({ bookingId, method, platform }); - const url = result.clientAction?.type === 'REDIRECT' ? result.clientAction.url : undefined; + const result = await this.service.initiatePayment({ + bookingId, + method, + platform, + }); + const url = + result.clientAction?.type === "REDIRECT" + ? result.clientAction.url + : undefined; if (url) { - return res.status(HttpStatus.OK).type('html').send(this.buildRedirectHtml(url)); + return res + .status(HttpStatus.OK) + .type("html") + .send(this.buildRedirectHtml(url)); } - return res.status(HttpStatus.OK).type('html').send(this.buildStatusHtml(result.status, result.intentId)); + return res + .status(HttpStatus.OK) + .type("html") + .send(this.buildStatusHtml(result.status, result.intentId)); } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'An unexpected error occurred'; - return res.status(HttpStatus.OK).type('html').send(this.buildErrorHtml(message)); + const message = + err instanceof Error ? err.message : "An unexpected error occurred"; + return res + .status(HttpStatus.OK) + .type("html") + .send(this.buildErrorHtml(message)); } } private buildRedirectHtml(url: string): string { - const escaped = url.replace(/\"/g, '"'); + const escaped = url.replace(/\"/g, """); return ` diff --git a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts index 9d8467c3f..309bd4a0a 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts @@ -1,36 +1,53 @@ -import { IsString, IsEnum, IsOptional, IsIn, IsBoolean, IsInt } from 'class-validator'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { PaymentIntentStatus } from '@prisma/client'; +import { + IsString, + IsEnum, + IsOptional, + IsIn, + IsBoolean, + IsInt, +} from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { PaymentIntentStatus } from "@prisma/client"; export enum PaymentRegionEnum { - ETHIOPIA = 'ETHIOPIA', - DJIBOUTI = 'DJIBOUTI', - INTERNATIONAL = 'INTERNATIONAL', - GLOBAL = 'GLOBAL', + ETHIOPIA = "ETHIOPIA", + DJIBOUTI = "DJIBOUTI", + INTERNATIONAL = "INTERNATIONAL", + GLOBAL = "GLOBAL", } -export enum PaymentMethodTypeEnum { - TELEBIRR = 'TELEBIRR', // Ethiopia - CBE_BIRR = 'CBE_BIRR', // Ethiopia - EBIRR = 'EBIRR', // Ethiopia - WAAFI = 'WAAFI', // Djibouti - CARD = 'CARD', // International - WALLET = 'WALLET' // Internal +export enum PaymentMethodTypeEnum { + TELEBIRR = "TELEBIRR", // Ethiopia + CBE_BIRR = "CBE_BIRR", // Ethiopia + EBIRR = "EBIRR", // Ethiopia + WAAFI = "WAAFI", // Djibouti + CARD = "CARD", // International + WALLET = "WALLET", // Internal } -export type PaymentPlatformDto = 'web' | 'mobile'; +export type PaymentPlatformDto = "web" | "mobile"; export class InitiatePaymentDto { - @ApiProperty({ example: 'booking-uuid' }) @IsString() bookingId: 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; - @ApiPropertyOptional({ enum: ['web', 'mobile'], default: 'web', description: 'Payment platform (web or mobile)' }) + 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() - @IsIn(['web', 'mobile']) + @IsString() + paymentMethodId?: string; + @ApiPropertyOptional({ + enum: ["web", "mobile"], + default: "web", + description: "Payment platform (web or mobile)", + }) + @IsOptional() + @IsIn(["web", "mobile"]) platform?: PaymentPlatformDto; } @@ -40,42 +57,76 @@ export class RefundDto { } export class AddPaymentMethodDto { - @ApiProperty({ enum: PaymentMethodTypeEnum }) @IsEnum(PaymentMethodTypeEnum) type: PaymentMethodTypeEnum; + @ApiProperty({ enum: PaymentMethodTypeEnum }) + @IsEnum(PaymentMethodTypeEnum) + type: PaymentMethodTypeEnum; @ApiProperty() @IsString() displayName: string; - @ApiProperty({ enum: PaymentRegionEnum }) @IsEnum(PaymentRegionEnum) region: PaymentRegionEnum; - @ApiPropertyOptional({ example: 'ETB' }) @IsOptional() @IsString() currency?: string; + @ApiProperty({ enum: PaymentRegionEnum }) + @IsEnum(PaymentRegionEnum) + region: PaymentRegionEnum; + @ApiPropertyOptional({ example: "ETB" }) + @IsOptional() + @IsString() + currency?: string; @ApiPropertyOptional() @IsOptional() @IsString() providerId?: string; - @ApiPropertyOptional({ default: true }) @IsOptional() @IsBoolean() enabled?: boolean; - @ApiPropertyOptional({ default: 0 }) @IsOptional() @IsInt() sortOrder?: number; + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + enabled?: boolean; + @ApiPropertyOptional({ default: 0 }) + @IsOptional() + @IsInt() + sortOrder?: number; } export class SupportedPaymentMethodDto { @ApiProperty({ enum: PaymentMethodTypeEnum }) type: PaymentMethodTypeEnum; - @ApiProperty({ example: 'Telebirr' }) displayName: string; + @ApiProperty({ example: "Telebirr" }) displayName: string; @ApiProperty({ enum: PaymentRegionEnum }) region: PaymentRegionEnum; - @ApiProperty({ example: 'ETB', description: 'Settlement currency for this method' }) currency: string; - @ApiProperty({ description: 'Whether the platform currently accepts this method' }) enabled: boolean; + @ApiProperty({ + example: "ETB", + description: "Settlement currency for this method", + }) + currency: string; + @ApiProperty({ + description: "Whether the platform currently accepts this method", + }) + enabled: boolean; } export class ClientActionDto { - @ApiProperty({ enum: ['REDIRECT', 'LAUNCH_APP'] }) type: 'REDIRECT' | 'LAUNCH_APP'; - @ApiPropertyOptional({ description: 'Set when type=REDIRECT (web flow)' }) url?: string; - @ApiPropertyOptional({ description: 'Set when type=LAUNCH_APP (mobile flow)' }) prepayId?: string; - @ApiPropertyOptional({ description: 'Set when type=LAUNCH_APP (mobile flow)' }) receiveCode?: string; - @ApiPropertyOptional({ description: 'Set when type=LAUNCH_APP (mobile flow)' }) shortCode?: string; + @ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP"] }) type: + | "REDIRECT" + | "LAUNCH_APP"; + @ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" }) + url?: string; + @ApiPropertyOptional({ + description: "Set when type=LAUNCH_APP (mobile flow)", + }) + prepayId?: string; + @ApiPropertyOptional({ + description: "Set when type=LAUNCH_APP (mobile flow)", + }) + receiveCode?: string; + @ApiPropertyOptional({ + description: "Set when type=LAUNCH_APP (mobile flow)", + }) + shortCode?: string; } export class InitiateResponseDto { @ApiProperty() intentId: string; @ApiProperty({ enum: PaymentIntentStatus }) status: PaymentIntentStatus; - @ApiPropertyOptional({ type: ClientActionDto }) clientAction?: ClientActionDto; + @ApiPropertyOptional({ type: ClientActionDto }) + clientAction?: ClientActionDto; @ApiPropertyOptional() merchantOrderId?: string; } export class IntentStatusDto { @ApiProperty() intentId: string; @ApiProperty({ enum: PaymentIntentStatus }) status: PaymentIntentStatus; - @ApiPropertyOptional({ type: ClientActionDto }) clientAction?: ClientActionDto; + @ApiPropertyOptional({ type: ClientActionDto }) + clientAction?: ClientActionDto; @ApiPropertyOptional() merchantOrderId?: string; @ApiPropertyOptional() paidAt?: string; @ApiPropertyOptional() failureCode?: string; diff --git a/apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts b/apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts index 0fe3bdd3b..0fd594b39 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts @@ -1,10 +1,10 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication, ValidationPipe } from '@nestjs/common'; -import request from 'supertest'; -import { AppModule } from '../../app.module'; -import { PrismaService } from '../../common/prisma.service'; +import { Test, TestingModule } from "@nestjs/testing"; +import { INestApplication, ValidationPipe } from "@nestjs/common"; +import request from "supertest"; +import { AppModule } from "../../app.module"; +import { PrismaService } from "../../common/prisma.service"; -describe('Payments E2E', () => { +describe("Payments E2E", () => { let app: INestApplication; let prisma: PrismaService; let authToken: string; @@ -16,47 +16,123 @@ describe('Payments E2E', () => { }).compile(); app = moduleFixture.createNestApplication(); - app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })); + app.useGlobalPipes( + new ValidationPipe({ transform: true, whitelist: true }), + ); await app.init(); prisma = app.get(PrismaService); const testUser = await prisma.user.create({ - data: { email: 'payment-test@example.com', phone: '+251911111112', fullName: 'Payment Test User', passwordHash: '$2b$10$abcdefghijklmnopqrstuvwxyz', role: 'PASSENGER' }, + data: { + email: "payment-test@example.com", + phone: "+251911111112", + fullName: "Payment Test User", + passwordHash: "$2b$10$abcdefghijklmnopqrstuvwxyz", + role: "PASSENGER", + }, }); - const passenger = await prisma.passenger.create({ data: { userId: testUser.id } }); + const passenger = await prisma.passenger.create({ + data: { userId: testUser.id }, + }); - await prisma.walletAccount.create({ data: { passengerId: passenger.id, balanceMinor: 100000, currency: 'ETB' } }); + await prisma.walletAccount.create({ + data: { + passengerId: passenger.id, + balanceMinor: 100000, + currency: "ETB", + }, + }); - authToken = 'mock-jwt-token'; + authToken = "mock-jwt-token"; - const station1 = await prisma.station.create({ data: { code: 'TST1', name: 'Test Station 1', city: 'Test City', lat: 9.0, lng: 38.0 } }); - const station2 = await prisma.station.create({ data: { code: 'TST2', name: 'Test Station 2', city: 'Test City 2', lat: 9.5, lng: 38.5 } }); + const station1 = await prisma.station.create({ + data: { + code: "TST1", + name: "Test Station 1", + city: "Test City", + lat: 9.0, + lng: 38.0, + }, + }); + const station2 = await prisma.station.create({ + data: { + code: "TST2", + name: "Test Station 2", + city: "Test City 2", + lat: 9.5, + lng: 38.5, + }, + }); - const train = await prisma.train.create({ data: { number: 'TEST-001', name: 'Test Train' } }); + const train = await prisma.train.create({ + data: { number: "TEST-001", name: "Test Train" }, + }); const schedule = await prisma.trainSchedule.create({ - data: { trainId: train.id, originStationId: station1.id, destinationStationId: station2.id, departureAt: new Date(Date.now() + 86400000), arrivalAt: new Date(Date.now() + 90000000), durationMinutes: 60 }, + data: { + trainId: train.id, + originStationId: station1.id, + destinationStationId: station2.id, + departureAt: new Date(Date.now() + 86400000), + arrivalAt: new Date(Date.now() + 90000000), + durationMinutes: 60, + }, }); - const coachType = await prisma.coachType.create({ data: { name: 'Standard', code: 'STD' } }); + const coachType = await prisma.coachType.create({ + data: { name: "Standard", code: "STD" }, + }); const seatClass = await prisma.seatClass.create({ - data: { name: 'Economy Regular', description: 'Standard economy seating', baseFareMinor: 45000, isActive: true, coachTypeId: coachType.id }, + data: { + name: "Economy Regular", + description: "Standard economy seating", + baseFareMinor: 45000, + isActive: true, + coachTypeId: coachType.id, + }, }); const coach = await prisma.coach.create({ - data: { coachTypeId: coachType.id, number: 'TEST-C1', arrangement: '2+2', capacity: 10, status: 'ACTIVE' }, + data: { + coachTypeId: coachType.id, + number: "TEST-C1", + arrangement: "2+2", + capacity: 10, + status: "ACTIVE", + }, }); - const seat = await prisma.seat.create({ data: { coachId: coach.id, row: 1, col: 'A', seatNumber: '1A', status: 'AVAILABLE' } }); + const seat = await prisma.seat.create({ + data: { + coachId: coach.id, + row: 1, + col: "A", + seatNumber: "1A", + status: "AVAILABLE", + }, + }); const booking = await prisma.booking.create({ - data: { bookingRef: 'TEST-BOOK-001', passengerId: passenger.id, scheduleId: schedule.id, status: 'PENDING_PAYMENT', totalMinor: 50000, currency: 'ETB' }, + data: { + bookingRef: "TEST-BOOK-001", + passengerId: passenger.id, + scheduleId: schedule.id, + status: "PENDING_PAYMENT", + totalMinor: 50000, + currency: "ETB", + }, }); - await prisma.bookingSeat.create({ data: { bookingId: booking.id, seatId: seat.id, passengerName: 'Test Passenger' } }); + await prisma.bookingSeat.create({ + data: { + bookingId: booking.id, + seatId: seat.id, + passengerName: "Test Passenger", + }, + }); bookingId = booking.id; }); @@ -71,89 +147,60 @@ describe('Payments E2E', () => { prisma.coach.deleteMany(), prisma.trainSchedule.deleteMany(), prisma.train.deleteMany(), - prisma.station.deleteMany({ where: { code: { in: ['TST1', 'TST2'] } } }), + prisma.station.deleteMany({ where: { code: { in: ["TST1", "TST2"] } } }), prisma.walletLedgerEntry.deleteMany(), prisma.walletAccount.deleteMany(), prisma.passenger.deleteMany(), - prisma.user.deleteMany({ where: { email: 'payment-test@example.com' } }), + prisma.user.deleteMany({ where: { email: "payment-test@example.com" } }), ]); await app.close(); }); - describe('POST /payments/initiate', () => { - it('should initiate wallet payment successfully', async () => { + describe("POST /payments/initiate", () => { + it("should initiate wallet payment successfully", async () => { const response = await request(app.getHttpServer()) - .post('/payments/initiate') - .set('Authorization', `Bearer ${authToken}`) - .send({ bookingId, method: 'WALLET' }) + .post("/payments/initiate") + .set("Authorization", `Bearer ${authToken}`) + .send({ bookingId, method: "WALLET" }) .expect(201); expect(response.body.intentId).toBeDefined(); - expect(response.body.status).toBe('SUCCEEDED'); + expect(response.body.status).toBe("SUCCEEDED"); }); - it('should return 400 for invalid payment method', async () => { + it("should return 400 for invalid payment method", async () => { await request(app.getHttpServer()) - .post('/payments/initiate') - .set('Authorization', `Bearer ${authToken}`) - .send({ bookingId, method: 'INVALID_METHOD' }) + .post("/payments/initiate") + .set("Authorization", `Bearer ${authToken}`) + .send({ bookingId, method: "INVALID_METHOD" }) .expect(400); }); - it('should return 404 for non-existent booking', async () => { + it("should return 404 for non-existent booking", async () => { await request(app.getHttpServer()) - .post('/payments/initiate') - .set('Authorization', `Bearer ${authToken}`) - .send({ bookingId: 'non-existent-id', method: 'WALLET' }) + .post("/payments/initiate") + .set("Authorization", `Bearer ${authToken}`) + .send({ bookingId: "non-existent-id", method: "WALLET" }) .expect(404); }); }); - describe('GET /payments/intents/:bookingId', () => { - it('should get payment intent status', async () => { + describe("GET /payments/intents/:bookingId", () => { + it("should get payment intent status", async () => { const response = await request(app.getHttpServer()) .get(`/payments/intents/${bookingId}`) - .set('Authorization', `Bearer ${authToken}`) + .set("Authorization", `Bearer ${authToken}`) .expect(200); expect(response.body.intentId).toBeDefined(); expect(response.body.status).toBeDefined(); }); - it('should return 404 for non-existent intent', async () => { + it("should return 404 for non-existent intent", async () => { await request(app.getHttpServer()) - .get('/payments/intents/non-existent-booking') - .set('Authorization', `Bearer ${authToken}`) + .get("/payments/intents/non-existent-booking") + .set("Authorization", `Bearer ${authToken}`) .expect(404); }); }); - describe('Webhook endpoints', () => { - it('should handle Telebirr webhook', async () => { - await request(app.getHttpServer()) - .post('/payments/webhooks/telebirr') - .send({ merch_order_id: 'TEST-ORDER-123', payment_order_id: 'PAY-123', trade_status: 'Completed', sign: 'mock-signature' }) - .expect(200); - }); - - it('should handle CBE Birr webhook', async () => { - await request(app.getHttpServer()) - .post('/payments/webhooks/cbe-birr') - .send({ merchantId: 'TEST-MERCHANT', merchantOrderId: 'TEST-ORDER-123', orderId: 'CBE-ORDER-123', status: 'SUCCESS', signature: 'mock-signature' }) - .expect(200); - }); - - it('should handle eBirr webhook', async () => { - await request(app.getHttpServer()) - .post('/payments/webhooks/ebirr') - .send({ merchantCode: 'TEST-MERCHANT', orderNo: 'TEST-ORDER-123', tradeStatus: 'TRADE_SUCCESS', timestamp: Date.now(), sign: 'mock-signature' }) - .expect(200); - }); - - it('should handle Card webhook', async () => { - await request(app.getHttpServer()) - .post('/payments/webhooks/card') - .set('stripe-signature', 'mock-signature') - .send({ id: 'evt_123', type: 'payment_intent.succeeded', data: { object: { id: 'pi_123', status: 'succeeded', amount: 50000, currency: 'ETB', metadata: { merchantOrderId: 'TEST-ORDER-123', bookingRef: 'TEST-BOOK-001' } } }, created: Math.floor(Date.now() / 1000) }) - .expect(200); - }); - }); + // Provider webhooks moved to the payment microservice (apps/edr-payment-api /webhooks/*). }); diff --git a/apps/edr-passenger-api/src/modules/payments/payments.module.ts b/apps/edr-passenger-api/src/modules/payments/payments.module.ts index 1f8086ac3..dfab3e9f1 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.module.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.module.ts @@ -1,38 +1,25 @@ -import { Module } from '@nestjs/common'; -import { HttpModule } from '@nestjs/axios'; -import { PaymentsController } from './payments.controller'; -import { PaymentsService } from './payments.service'; -import { SeatsModule } from '../seats/seats.module'; -import { TicketsModule } from '../tickets/tickets.module'; -import { - TelebirrProvider, - CbeBirrProvider, - EBirrProvider, - CardProvider, - WaafiProvider, -} from '@edr/payment-providers'; -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'; +import { Module } from "@nestjs/common"; +import { HttpModule } from "@nestjs/axios"; +import { PaymentsController } from "./payments.controller"; +import { PaymentsService } from "./payments.service"; +import { InternalPaymentsController } from "./internal-payments.controller"; +import { PaymentClientService } from "./payment-client.service"; +import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; +import { SeatsModule } from "../seats/seats.module"; +import { TicketsModule } from "../tickets/tickets.module"; +/** + * Post-cutover (docs/payment-service phase 6): provider gateways and webhook handlers live in + * apps/edr-payment-api. This module keeps domain validation, the WALLET flow, the payment + * client, and the idempotent mark-paid consumer. + */ @Module({ - imports: [SeatsModule, TicketsModule, HttpModule.register({ timeout: 10_000 })], - controllers: [PaymentsController, WebhooksController], - providers: [ - PaymentsService, - TelebirrProvider, - CbeBirrProvider, - EBirrProvider, - CardProvider, - WaafiProvider, - TelebirrWebhookService, - CbeBirrWebhookService, - EBirrWebhookService, - CardWebhookService, - WaafiWebhookService, + imports: [ + SeatsModule, + TicketsModule, + HttpModule.register({ timeout: 10_000 }), ], + controllers: [PaymentsController, InternalPaymentsController], + providers: [PaymentsService, PaymentClientService, ServiceAuthGuard], }) export class PaymentsModule {} diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts index d4a35e14f..1a2ebdf1f 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts @@ -1,19 +1,21 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { PaymentsService } from './payments.service'; -import { PrismaService } from '../../common/prisma.service'; -import { SeatsService } from '../seats/seats.service'; -import { TicketsService } from '../tickets/tickets.service'; -import { EventEmitter2 } from '@nestjs/event-emitter'; +import { Test, TestingModule } from "@nestjs/testing"; +import { PaymentsService } from "./payments.service"; +import { PaymentClientService } from "./payment-client.service"; +import { PrismaService } from "../../common/prisma.service"; +import { SeatsService } from "../seats/seats.service"; +import { TicketsService } from "../tickets/tickets.service"; +import { EventEmitter2 } from "@nestjs/event-emitter"; +import { PaymentIntentStatus, PaymentMethodType } from "@prisma/client"; +import { BadRequestException, NotFoundException } from "@nestjs/common"; import { - TelebirrProvider, - CbeBirrProvider, - EBirrProvider, - CardProvider, -} from '@edr/payment-providers'; -import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client'; -import { BadRequestException, NotFoundException } from '@nestjs/common'; + PaymentIntentSnapshot, + PaymentReferenceType, + PaymentService as PaymentServiceEnum, + ProviderMethod, + ProviderPaymentStatus, +} from "@edr/types"; -describe('PaymentsService', () => { +describe("PaymentsService", () => { let service: PaymentsService; let prisma: PrismaService; let seatsService: SeatsService; @@ -62,29 +64,25 @@ describe('PaymentsService', () => { emit: jest.fn(), }; - const mockTelebirrProvider = { - method: PaymentMethodType.TELEBIRR, + const mockPaymentClient = { initiate: jest.fn(), - queryStatus: jest.fn(), + getIntentByReference: jest.fn(), }; - const mockCbeBirrProvider = { - method: PaymentMethodType.CBE_BIRR, - initiate: jest.fn(), - queryStatus: jest.fn(), - }; - - const mockEBirrProvider = { - method: PaymentMethodType.EBIRR, - initiate: jest.fn(), - queryStatus: jest.fn(), - }; - - const mockCardProvider = { - method: PaymentMethodType.CARD, - initiate: jest.fn(), - queryStatus: jest.fn(), - }; + const requiresActionSnapshot = ( + provider: ProviderMethod, + ): PaymentIntentSnapshot => ({ + intentId: "remote-intent-1", + service: PaymentServiceEnum.PASSENGER, + referenceType: PaymentReferenceType.BOOKING, + referenceId: "booking-1", + merchantOrderId: "PSG-MERCH-123", + provider, + status: ProviderPaymentStatus.REQUIRES_ACTION, + amountMinor: 50000, + currency: "ETB", + clientAction: { type: "REDIRECT", url: "https://provider.example/pay" }, + }); beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -94,10 +92,7 @@ describe('PaymentsService', () => { { provide: SeatsService, useValue: mockSeatsService }, { provide: TicketsService, useValue: mockTicketsService }, { provide: EventEmitter2, useValue: mockEventEmitter }, - { provide: TelebirrProvider, useValue: mockTelebirrProvider }, - { provide: CbeBirrProvider, useValue: mockCbeBirrProvider }, - { provide: EBirrProvider, useValue: mockEBirrProvider }, - { provide: CardProvider, useValue: mockCardProvider }, + { provide: PaymentClientService, useValue: mockPaymentClient }, ], }).compile(); @@ -108,221 +103,276 @@ describe('PaymentsService', () => { eventEmitter = module.get(EventEmitter2); jest.clearAllMocks(); + mockPaymentClient.getIntentByReference.mockResolvedValue(null); }); - describe('initiatePayment', () => { + describe("initiatePayment", () => { const mockBooking = { - id: 'booking-1', - bookingRef: 'EDR123456', - passengerId: 'passenger-1', + id: "booking-1", + bookingRef: "EDR123456", + passengerId: "passenger-1", totalMinor: 50000, - currency: 'ETB', - status: 'PENDING_PAYMENT', - seats: [{ id: 'seat-1', seatId: 'seat-id-1' }], + currency: "ETB", + status: "PENDING_PAYMENT", + seats: [{ id: "seat-1", seatId: "seat-id-1" }], }; - it('should throw NotFoundException if booking not found', async () => { + it("should throw NotFoundException if booking not found", async () => { mockPrisma.booking.findUnique.mockResolvedValue(null); await expect( service.initiatePayment({ - bookingId: 'invalid', - method: 'TELEBIRR' as any, + bookingId: "invalid", + method: "TELEBIRR" as any, }), ).rejects.toThrow(NotFoundException); }); - it('should throw BadRequestException if booking not payable', async () => { + it("should throw BadRequestException if booking not payable", async () => { mockPrisma.booking.findUnique.mockResolvedValue({ ...mockBooking, - status: 'CONFIRMED', + status: "CONFIRMED", }); await expect( service.initiatePayment({ - bookingId: 'booking-1', - method: 'TELEBIRR' as any, + bookingId: "booking-1", + method: "TELEBIRR" as any, }), ).rejects.toThrow(BadRequestException); }); - it('should initiate Telebirr payment successfully', async () => { + it("should initiate a provider payment through the payment microservice", async () => { mockPrisma.booking.findUnique.mockResolvedValue(mockBooking); - mockPrisma.paymentIntent.findUnique.mockResolvedValue(null); - mockTelebirrProvider.initiate.mockResolvedValue({ - providerOrderId: 'TB-ORDER-123', - clientAction: { type: 'REDIRECT', url: 'https://telebirr.com/pay' }, - expiresAt: new Date(), - rawInitiation: {}, - }); + mockPaymentClient.initiate.mockResolvedValue( + requiresActionSnapshot(ProviderMethod.TELEBIRR), + ); mockPrisma.paymentIntent.upsert.mockResolvedValue({ - id: 'intent-1', + id: "intent-1", status: PaymentIntentStatus.REQUIRES_ACTION, - merchantOrderId: 'MERCH-123', - clientAction: { type: 'REDIRECT', url: 'https://telebirr.com/pay' }, + merchantOrderId: "PSG-MERCH-123", + clientAction: { type: "REDIRECT", url: "https://provider.example/pay" }, }); const result = await service.initiatePayment({ - bookingId: 'booking-1', - method: 'TELEBIRR' as any, + bookingId: "booking-1", + method: "TELEBIRR" as any, }); expect(result.status).toBe(PaymentIntentStatus.REQUIRES_ACTION); - expect(mockTelebirrProvider.initiate).toHaveBeenCalled(); + expect(result.clientAction?.url).toBe("https://provider.example/pay"); + expect(mockPaymentClient.initiate).toHaveBeenCalledWith( + expect.objectContaining({ + service: PaymentServiceEnum.PASSENGER, + referenceType: PaymentReferenceType.BOOKING, + referenceId: "booking-1", + orderRef: "EDR123456", + amountMinor: 50000, + currency: "ETB", + provider: "TELEBIRR", + }), + ); + // Snapshot mirrored into the local projection. + expect(mockPrisma.paymentIntent.upsert).toHaveBeenCalledWith( + expect.objectContaining({ where: { bookingId: "booking-1" } }), + ); }); - it('should initiate CBE Birr payment successfully', async () => { + it("should finalize the booking when the service reports an already-paid intent", async () => { mockPrisma.booking.findUnique.mockResolvedValue(mockBooking); - mockPrisma.paymentIntent.findUnique.mockResolvedValue(null); - mockCbeBirrProvider.initiate.mockResolvedValue({ - providerOrderId: 'CBE-ORDER-123', - clientAction: { type: 'REDIRECT', url: 'https://cbe.com/pay' }, - expiresAt: new Date(), - rawInitiation: {}, + mockPaymentClient.initiate.mockResolvedValue({ + ...requiresActionSnapshot(ProviderMethod.WAAFI), + status: ProviderPaymentStatus.SUCCEEDED, + providerTxnId: "TXN-1", + paidAt: new Date().toISOString(), }); + // Projection clamps SUCCEEDED to PROCESSING; finalizePaymentSuccess flips it. mockPrisma.paymentIntent.upsert.mockResolvedValue({ - id: 'intent-1', - status: PaymentIntentStatus.REQUIRES_ACTION, - merchantOrderId: 'MERCH-123', - clientAction: { type: 'REDIRECT', url: 'https://cbe.com/pay' }, + id: "intent-1", + bookingId: "booking-1", + status: PaymentIntentStatus.PROCESSING, }); - - const result = await service.initiatePayment({ - bookingId: 'booking-1', - method: 'CBE_BIRR' as any, - }); - - expect(result.status).toBe(PaymentIntentStatus.REQUIRES_ACTION); - expect(mockCbeBirrProvider.initiate).toHaveBeenCalled(); - }); - - it('should initiate wallet payment and debit successfully', async () => { - mockPrisma.booking.findUnique.mockResolvedValue(mockBooking); - mockPrisma.paymentIntent.findUnique.mockResolvedValue(null); - mockPrisma.walletAccount.findUnique.mockResolvedValue({ - id: 'wallet-1', - passengerId: 'passenger-1', - balanceMinor: 100000, - }); - mockPrisma.paymentIntent.upsert.mockResolvedValue({ - id: 'intent-1', + mockPrisma.paymentIntent.findUnique.mockResolvedValue({ + id: "intent-1", + bookingId: "booking-1", status: PaymentIntentStatus.PROCESSING, }); mockPrisma.paymentIntent.findUniqueOrThrow.mockResolvedValue({ - id: 'intent-1', + id: "intent-1", status: PaymentIntentStatus.SUCCEEDED, - bookingId: 'booking-1', + merchantOrderId: "PSG-MERCH-123", + }); + mockPrisma.loyaltyAccount.findUnique.mockResolvedValue(null); + + const result = await service.initiatePayment({ + bookingId: "booking-1", + method: "WAAFI" as any, + }); + + expect(result.status).toBe(PaymentIntentStatus.SUCCEEDED); + expect(mockTicketsService.generate).toHaveBeenCalledWith("booking-1"); + }); + + it("should initiate wallet payment and debit successfully", async () => { + mockPrisma.booking.findUnique.mockResolvedValue(mockBooking); + // First call: existing-intent check (none); second call: finalize loads the new intent. + mockPrisma.paymentIntent.findUnique + .mockResolvedValueOnce(null) + .mockResolvedValue({ + id: "intent-1", + bookingId: "booking-1", + status: PaymentIntentStatus.PROCESSING, + }); + mockPrisma.walletAccount.findUnique.mockResolvedValue({ + id: "wallet-1", + passengerId: "passenger-1", + balanceMinor: 100000, + }); + mockPrisma.paymentIntent.upsert.mockResolvedValue({ + id: "intent-1", + status: PaymentIntentStatus.PROCESSING, + }); + mockPrisma.paymentIntent.findUniqueOrThrow.mockResolvedValue({ + id: "intent-1", + status: PaymentIntentStatus.SUCCEEDED, + bookingId: "booking-1", }); mockPrisma.loyaltyAccount.findUnique.mockResolvedValue({ - id: 'loyalty-1', + id: "loyalty-1", pointsBalance: 100, }); const result = await service.initiatePayment({ - bookingId: 'booking-1', - method: 'WALLET' as any, + bookingId: "booking-1", + method: "WALLET" as any, }); expect(result.status).toBe(PaymentIntentStatus.SUCCEEDED); expect(mockSeatsService.confirmSeats).toHaveBeenCalled(); expect(mockTicketsService.generate).toHaveBeenCalled(); + expect(mockPaymentClient.initiate).not.toHaveBeenCalled(); }); - it('should fail wallet payment with insufficient balance', async () => { + it("should fail wallet payment with insufficient balance", async () => { mockPrisma.booking.findUnique.mockResolvedValue(mockBooking); mockPrisma.paymentIntent.findUnique.mockResolvedValue(null); mockPrisma.walletAccount.findUnique.mockResolvedValue({ - id: 'wallet-1', - passengerId: 'passenger-1', + id: "wallet-1", + passengerId: "passenger-1", balanceMinor: 10000, // Less than booking total }); mockPrisma.paymentIntent.upsert.mockResolvedValue({ - id: 'intent-1', + id: "intent-1", status: PaymentIntentStatus.FAILED, - failureCode: 'INSUFFICIENT_BALANCE', + failureCode: "INSUFFICIENT_BALANCE", }); const result = await service.initiatePayment({ - bookingId: 'booking-1', - method: 'WALLET' as any, + bookingId: "booking-1", + method: "WALLET" as any, }); expect(result.status).toBe(PaymentIntentStatus.FAILED); }); }); - describe('finalizePaymentSuccess', () => { - it('should finalize payment and issue ticket', async () => { + describe("finalizePaymentSuccess", () => { + it("should finalize payment and issue ticket", async () => { const mockIntent = { - id: 'intent-1', - bookingId: 'booking-1', + id: "intent-1", + bookingId: "booking-1", status: PaymentIntentStatus.PROCESSING, }; const mockBooking = { - id: 'booking-1', - passengerId: 'passenger-1', + id: "booking-1", + passengerId: "passenger-1", totalMinor: 50000, - seats: [{ seatId: 'seat-1' }], + seats: [{ seatId: "seat-1" }], }; mockPrisma.paymentIntent.findUnique.mockResolvedValue(mockIntent); mockPrisma.booking.findUnique.mockResolvedValue(mockBooking); mockPrisma.loyaltyAccount.findUnique.mockResolvedValue({ - id: 'loyalty-1', + id: "loyalty-1", pointsBalance: 100, }); const result = await service.finalizePaymentSuccess({ - intentId: 'intent-1', - providerTxnId: 'TXN-123', + intentId: "intent-1", + providerTxnId: "TXN-123", }); expect(result.alreadyFinalized).toBe(false); - expect(mockSeatsService.confirmSeats).toHaveBeenCalledWith(['seat-1']); - expect(mockTicketsService.generate).toHaveBeenCalledWith('booking-1'); - expect(mockEventEmitter.emit).toHaveBeenCalledWith('payment.succeeded', { + expect(mockSeatsService.confirmSeats).toHaveBeenCalledWith(["seat-1"]); + expect(mockTicketsService.generate).toHaveBeenCalledWith("booking-1"); + expect(mockEventEmitter.emit).toHaveBeenCalledWith("payment.succeeded", { booking: mockBooking, }); }); - it('should return alreadyFinalized if payment already succeeded', async () => { + it("should return alreadyFinalized if payment already succeeded", async () => { mockPrisma.paymentIntent.findUnique.mockResolvedValue({ - id: 'intent-1', + id: "intent-1", status: PaymentIntentStatus.SUCCEEDED, }); const result = await service.finalizePaymentSuccess({ - intentId: 'intent-1', + intentId: "intent-1", }); expect(result.alreadyFinalized).toBe(true); }); }); - describe('getIntentByBookingId', () => { - it('should return intent status', async () => { + describe("getIntentByBookingId", () => { + it("should return the cached local intent when the payment service has none", async () => { const mockIntent = { - id: 'intent-1', - bookingId: 'booking-1', + id: "intent-1", + bookingId: "booking-1", status: PaymentIntentStatus.SUCCEEDED, method: PaymentMethodType.TELEBIRR, paidAt: new Date(), - merchantOrderId: 'MERCH-123', + merchantOrderId: "MERCH-123", updatedAt: new Date(), }; mockPrisma.paymentIntent.findUnique.mockResolvedValue(mockIntent); + mockPaymentClient.getIntentByReference.mockResolvedValue(null); - const result = await service.getIntentByBookingId('booking-1'); + const result = await service.getIntentByBookingId("booking-1"); - expect(result.intentId).toBe('intent-1'); + expect(result.intentId).toBe("intent-1"); expect(result.status).toBe(PaymentIntentStatus.SUCCEEDED); }); - it('should throw NotFoundException if intent not found', async () => { + it("should mirror a payment-service snapshot into the local projection", async () => { mockPrisma.paymentIntent.findUnique.mockResolvedValue(null); + mockPaymentClient.getIntentByReference.mockResolvedValue( + requiresActionSnapshot(ProviderMethod.WAAFI), + ); + mockPrisma.paymentIntent.upsert.mockResolvedValue({ + id: "intent-1", + bookingId: "booking-1", + status: PaymentIntentStatus.REQUIRES_ACTION, + merchantOrderId: "PSG-MERCH-123", + clientAction: { type: "REDIRECT", url: "https://provider.example/pay" }, + }); - await expect(service.getIntentByBookingId('invalid')).rejects.toThrow( + const result = await service.getIntentByBookingId("booking-1"); + + expect(mockPaymentClient.getIntentByReference).toHaveBeenCalledWith( + PaymentReferenceType.BOOKING, + "booking-1", + ); + expect(result.status).toBe(PaymentIntentStatus.REQUIRES_ACTION); + expect(result.clientAction?.url).toBe("https://provider.example/pay"); + }); + + it("should throw NotFoundException if intent not found anywhere", async () => { + mockPrisma.paymentIntent.findUnique.mockResolvedValue(null); + mockPaymentClient.getIntentByReference.mockResolvedValue(null); + + await expect(service.getIntentByBookingId("invalid")).rejects.toThrow( NotFoundException, ); }); diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 766ae6abe..fe80ae88e 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -1,22 +1,37 @@ -import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; -import { PrismaService } from '../../common/prisma.service'; -import { SeatsService } from '../seats/seats.service'; -import { TicketsService } from '../tickets/tickets.service'; -import { EventEmitter2 } from '@nestjs/event-emitter'; -import { Prisma, PaymentIntentStatus, PaymentMethodType, PaymentRegion } from '@prisma/client'; -import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, InitiateResponseDto, IntentStatusDto, PaymentRegionEnum } from './payments.dto'; import { + Injectable, + Logger, + NotFoundException, + BadRequestException, +} from "@nestjs/common"; +import { PrismaService } from "../../common/prisma.service"; +import { SeatsService } from "../seats/seats.service"; +import { TicketsService } from "../tickets/tickets.service"; +import { EventEmitter2 } from "@nestjs/event-emitter"; +import { + Prisma, + PaymentIntentStatus, + PaymentMethodType, + PaymentRegion, +} from "@prisma/client"; +import { + InitiatePaymentDto, + RefundDto, + AddPaymentMethodDto, + InitiateResponseDto, + IntentStatusDto, + PaymentRegionEnum, +} from "./payments.dto"; +import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto"; +import { PaymentClientService } from "./payment-client.service"; +import { + PaymentService as PaymentServiceEnum, + PaymentReferenceType, + PaymentIntentSnapshot, + ProviderMethod, ClientAction, - PaymentProvider, - ProviderStatus, ProviderPaymentStatus, - TelebirrProvider, - CbeBirrProvider, - EBirrProvider, - CardProvider, - WaafiProvider, - createMerchantOrderId, -} from '@edr/payment-providers'; +} from "@edr/types"; const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [ PaymentIntentStatus.REQUIRES_ACTION, @@ -27,37 +42,30 @@ const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [ @Injectable() export class PaymentsService { private readonly logger = new Logger(PaymentsService.name); - private readonly providers: Map; constructor( private prisma: PrismaService, private seatsService: SeatsService, private ticketsService: TicketsService, private eventEmitter: EventEmitter2, - private telebirrProvider: TelebirrProvider, - private cbeBirrProvider: CbeBirrProvider, - private eBirrProvider: EBirrProvider, - private cardProvider: CardProvider, - private waafiProvider: WaafiProvider, - ) { - this.providers = new Map([ - [PaymentMethodType.TELEBIRR, this.telebirrProvider], - [PaymentMethodType.CBE_BIRR, this.cbeBirrProvider], - [PaymentMethodType.EBIRR, this.eBirrProvider], - [PaymentMethodType.CARD, this.cardProvider], - [PaymentMethodType.WAAFI, this.waafiProvider], - ]); - } + private paymentClient: PaymentClientService, + ) {} - async getAll(filters: { search?: string; status?: string; method?: string; page?: number; pageSize?: number }) { + async getAll(filters: { + search?: string; + status?: string; + method?: string; + page?: number; + pageSize?: number; + }) { const { search, status, method, page = 1, pageSize = 10 } = filters; const skip = (page - 1) * pageSize; const where: any = {}; if (search) { where.OR = [ - { id: { contains: search, mode: 'insensitive' } }, - { booking: { bookingRef: { contains: search, mode: 'insensitive' } } }, + { id: { contains: search, mode: "insensitive" } }, + { booking: { bookingRef: { contains: search, mode: "insensitive" } } }, ]; } if (status) { @@ -73,13 +81,13 @@ export class PaymentsService { include: { booking: true }, skip, take: pageSize, - orderBy: { createdAt: 'desc' }, + orderBy: { createdAt: "desc" }, }), this.prisma.paymentIntent.count({ where }), ]); return { - items: items.map(item => ({ + items: items.map((item) => ({ id: item.id, reference: item.id.substring(0, 8), bookingId: item.bookingId, @@ -102,30 +110,87 @@ export class PaymentsService { where: { id: dto.bookingId }, include: { seats: true }, }); - if (!booking) throw new NotFoundException('Booking not found'); - if (booking.status !== 'PENDING_PAYMENT') { - throw new BadRequestException('Booking not payable'); - } - - const existing = await this.prisma.paymentIntent.findUnique({ - where: { bookingId: dto.bookingId }, - }); - if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) { - return this.formatIntentResponse(existing); + if (!booking) throw new NotFoundException("Booking not found"); + if (booking.status !== "PENDING_PAYMENT") { + throw new BadRequestException("Booking not payable"); } const method = dto.method as PaymentMethodType; + // WALLET is an internal balance debit — it never leaves this app. if (method === PaymentMethodType.WALLET) { + const existing = await this.prisma.paymentIntent.findUnique({ + where: { bookingId: dto.bookingId }, + }); + if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) { + return this.formatIntentResponse(existing); + } return this.initiateWalletPayment(booking); } - const provider = this.providers.get(method); - if (provider) { - return this.initiateProviderPayment(booking, provider, dto.platform); - } + // Provider methods go through the payment microservice (docs/payment-service §7.1): + // it owns the intent, the provider session, and the single webhook per provider. + // Re-initiating is safe — the service returns the existing active intent (idempotent). + const snapshot = await this.paymentClient.initiate({ + service: PaymentServiceEnum.PASSENGER, + referenceType: PaymentReferenceType.BOOKING, + referenceId: booking.id, + orderRef: booking.bookingRef, + amountMinor: booking.totalMinor, + currency: booking.currency, + provider: method as unknown as ProviderMethod, + platform: dto.platform, + // PASSENGER-owned browser bounce-back after the hosted page (freight passes its own). + // UX only — payment is confirmed by the webhook/mark-paid event, never this redirect. + returnUrl: process.env.PAYMENT_RETURN_URL || undefined, + failureUrl: process.env.PAYMENT_FAILURE_URL || undefined, + }); - throw new BadRequestException(`Unsupported payment method: ${method}`); + let intent = await this.syncIntentProjection(booking.id, snapshot); + if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) { + // Already-paid order re-initiated: converge the booking now (idempotent). + await this.finalizePaymentSuccess({ + intentId: intent.id, + providerTxnId: snapshot.providerTxnId, + paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined, + }); + intent = await this.prisma.paymentIntent.findUniqueOrThrow({ + where: { id: intent.id }, + }); + } + return this.formatIntentResponse(intent); + } + + private async syncIntentProjection( + bookingId: string, + snapshot: PaymentIntentSnapshot, + ) { + const status = + snapshot.status === ProviderPaymentStatus.SUCCEEDED + ? PaymentIntentStatus.PROCESSING + : (snapshot.status as unknown as PaymentIntentStatus); + const data = { + status, + method: snapshot.provider as unknown as PaymentMethodType, + merchantOrderId: snapshot.merchantOrderId, + clientAction: snapshot.clientAction + ? (snapshot.clientAction as unknown as Prisma.InputJsonValue) + : Prisma.DbNull, + providerTxnId: snapshot.providerTxnId ?? null, + expiresAt: snapshot.expiresAt ? new Date(snapshot.expiresAt) : null, + failureCode: snapshot.failureCode ?? null, + failureMessage: snapshot.failureMessage ?? null, + }; + return this.prisma.paymentIntent.upsert({ + where: { bookingId }, + update: data, + create: { + bookingId, + amountMinor: snapshot.amountMinor, + currency: snapshot.currency, + ...data, + }, + }); } private async initiateWalletPayment( @@ -146,7 +211,7 @@ export class PaymentsService { await tx.walletLedgerEntry.create({ data: { walletId: wallet.id, - type: 'DEBIT', + type: "DEBIT", amountMinor: booking.totalMinor, balanceAfterMinor: newBalance, description: `Train Ticket - ${booking.bookingRef}`, @@ -161,14 +226,14 @@ export class PaymentsService { where: { bookingId: booking.id }, update: { status: PaymentIntentStatus.FAILED, - failureCode: 'INSUFFICIENT_BALANCE', + failureCode: "INSUFFICIENT_BALANCE", }, create: { bookingId: booking.id, amountMinor: booking.totalMinor, method: PaymentMethodType.WALLET, status: PaymentIntentStatus.FAILED, - failureCode: 'INSUFFICIENT_BALANCE', + failureCode: "INSUFFICIENT_BALANCE", }, }); return this.formatIntentResponse(failed); @@ -192,55 +257,11 @@ export class PaymentsService { return this.formatIntentResponse(refreshed); } - private async initiateProviderPayment( - booking: Prisma.BookingGetPayload<{ include: { seats: true } }>, - provider: PaymentProvider, - platform: 'web' | 'mobile' | undefined, - ): Promise { - const merchantOrderId = createMerchantOrderId(); - const result = await provider.initiate({ - merchantOrderId, - orderRef: booking.bookingRef, - amountMinor: booking.totalMinor, - currency: booking.currency, - platform, - }); - - const providerMethod = provider.method as unknown as PaymentMethodType; - const intent = await this.prisma.paymentIntent.upsert({ - where: { bookingId: booking.id }, - update: { - status: PaymentIntentStatus.REQUIRES_ACTION, - method: providerMethod, - merchantOrderId, - providerOrderId: result.providerOrderId, - clientAction: result.clientAction as unknown as Prisma.InputJsonValue, - rawInitiation: result.rawInitiation as Prisma.InputJsonValue, - expiresAt: result.expiresAt, - failureCode: null, - failureMessage: null, - }, - create: { - bookingId: booking.id, - amountMinor: booking.totalMinor, - currency: booking.currency, - method: providerMethod, - status: PaymentIntentStatus.REQUIRES_ACTION, - merchantOrderId, - providerOrderId: result.providerOrderId, - clientAction: result.clientAction as unknown as Prisma.InputJsonValue, - rawInitiation: result.rawInitiation as Prisma.InputJsonValue, - expiresAt: result.expiresAt, - }, - }); - return this.formatIntentResponse(intent); - } - private formatIntentResponse( intent: Prisma.PaymentIntentGetPayload>, ): InitiateResponseDto { const clientAction = - intent.clientAction && typeof intent.clientAction === 'object' + intent.clientAction && typeof intent.clientAction === "object" ? (intent.clientAction as unknown as ClientAction) : undefined; return { @@ -252,65 +273,52 @@ export class PaymentsService { } async getIntentByBookingId(bookingId: string): Promise { - const intent = await this.prisma.paymentIntent.findUnique({ + const local = await this.prisma.paymentIntent.findUnique({ where: { bookingId }, }); - if (!intent) throw new NotFoundException('PaymentIntent not found'); - const refreshable = - intent.status === PaymentIntentStatus.REQUIRES_ACTION || - intent.status === PaymentIntentStatus.PROCESSING; - const stale = intent.updatedAt.getTime() < Date.now() - 5_000; - const provider = this.providers.get(intent.method); - - if (refreshable && stale && intent.merchantOrderId && provider) { - try { - const status = await provider.queryStatus(intent.merchantOrderId); - this.logger.log(status); - await this.applyProviderStatus(intent.id, status); - const refreshed = await this.prisma.paymentIntent.findUniqueOrThrow({ - where: { id: intent.id }, - }); - return this.formatIntentStatus(refreshed); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - this.logger.warn( - `queryStatus failed for intent ${intent.id}: ${message}; returning cached`, - ); - } + // WALLET payments never leave this app — no remote intent exists for them. + if (local?.method === PaymentMethodType.WALLET) { + return this.formatIntentStatus(local); } - return this.formatIntentStatus(intent); - } + // Pull/reconcile through the payment microservice (it refreshes stale intents from the + // provider itself). Falls back to the legacy local path when the service is unreachable + // or only a pre-cutover local intent exists. + let snapshot: PaymentIntentSnapshot | null = null; + try { + snapshot = await this.paymentClient.getIntentByReference( + PaymentReferenceType.BOOKING, + bookingId, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.warn( + `payment service lookup failed for booking ${bookingId}: ${message}; using local intent`, + ); + } - private async applyProviderStatus( - intentId: string, - status: ProviderStatus, - ): Promise { - const bizContent = (status.rawResponse as { biz_content?: { order_status?: string } }) - ?.biz_content; - if (bizContent?.order_status === 'PAY_SUCCESS') { + if (!snapshot) { + // Pre-cutover/local-only intent (or service briefly unreachable): serve the cached + // status. The payment service owns provider refresh for everything initiated after + // the cutover; webhooks/mark-paid converge the rest. + if (!local) throw new NotFoundException("PaymentIntent not found"); + return this.formatIntentStatus(local); + } + + let intent = await this.syncIntentProjection(bookingId, snapshot); + if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) { + // Poll observed success before (or instead of) the mark-paid event — converge now. await this.finalizePaymentSuccess({ - intentId, - providerTxnId: status.providerTxnId, + intentId: intent.id, + providerTxnId: snapshot.providerTxnId, + paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined, }); - return; - } - if (status.status === ProviderPaymentStatus.FAILED) { - await this.markPaymentFailed({ - intentId, - failureCode: status.failureCode, - failureMessage: status.failureMessage, + intent = await this.prisma.paymentIntent.findUniqueOrThrow({ + where: { id: intent.id }, }); - return; } - await this.prisma.paymentIntent.update({ - where: { id: intentId }, - data: { - status: status.status as unknown as PaymentIntentStatus, - providerTxnId: status.providerTxnId ?? undefined, - }, - }); + return this.formatIntentStatus(intent); } private formatIntentStatus( @@ -326,13 +334,25 @@ export class PaymentsService { } async refund(dto: RefundDto) { - const intent = await this.prisma.paymentIntent.findUnique({ where: { bookingId: dto.bookingId } }); - if (!intent || intent.status !== 'SUCCEEDED') throw new BadRequestException('No successful payment to refund'); - await this.prisma.paymentIntent.update({ where: { bookingId: dto.bookingId }, data: { status: 'CANCELLED' } }); - const booking = await this.prisma.booking.findUnique({ where: { id: dto.bookingId }, include: { seats: true } }); + const intent = await this.prisma.paymentIntent.findUnique({ + where: { bookingId: dto.bookingId }, + }); + if (!intent || intent.status !== "SUCCEEDED") + throw new BadRequestException("No successful payment to refund"); + await this.prisma.paymentIntent.update({ + where: { bookingId: dto.bookingId }, + data: { status: "CANCELLED" }, + }); + const booking = await this.prisma.booking.findUnique({ + where: { id: dto.bookingId }, + include: { seats: true }, + }); if (booking) { await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId)); - await this.prisma.booking.update({ where: { id: dto.bookingId }, data: { status: 'CANCELLED' } }); + await this.prisma.booking.update({ + where: { id: dto.bookingId }, + data: { status: "CANCELLED" }, + }); } return { refunded: true, bookingRef: booking?.bookingRef }; } @@ -342,7 +362,7 @@ export class PaymentsService { type: dto.type as unknown as PaymentMethodType, displayName: dto.displayName, region: dto.region as unknown as PaymentRegion, - currency: dto.currency ?? 'ETB', + currency: dto.currency ?? "ETB", providerId: dto.providerId, enabled: dto.enabled ?? true, sortOrder: dto.sortOrder ?? 0, @@ -359,10 +379,17 @@ export class PaymentsService { where: { enabled: true, ...(region - ? { region: { in: [region, PaymentRegionEnum.GLOBAL] as unknown as PaymentRegion[] } } + ? { + region: { + in: [ + region, + PaymentRegionEnum.GLOBAL, + ] as unknown as PaymentRegion[], + }, + } : {}), }, - orderBy: [{ sortOrder: 'asc' }, { displayName: 'asc' }], + orderBy: [{ sortOrder: "asc" }, { displayName: "asc" }], }); } @@ -374,19 +401,21 @@ export class PaymentsService { const intent = await this.prisma.paymentIntent.findUnique({ where: { id: input.intentId }, }); - if (!intent) throw new NotFoundException('PaymentIntent not found'); + if (!intent) throw new NotFoundException("PaymentIntent not found"); if (intent.status === PaymentIntentStatus.SUCCEEDED) { return { alreadyFinalized: true }; } if (intent.status === PaymentIntentStatus.CANCELLED) { - throw new BadRequestException('PaymentIntent is cancelled; cannot finalize'); + throw new BadRequestException( + "PaymentIntent is cancelled; cannot finalize", + ); } const booking = await this.prisma.booking.findUnique({ where: { id: intent.bookingId }, include: { seats: true }, }); - if (!booking) throw new NotFoundException('Booking not found'); + if (!booking) throw new NotFoundException("Booking not found"); const paidAt = input.paidAt ?? new Date(); await this.prisma.$transaction(async (tx) => { @@ -394,45 +423,134 @@ export class PaymentsService { where: { id: intent.id }, data: { status: PaymentIntentStatus.SUCCEEDED, - providerTxnId: input.providerTxnId ?? intent.providerTxnId ?? undefined, + providerTxnId: + input.providerTxnId ?? intent.providerTxnId ?? undefined, paidAt, }, }); await tx.booking.update({ where: { id: booking.id }, - data: { status: 'CONFIRMED' }, + data: { status: "CONFIRMED" }, }); }); try { await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId)); } catch (err) { - this.logger.error(`Error confirming seats: ${err instanceof Error ? err.message : String(err)}`); + this.logger.error( + `Error confirming seats: ${err instanceof Error ? err.message : String(err)}`, + ); } try { await this.createJourneySegments(booking); } catch (err) { - this.logger.error(`Error creating journey segments: ${err instanceof Error ? err.message : String(err)}`); + this.logger.error( + `Error creating journey segments: ${err instanceof Error ? err.message : String(err)}`, + ); } try { await this.ticketsService.generate(booking.id); } catch (err) { - this.logger.error(`Error generating ticket: ${err instanceof Error ? err.message : String(err)}`); + this.logger.error( + `Error generating ticket: ${err instanceof Error ? err.message : String(err)}`, + ); throw err; } try { - await this.awardLoyaltyPoints(booking.passengerId, booking.totalMinor, booking.id); + await this.awardLoyaltyPoints( + booking.passengerId, + booking.totalMinor, + booking.id, + ); } catch (err) { - this.logger.warn(`Error awarding loyalty points: ${err instanceof Error ? err.message : String(err)}`); + this.logger.warn( + `Error awarding loyalty points: ${err instanceof Error ? err.message : String(err)}`, + ); } - this.eventEmitter.emit('payment.succeeded', { booking }); + this.eventEmitter.emit("payment.succeeded", { booking }); return { alreadyFinalized: false }; } + async handlePaymentEvent( + event: PaymentEventDto, + ): Promise { + if ( + event.service !== PaymentServiceEnum.PASSENGER || + event.referenceType !== PaymentReferenceType.BOOKING + ) { + this.logger.warn( + `mark-paid: ignoring foreign reference ${event.service}/${event.referenceType}/${event.referenceId}`, + ); + return { processed: false, reason: "foreign-reference" }; + } + + if (event.eventType === "payment.failed") { + const intent = await this.prisma.paymentIntent.findUnique({ + where: { bookingId: event.referenceId }, + }); + if (intent) { + await this.markPaymentFailed({ + intentId: intent.id, + failureCode: event.failureCode, + failureMessage: event.failureMessage, + }); + } + return { processed: true }; + } + + const booking = await this.prisma.booking.findUnique({ + where: { id: event.referenceId }, + }); + if (!booking) { + // Ack (200) — a missing booking will not appear on redelivery; needs investigation. + this.logger.error( + `mark-paid: no booking for reference ${event.referenceId}`, + ); + return { processed: false, reason: "booking-not-found" }; + } + + if (booking.totalMinor !== event.amountMinor) { + // Refuse to confirm: a 4xx makes the relay retry and eventually flag the row FAILED, + // which is the alertable signal for an asserted-vs-paid amount divergence. + this.logger.error( + `mark-paid: amount mismatch for booking ${booking.id}: booking=${booking.totalMinor} event=${event.amountMinor}`, + ); + throw new BadRequestException( + "Event amount does not match booking total", + ); + } + + // Local intent row is a projection during the strangler migration: reuse it when the + // legacy initiate path created one, otherwise materialize it from the event. + let intent = await this.prisma.paymentIntent.findUnique({ + where: { bookingId: event.referenceId }, + }); + if (!intent) { + intent = await this.prisma.paymentIntent.create({ + data: { + bookingId: event.referenceId, + amountMinor: event.amountMinor, + currency: event.currency, + method: event.provider as unknown as PaymentMethodType, + status: PaymentIntentStatus.PROCESSING, + merchantOrderId: event.merchantOrderId, + providerTxnId: event.providerTxnId, + }, + }); + } + + const { alreadyFinalized } = await this.finalizePaymentSuccess({ + intentId: intent.id, + providerTxnId: event.providerTxnId, + paidAt: event.paidAt ? new Date(event.paidAt) : undefined, + }); + return { processed: true, alreadyFinalized }; + } + async markPaymentFailed(input: { intentId: string; failureCode?: string; @@ -441,7 +559,7 @@ export class PaymentsService { const intent = await this.prisma.paymentIntent.findUnique({ where: { id: input.intentId }, }); - if (!intent) throw new NotFoundException('PaymentIntent not found'); + if (!intent) throw new NotFoundException("PaymentIntent not found"); if ( intent.status === PaymentIntentStatus.SUCCEEDED || intent.status === PaymentIntentStatus.CANCELLED @@ -458,35 +576,72 @@ export class PaymentsService { }); } - private async awardLoyaltyPoints(passengerId: string, amountMinor: number, bookingId: string) { + private async awardLoyaltyPoints( + passengerId: string, + amountMinor: number, + bookingId: string, + ) { const points = Math.floor(amountMinor / 100); - const account = await this.prisma.loyaltyAccount.findUnique({ where: { passengerId } }); + const account = await this.prisma.loyaltyAccount.findUnique({ + where: { passengerId }, + }); if (!account) return; const newBalance = account.pointsBalance + points; - const tier = newBalance >= 10000 ? 'PLATINUM' : newBalance >= 5000 ? 'GOLD' : newBalance >= 2000 ? 'SILVER' : 'BRONZE'; - await this.prisma.loyaltyAccount.update({ where: { passengerId }, data: { pointsBalance: { increment: points }, tier: tier as any } }); - await this.prisma.loyaltyLedgerEntry.create({ data: { accountId: account.id, delta: points, reason: 'TRIP_COMPLETED', bookingId, balanceAfter: newBalance } }); + const tier = + newBalance >= 10000 + ? "PLATINUM" + : newBalance >= 5000 + ? "GOLD" + : newBalance >= 2000 + ? "SILVER" + : "BRONZE"; + await this.prisma.loyaltyAccount.update({ + where: { passengerId }, + data: { pointsBalance: { increment: points }, tier: tier as any }, + }); + await this.prisma.loyaltyLedgerEntry.create({ + data: { + accountId: account.id, + delta: points, + reason: "TRIP_COMPLETED", + bookingId, + balanceAfter: newBalance, + }, + }); } - private async createJourneySegments(booking: Prisma.BookingGetPayload<{ include: { seats: true } }>) { + private async createJourneySegments( + booking: Prisma.BookingGetPayload<{ include: { seats: true } }>, + ) { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: booking.scheduleId }, - include: { stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }, + include: { + stopTimes: { include: { station: true }, orderBy: { sequence: "asc" } }, + }, }); if (!schedule) return; const stopTimes = schedule.stopTimes; if (stopTimes.length < 2) return; - const originSequence = stopTimes.findIndex(st => st.stationId === schedule.originStationId); - const destSequence = stopTimes.findIndex(st => st.stationId === schedule.destinationStationId); + const originSequence = stopTimes.findIndex( + (st) => st.stationId === schedule.originStationId, + ); + const destSequence = stopTimes.findIndex( + (st) => st.stationId === schedule.destinationStationId, + ); - if (originSequence < 0 || destSequence < 0 || originSequence >= destSequence) return; + if ( + originSequence < 0 || + destSequence < 0 || + originSequence >= destSequence + ) + return; const journey = await this.prisma.journey.create({ data: { passengerId: booking.passengerId, - status: 'CONFIRMED', + status: "CONFIRMED", totalMinor: booking.totalMinor, currency: booking.currency, }, diff --git a/apps/edr-passenger-api/src/modules/payments/payments.types.ts b/apps/edr-passenger-api/src/modules/payments/payments.types.ts index 686274065..2e9463ad3 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.types.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.types.ts @@ -1,5 +1,6 @@ -// The payment provider contract now lives in @edr/types (consumed via @edr/payment-providers). -// This file remains as a thin re-export so existing local imports keep working. +// The payment provider contract lives in @edr/types; the gateways themselves now run only +// inside apps/edr-payment-api. This file remains as a thin re-export so existing local +// imports keep working. export type { PaymentProvider, ProviderInitiationInput, @@ -7,5 +8,5 @@ export type { ProviderStatus, ClientAction, PaymentPlatform, -} from '@edr/types'; -export { ProviderPaymentStatus, ProviderMethod } from '@edr/types'; +} from "@edr/types"; +export { ProviderPaymentStatus, ProviderMethod } from "@edr/types"; diff --git a/apps/edr-passenger-api/src/modules/payments/webhooks/card-webhook.service.ts b/apps/edr-passenger-api/src/modules/payments/webhooks/card-webhook.service.ts deleted file mode 100644 index 5bf977107..000000000 --- a/apps/edr-passenger-api/src/modules/payments/webhooks/card-webhook.service.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client'; -import { - CardProvider, - CardWebhookPayload, - ProviderPaymentStatus, -} from '@edr/payment-providers'; -import { PrismaService } from '../../../common/prisma.service'; -import { PaymentsService } from '../payments.service'; - -@Injectable() -export class CardWebhookService { - private readonly logger = new Logger(CardWebhookService.name); - - constructor( - private readonly prisma: PrismaService, - private readonly provider: CardProvider, - private readonly payments: PaymentsService, - ) {} - - async handle(payload: CardWebhookPayload, signature: string): Promise { - const merchantOrderId = payload.data.object.metadata.merchantOrderId; - const externalEventId = `${payload.id}_${payload.type}`; - const signatureValid = this.provider.verifyWebhookSignature( - payload as unknown as Record, - signature, - ); - - const eventRow = await this.persistEvent({ - externalEventId, - merchantOrderId, - providerTxnId: payload.data.object.transaction_id, - signatureValid, - status: payload.data.object.status, - payload, - }); - - if (!eventRow) { - this.logger.log(`Card webhook duplicate: ${externalEventId} — short-circuit OK`); - return; - } - - if (!signatureValid) { - this.logger.warn(`Card webhook signature invalid for merchantOrderId=${merchantOrderId}`); - await this.markProcessed(eventRow.id, 'signature-invalid'); - return; - } - - const intent = await this.prisma.paymentIntent.findUnique({ - where: { merchantOrderId }, - }); - if (!intent) { - this.logger.warn(`Card webhook: no PaymentIntent for merchantOrderId=${merchantOrderId}`); - await this.markProcessed(eventRow.id, 'intent-not-found'); - return; - } - - const mapped = this.provider.mapWebhookStatus(payload.data.object.status); - - try { - if (mapped === ProviderPaymentStatus.SUCCEEDED) { - await this.payments.finalizePaymentSuccess({ - intentId: intent.id, - providerTxnId: payload.data.object.transaction_id, - paidAt: payload.data.object.paid_at ? new Date(payload.data.object.paid_at * 1000) : undefined, - }); - } else if (mapped === ProviderPaymentStatus.FAILED) { - await this.payments.markPaymentFailed({ - intentId: intent.id, - failureCode: payload.data.object.failure_code, - failureMessage: payload.data.object.failure_message, - }); - } else { - await this.prisma.paymentIntent.update({ - where: { id: intent.id }, - data: { - status: mapped as unknown as PaymentIntentStatus, - providerTxnId: payload.data.object.transaction_id ?? undefined, - }, - }); - } - await this.markProcessed(eventRow.id); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - this.logger.error(`Card webhook processing failed for ${merchantOrderId}: ${message}`); - await this.markProcessed(eventRow.id, `processing-error: ${message}`); - throw err; - } - } - - private async persistEvent(input: { - externalEventId: string; - merchantOrderId: string; - providerTxnId?: string; - signatureValid: boolean; - status: string; - payload: CardWebhookPayload; - }): Promise<{ id: string } | null> { - try { - return await this.prisma.paymentWebhookEvent.create({ - data: { - provider: PaymentMethodType.CARD, - 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 }, - }); - } -} diff --git a/apps/edr-passenger-api/src/modules/payments/webhooks/cbe-birr-webhook.service.ts b/apps/edr-passenger-api/src/modules/payments/webhooks/cbe-birr-webhook.service.ts deleted file mode 100644 index 42502e941..000000000 --- a/apps/edr-passenger-api/src/modules/payments/webhooks/cbe-birr-webhook.service.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client'; -import { - CbeBirrProvider, - CbeBirrWebhookPayload, - ProviderPaymentStatus, -} from '@edr/payment-providers'; -import { PrismaService } from '../../../common/prisma.service'; -import { PaymentsService } from '../payments.service'; - -@Injectable() -export class CbeBirrWebhookService { - private readonly logger = new Logger(CbeBirrWebhookService.name); - - constructor( - private readonly prisma: PrismaService, - private readonly provider: CbeBirrProvider, - private readonly payments: PaymentsService, - ) {} - - async handle(payload: CbeBirrWebhookPayload): Promise { - const merchantOrderId = payload.merchantOrderId; - const externalEventId = `${payload.orderId}_${payload.status}`; - const signatureValid = this.provider.verifyWebhookSignature( - payload as unknown as Record, - ); - - const eventRow = await this.persistEvent({ - externalEventId, - merchantOrderId, - providerTxnId: payload.transactionId ?? payload.orderId, - signatureValid, - status: payload.status, - payload, - }); - - if (!eventRow) { - this.logger.log(`CBE Birr webhook duplicate: ${externalEventId} — short-circuit OK`); - return; - } - - if (!signatureValid) { - this.logger.warn(`CBE Birr webhook signature invalid for merchantOrderId=${merchantOrderId}`); - await this.markProcessed(eventRow.id, 'signature-invalid'); - return; - } - - const intent = await this.prisma.paymentIntent.findUnique({ - where: { merchantOrderId }, - }); - if (!intent) { - this.logger.warn(`CBE Birr webhook: no PaymentIntent for merchantOrderId=${merchantOrderId}`); - await this.markProcessed(eventRow.id, 'intent-not-found'); - return; - } - - const mapped = this.provider.mapWebhookStatus(payload.status); - - try { - if (mapped === ProviderPaymentStatus.SUCCEEDED) { - await this.payments.finalizePaymentSuccess({ - intentId: intent.id, - providerTxnId: payload.transactionId ?? payload.orderId, - paidAt: payload.paidAt ? new Date(payload.paidAt) : undefined, - }); - } else if (mapped === ProviderPaymentStatus.FAILED) { - await this.payments.markPaymentFailed({ - intentId: intent.id, - failureCode: payload.status, - }); - } else { - await this.prisma.paymentIntent.update({ - where: { id: intent.id }, - data: { - status: mapped as unknown as PaymentIntentStatus, - providerTxnId: payload.transactionId ?? undefined, - }, - }); - } - await this.markProcessed(eventRow.id); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - this.logger.error(`CBE Birr webhook processing failed for ${merchantOrderId}: ${message}`); - await this.markProcessed(eventRow.id, `processing-error: ${message}`); - throw err; - } - } - - private async persistEvent(input: { - externalEventId: string; - merchantOrderId: string; - providerTxnId?: string; - signatureValid: boolean; - status: string; - payload: CbeBirrWebhookPayload; - }): Promise<{ id: string } | null> { - try { - return await this.prisma.paymentWebhookEvent.create({ - data: { - provider: PaymentMethodType.CBE_BIRR, - 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 }, - }); - } -} diff --git a/apps/edr-passenger-api/src/modules/payments/webhooks/ebirr-webhook.service.ts b/apps/edr-passenger-api/src/modules/payments/webhooks/ebirr-webhook.service.ts deleted file mode 100644 index ace727a2a..000000000 --- a/apps/edr-passenger-api/src/modules/payments/webhooks/ebirr-webhook.service.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client'; -import { - EBirrProvider, - EBirrWebhookPayload, - ProviderPaymentStatus, -} from '@edr/payment-providers'; -import { PrismaService } from '../../../common/prisma.service'; -import { PaymentsService } from '../payments.service'; - -@Injectable() -export class EBirrWebhookService { - private readonly logger = new Logger(EBirrWebhookService.name); - - constructor( - private readonly prisma: PrismaService, - private readonly provider: EBirrProvider, - private readonly payments: PaymentsService, - ) {} - - async handle(payload: EBirrWebhookPayload): Promise { - const merchantOrderId = payload.orderNo; - const externalEventId = `${payload.orderNo}_${payload.tradeStatus}_${payload.timestamp}`; - const signatureValid = this.provider.verifyWebhookSignature( - payload as unknown as Record, - ); - - const eventRow = await this.persistEvent({ - externalEventId, - merchantOrderId, - providerTxnId: payload.tradeNo, - signatureValid, - status: payload.tradeStatus, - payload, - }); - - if (!eventRow) { - this.logger.log(`eBirr webhook duplicate: ${externalEventId} — short-circuit OK`); - return; - } - - if (!signatureValid) { - this.logger.warn(`eBirr webhook signature invalid for orderNo=${merchantOrderId}`); - await this.markProcessed(eventRow.id, 'signature-invalid'); - return; - } - - const intent = await this.prisma.paymentIntent.findUnique({ - where: { merchantOrderId }, - }); - if (!intent) { - this.logger.warn(`eBirr webhook: no PaymentIntent for orderNo=${merchantOrderId}`); - await this.markProcessed(eventRow.id, 'intent-not-found'); - return; - } - - const mapped = this.provider.mapWebhookStatus(payload.tradeStatus); - - try { - if (mapped === ProviderPaymentStatus.SUCCEEDED) { - await this.payments.finalizePaymentSuccess({ - intentId: intent.id, - providerTxnId: payload.tradeNo, - paidAt: payload.payTime ? new Date(payload.payTime) : undefined, - }); - } else if (mapped === ProviderPaymentStatus.FAILED) { - await this.payments.markPaymentFailed({ - intentId: intent.id, - failureCode: payload.tradeStatus, - }); - } else { - await this.prisma.paymentIntent.update({ - where: { id: intent.id }, - data: { - status: mapped as unknown as PaymentIntentStatus, - providerTxnId: payload.tradeNo ?? undefined, - }, - }); - } - await this.markProcessed(eventRow.id); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - this.logger.error(`eBirr webhook processing failed for ${merchantOrderId}: ${message}`); - await this.markProcessed(eventRow.id, `processing-error: ${message}`); - throw err; - } - } - - private async persistEvent(input: { - externalEventId: string; - merchantOrderId: string; - providerTxnId?: string; - signatureValid: boolean; - status: string; - payload: EBirrWebhookPayload; - }): Promise<{ id: string } | null> { - try { - return await this.prisma.paymentWebhookEvent.create({ - data: { - provider: PaymentMethodType.EBIRR, - 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 }, - }); - } -} diff --git a/apps/edr-passenger-api/src/modules/payments/webhooks/telebirr-webhook.service.ts b/apps/edr-passenger-api/src/modules/payments/webhooks/telebirr-webhook.service.ts deleted file mode 100644 index 1f2b06c3e..000000000 --- a/apps/edr-passenger-api/src/modules/payments/webhooks/telebirr-webhook.service.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client'; -import { - TelebirrProvider, - TelebirrWebhookPayload, - ProviderPaymentStatus, -} from '@edr/payment-providers'; -import { PrismaService } from '../../../common/prisma.service'; -import { PaymentsService } from '../payments.service'; - -@Injectable() -export class TelebirrWebhookService { - private readonly logger = new Logger(TelebirrWebhookService.name); - - constructor( - private readonly prisma: PrismaService, - private readonly provider: TelebirrProvider, - private readonly payments: PaymentsService, - ) {} - - async handle(payload: TelebirrWebhookPayload): Promise { - const merchantOrderId = payload.merch_order_id; - const externalEventId = this.buildExternalEventId(payload); - // TODO: re-enable Telebirr public-key signature verification — skipped for now - // const signatureValid = this.provider.verifyWebhookSignature( - // payload as unknown as Record, - // ); - const signatureValid = true; - - const eventRow = await this.persistEvent({ - externalEventId, - merchantOrderId, - providerTxnId: payload.trans_id ?? payload.payment_order_id, - signatureValid, - status: payload.trade_status, - payload, - }); - - if (!eventRow) { - this.logger.log( - `Telebirr webhook duplicate: ${externalEventId} — short-circuit OK`, - ); - return; - } - - // TODO: re-enable signature gate once verifyWebhookSignature is restored - // if (!signatureValid) { - // this.logger.warn( - // `Telebirr webhook signature invalid for merch_order_id=${merchantOrderId}`, - // ); - // await this.markProcessed(eventRow.id, 'signature-invalid'); - // return; - // } - - const intent = await this.prisma.paymentIntent.findUnique({ - where: { merchantOrderId }, - }); - if (!intent) { - this.logger.warn( - `Telebirr webhook: no PaymentIntent for merch_order_id=${merchantOrderId}`, - ); - await this.markProcessed(eventRow.id, 'intent-not-found'); - return; - } - - const mapped = this.provider.mapWebhookTradeStatus(payload.trade_status); - - try { - if (mapped === ProviderPaymentStatus.SUCCEEDED) { - await this.payments.finalizePaymentSuccess({ - intentId: intent.id, - providerTxnId: payload.trans_id ?? payload.payment_order_id, - paidAt: this.parseEpochSeconds(payload.trans_end_time), - }); - } else if (mapped === ProviderPaymentStatus.FAILED) { - await this.payments.markPaymentFailed({ - intentId: intent.id, - failureCode: payload.trade_status, - }); - } else { - await this.prisma.paymentIntent.update({ - where: { id: intent.id }, - data: { - status: mapped as unknown as PaymentIntentStatus, - providerTxnId: payload.trans_id ?? undefined, - }, - }); - } - await this.markProcessed(eventRow.id); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - this.logger.error( - `Telebirr webhook processing failed for ${merchantOrderId}: ${message}`, - ); - await this.markProcessed(eventRow.id, `processing-error: ${message}`); - throw err; - } - } - - private buildExternalEventId(payload: TelebirrWebhookPayload): string { - return `${payload.payment_order_id}_${payload.trade_status}`; - } - - private async persistEvent(input: { - externalEventId: string; - merchantOrderId: string; - providerTxnId?: string; - signatureValid: boolean; - status: string; - payload: TelebirrWebhookPayload; - }): Promise<{ id: string } | null> { - try { - return await this.prisma.paymentWebhookEvent.create({ - data: { - provider: PaymentMethodType.TELEBIRR, - 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 }, - }); - } - - private parseEpochSeconds(raw: string | undefined): Date | undefined { - if (!raw) return undefined; - const n = parseInt(raw, 10); - if (Number.isNaN(n)) return undefined; - return new Date(n * 1000); - } -} 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 deleted file mode 100644 index 3b9110920..000000000 --- a/apps/edr-passenger-api/src/modules/payments/webhooks/waafi-webhook.service.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client'; -import { - WaafiProvider, - WaafiWebhookPayload, - WaafiWebhookHeaders, - WaafiWebhookTransactionPayload, - ProviderPaymentStatus, -} from '@edr/payment-providers'; -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 readonly prisma: PrismaService, - private readonly provider: WaafiProvider, - private readonly payments: PaymentsService, - ) {} - - async handleWebhook( - payload: WaafiWebhookPayload, - rawBody: string, - headers: WaafiWebhookHeaders, - ): Promise<{ received: boolean }> { - - 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 { 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']; - - 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/stale for ref=${merchantOrderId}`); - await this.markProcessed(eventRow.id, 'signature-invalid'); - return { received: true }; - } - - const intent = await this.prisma.paymentIntent.findUnique({ - where: { merchantOrderId }, - }); - if (!intent) { - this.logger.warn(`Waafi webhook: no PaymentIntent for ref=${merchantOrderId}`); - await this.markProcessed(eventRow.id, 'intent-not-found'); - return { received: true }; - } - - const mapped = this.provider.mapWebhookStatus(payment.status); - - 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 deleted file mode 100644 index 4e56c7c04..000000000 --- a/apps/edr-passenger-api/src/modules/payments/webhooks/webhooks.controller.ts +++ /dev/null @@ -1,126 +0,0 @@ -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'; -import { EBirrWebhookService } from './ebirr-webhook.service'; -import { CardWebhookService } from './card-webhook.service'; -import { WaafiWebhookService } from './waafi-webhook.service'; - -@ApiTags('Payment Webhooks') -@Controller('payments/webhooks') -export class WebhooksController { - private readonly logger = new Logger(WebhooksController.name); - - constructor( - private readonly telebirr: TelebirrWebhookService, - private readonly cbeBirr: CbeBirrWebhookService, - private readonly eBirr: EBirrWebhookService, - private readonly card: CardWebhookService, - private readonly waafi: WaafiWebhookService, - ) {} - - @All('telebirr') - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: 'Telebirr payment notification callback (Ethiopia)', - description: 'Webhook endpoint for Telebirr payment status updates. Used by Ethiopian passengers.' - }) - async receiveTelebirr(@Body() payload: TelebirrWebhookPayload) { - - this.logger.log( - `Telebirr webhook Called`, - ); - - try { - await this.telebirr.handle(payload); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - this.logger.error(`Telebirr webhook handler threw: ${message}`); - } - return { code: '0', message: 'OK' }; - } - - @Post('cbe-birr') - @HttpCode(HttpStatus.OK) - @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); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - this.logger.error(`CBE Birr webhook handler threw: ${message}`); - } - return { success: true }; - } - - @Post('ebirr') - @HttpCode(HttpStatus.OK) - @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); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - this.logger.error(`eBirr webhook handler threw: ${message}`); - } - return { code: '0000', message: 'success' }; - } - - @Post('card') - @HttpCode(HttpStatus.OK) - @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, - ) { - try { - await this.card.handle(payload, signature); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - this.logger.error(`Card webhook handler threw: ${message}`); - } - 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: 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 { - // 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}`); - } - return { responseCode: '2001', responseMsg: 'Success' }; - } -} diff --git a/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts b/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts index 20465fc0e..13a2f71a4 100644 --- a/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts +++ b/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts @@ -75,6 +75,25 @@ export class InitiatePaymentRequestDto implements InitiatePaymentRequest { @MaxLength(32) payerAccount?: string; + @ApiPropertyOptional({ + description: + "Per-transaction browser return URL on success — each calling app passes its own UI " + + "(passenger portal vs freight portal). UX only; never confirms payment. Falls back to " + + "the provider config when omitted.", + }) + @IsOptional() + @IsString() + @MaxLength(2048) + returnUrl?: string; + + @ApiPropertyOptional({ + description: "Failure/cancel counterpart of returnUrl", + }) + @IsOptional() + @IsString() + @MaxLength(2048) + failureUrl?: string; + @ApiPropertyOptional({ description: "Caller key to dedupe retried initiations", }) diff --git a/apps/edr-payment-api/src/modules/intents/intents.service.ts b/apps/edr-payment-api/src/modules/intents/intents.service.ts index faec489f9..492a86b78 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.service.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.service.ts @@ -9,7 +9,6 @@ import { DataSource, QueryFailedError } from "typeorm"; import { createMerchantOrderId } from "@edr/payment-providers"; import { InitiatePaymentRequest, - MERCHANT_ORDER_PREFIX, PaymentIntentSnapshot, PaymentReferenceType, PaymentService, @@ -60,6 +59,7 @@ export class IntentsService { async initiate( request: InitiatePaymentRequest, ): Promise { + if (request.idempotencyKey) { const byKey = await this.intentsRepository.findByIdempotencyKey( request.service, @@ -85,7 +85,7 @@ export class IntentsService { ); } - const merchantOrderId = `${MERCHANT_ORDER_PREFIX[request.service]}${createMerchantOrderId()}`; + const merchantOrderId = createMerchantOrderId(); const result = await provider.initiate({ merchantOrderId, orderRef: request.orderRef ?? request.referenceId, @@ -93,6 +93,9 @@ export class IntentsService { currency: request.currency, platform: request.platform, payerAccount: request.payerAccount, + returnUrl: request.returnUrl, + redirectUrl: request.returnUrl, + failureUrl: request.failureUrl, }); try { @@ -116,8 +119,6 @@ export class IntentsService { ); return this.toSnapshot(intent); } catch (err) { - // Concurrent initiate for the same reference lost the partial-unique race — return the - // winner's intent. The provider session we just opened is simply abandoned. if ( err instanceof QueryFailedError && (err.driverError as { code?: string })?.code === PG_UNIQUE_VIOLATION diff --git a/apps/edr-payment-api/src/modules/webhooks/webhook-processor.service.ts b/apps/edr-payment-api/src/modules/webhooks/webhook-processor.service.ts index 6be66b705..b8b54579a 100644 --- a/apps/edr-payment-api/src/modules/webhooks/webhook-processor.service.ts +++ b/apps/edr-payment-api/src/modules/webhooks/webhook-processor.service.ts @@ -1,9 +1,5 @@ import { Injectable, Logger } from "@nestjs/common"; -import { - MERCHANT_ORDER_PREFIX, - PaymentService, - ProviderMethod, -} from "@edr/types"; +import { ProviderMethod } from "@edr/types"; import { IntentsRepository } from "../intents/intents.repository"; import { IntentsService, @@ -81,20 +77,6 @@ export class WebhookProcessorService { return; } - // Integrity guard (§10): the stateless prefix and the stored discriminator must agree. - const expectedPrefix = - MERCHANT_ORDER_PREFIX[intent.service as PaymentService]; - if (expectedPrefix && !merchantOrderId.startsWith(expectedPrefix)) { - this.logger.error( - `${provider} webhook: merchantOrderId ${merchantOrderId} prefix does not match stored service ${intent.service} — refusing to process`, - ); - await this.webhookEvents.markProcessed( - eventRow.id, - "service-prefix-mismatch", - ); - return; - } - try { await this.intentsService.applyProviderResult(intent.id, webhook.result); await this.webhookEvents.markProcessed(eventRow.id); diff --git a/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts b/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts index 95ca51adc..8f1d1bd9e 100644 --- a/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts +++ b/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts @@ -112,6 +112,8 @@ export class WebhooksController { @Headers() headers: WaafiWebhookHeaders, @Req() req: { rawBody?: Buffer }, ) { + + this.logger.log("\n\n\n\nWaafi payment notification callback (Djibouti)\n\n\n\n"); this.logger.log( `Waafi webhook hit: event=${payload?.event ?? "unknown"} eventId=${headers["x-webhook-event-id"] ?? "n/a"}`, ); diff --git a/packages/payment-providers/src/providers/card/card.provider.ts b/packages/payment-providers/src/providers/card/card.provider.ts index c296f39a3..eaa9aed46 100644 --- a/packages/payment-providers/src/providers/card/card.provider.ts +++ b/packages/payment-providers/src/providers/card/card.provider.ts @@ -1,6 +1,6 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { HttpService } from '@nestjs/axios'; +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { HttpService } from "@nestjs/axios"; import { PaymentProvider, ProviderInitiationInput, @@ -8,10 +8,10 @@ import { ProviderStatus, ProviderPaymentStatus, ProviderMethod, -} from '@edr/types'; -import { AxiosError, AxiosRequestConfig } from 'axios'; -import { firstValueFrom } from 'rxjs'; -import * as crypto from 'node:crypto'; +} from "@edr/types"; +import { AxiosError, AxiosRequestConfig } from "axios"; +import { firstValueFrom } from "rxjs"; +import * as crypto from "node:crypto"; interface CardInitiateRequest { amount: number; @@ -54,7 +54,9 @@ export class CardProvider implements PaymentProvider { private readonly http: HttpService, ) {} - async initiate(input: ProviderInitiationInput): Promise { + async initiate( + input: ProviderInitiationInput, + ): Promise { const amount = input.amountMinor / 100; const requestBody: CardInitiateRequest = { @@ -65,7 +67,8 @@ export class CardProvider implements PaymentProvider { merchantOrderId: input.merchantOrderId, orderRef: input.orderRef, }, - return_url: this.returnUrl, + // Per-transaction browser return target (each calling app has its own UI); config is fallback. + return_url: input.returnUrl ?? this.returnUrl, webhook_url: this.webhookUrl, }; @@ -75,14 +78,16 @@ export class CardProvider implements PaymentProvider { ); if (!response.id) { - throw new Error(`Card gateway initiate failed: ${JSON.stringify(response)}`); + throw new Error( + `Card gateway initiate failed: ${JSON.stringify(response)}`, + ); } const expiresAt = new Date(response.expires_at * 1000); return { providerOrderId: response.id, - clientAction: { type: 'REDIRECT', url: response.checkout_url }, + clientAction: { type: "REDIRECT", url: response.checkout_url }, expiresAt, rawInitiation: { request: requestBody, @@ -109,12 +114,15 @@ export class CardProvider implements PaymentProvider { }; } - verifyWebhookSignature(payload: Record, signature: string): boolean { + verifyWebhookSignature( + payload: Record, + signature: string, + ): boolean { const payloadString = JSON.stringify(payload); const expectedSignature = crypto - .createHmac('sha256', this.webhookSecret) + .createHmac("sha256", this.webhookSecret) .update(payloadString) - .digest('hex'); + .digest("hex"); try { return crypto.timingSafeEqual( @@ -132,18 +140,18 @@ export class CardProvider implements PaymentProvider { private mapStatus(status: string): ProviderPaymentStatus { switch (status?.toLowerCase()) { - case 'succeeded': - case 'paid': + case "succeeded": + case "paid": return ProviderPaymentStatus.SUCCEEDED; - case 'failed': - case 'canceled': - case 'expired': + case "failed": + case "canceled": + case "expired": return ProviderPaymentStatus.FAILED; - case 'requires_payment_method': - case 'requires_confirmation': - case 'requires_action': + case "requires_payment_method": + case "requires_confirmation": + case "requires_action": return ProviderPaymentStatus.REQUIRES_ACTION; - case 'processing': + case "processing": return ProviderPaymentStatus.PROCESSING; default: return ProviderPaymentStatus.PROCESSING; @@ -153,8 +161,8 @@ export class CardProvider implements PaymentProvider { private async postJson(url: string, body: unknown): Promise { const config: AxiosRequestConfig = { headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + Authorization: `Bearer ${this.apiKey}`, }, timeout: 10_000, }; @@ -162,7 +170,9 @@ export class CardProvider implements PaymentProvider { const started = Date.now(); try { const res = await firstValueFrom(this.http.post(url, body, config)); - this.logger.debug(`Card Gateway POST ${url} status=${res.status} latency=${Date.now() - started}ms`); + this.logger.debug( + `Card Gateway POST ${url} status=${res.status} latency=${Date.now() - started}ms`, + ); return res.data; } catch (err) { if (err instanceof AxiosError) { @@ -170,7 +180,9 @@ export class CardProvider implements PaymentProvider { `Card Gateway POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`, ); } else { - this.logger.error(`Card Gateway POST ${url} threw: ${err instanceof Error ? err.message : err}`); + this.logger.error( + `Card Gateway POST ${url} threw: ${err instanceof Error ? err.message : err}`, + ); } throw err; } @@ -179,7 +191,7 @@ export class CardProvider implements PaymentProvider { private async getJson(url: string): Promise { const config: AxiosRequestConfig = { headers: { - 'Authorization': `Bearer ${this.apiKey}`, + Authorization: `Bearer ${this.apiKey}`, }, timeout: 10_000, }; @@ -187,7 +199,9 @@ export class CardProvider implements PaymentProvider { const started = Date.now(); try { const res = await firstValueFrom(this.http.get(url, config)); - this.logger.debug(`Card Gateway GET ${url} status=${res.status} latency=${Date.now() - started}ms`); + this.logger.debug( + `Card Gateway GET ${url} status=${res.status} latency=${Date.now() - started}ms`, + ); return res.data; } catch (err) { if (err instanceof AxiosError) { @@ -195,25 +209,27 @@ export class CardProvider implements PaymentProvider { `Card Gateway GET ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`, ); } else { - this.logger.error(`Card Gateway GET ${url} threw: ${err instanceof Error ? err.message : err}`); + this.logger.error( + `Card Gateway GET ${url} threw: ${err instanceof Error ? err.message : err}`, + ); } throw err; } } private get baseUrl(): string { - return this.config.get('card.baseUrl') ?? ''; + return this.config.get("card.baseUrl") ?? ""; } private get apiKey(): string { - return this.config.get('card.apiKey') ?? ''; + return this.config.get("card.apiKey") ?? ""; } private get webhookSecret(): string { - return this.config.get('card.webhookSecret') ?? ''; + return this.config.get("card.webhookSecret") ?? ""; } private get webhookUrl(): string { - return this.config.get('card.webhookUrl') ?? ''; + return this.config.get("card.webhookUrl") ?? ""; } private get returnUrl(): string { - return this.config.get('card.returnUrl') ?? ''; + return this.config.get("card.returnUrl") ?? ""; } } diff --git a/packages/payment-providers/src/providers/cbe-birr/cbe-birr.provider.ts b/packages/payment-providers/src/providers/cbe-birr/cbe-birr.provider.ts index ebce5caba..2f3303374 100644 --- a/packages/payment-providers/src/providers/cbe-birr/cbe-birr.provider.ts +++ b/packages/payment-providers/src/providers/cbe-birr/cbe-birr.provider.ts @@ -1,6 +1,6 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { HttpService } from '@nestjs/axios'; +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { HttpService } from "@nestjs/axios"; import { PaymentProvider, ProviderInitiationInput, @@ -8,10 +8,10 @@ import { ProviderStatus, ProviderPaymentStatus, ProviderMethod, -} from '@edr/types'; -import { AxiosError, AxiosRequestConfig } from 'axios'; -import { firstValueFrom } from 'rxjs'; -import * as crypto from 'node:crypto'; +} from "@edr/types"; +import { AxiosError, AxiosRequestConfig } from "axios"; +import { firstValueFrom } from "rxjs"; +import * as crypto from "node:crypto"; interface CbeBirrInitiateRequest { merchantId: string; @@ -51,7 +51,9 @@ export class CbeBirrProvider implements PaymentProvider { private readonly http: HttpService, ) {} - async initiate(input: ProviderInitiationInput): Promise { + async initiate( + input: ProviderInitiationInput, + ): Promise { const amount = (input.amountMinor / 100).toFixed(2); const timestamp = new Date().toISOString(); @@ -61,7 +63,8 @@ export class CbeBirrProvider implements PaymentProvider { amount, currency: input.currency, description: `EDR ${input.orderRef}`, - returnUrl: this.returnUrl, + // Per-transaction browser return target (each calling app has its own UI); config is fallback. + returnUrl: input.returnUrl ?? this.returnUrl, notifyUrl: this.notifyUrl, timestamp, signature: this.signRequest({ @@ -85,7 +88,7 @@ export class CbeBirrProvider implements PaymentProvider { return { providerOrderId: response.orderId, - clientAction: { type: 'REDIRECT', url: response.paymentUrl }, + clientAction: { type: "REDIRECT", url: response.paymentUrl }, expiresAt, rawInitiation: { request: this.sanitize(requestBody), @@ -117,14 +120,15 @@ export class CbeBirrProvider implements PaymentProvider { return { status: mapped, providerTxnId: response.transactionId, - failureCode: mapped === ProviderPaymentStatus.FAILED ? response.status : undefined, + failureCode: + mapped === ProviderPaymentStatus.FAILED ? response.status : undefined, rawResponse: response as unknown as Record, }; } verifyWebhookSignature(payload: Record): boolean { const { signature, ...data } = payload; - if (!signature || typeof signature !== 'string') return false; + if (!signature || typeof signature !== "string") return false; const expectedSignature = this.signRequest(data); return crypto.timingSafeEqual( @@ -139,16 +143,16 @@ export class CbeBirrProvider implements PaymentProvider { private mapStatus(status: string): ProviderPaymentStatus { switch (status?.toUpperCase()) { - case 'SUCCESS': - case 'COMPLETED': + case "SUCCESS": + case "COMPLETED": return ProviderPaymentStatus.SUCCEEDED; - case 'FAILED': - case 'REJECTED': - case 'EXPIRED': + case "FAILED": + case "REJECTED": + case "EXPIRED": return ProviderPaymentStatus.FAILED; - case 'PENDING': + case "PENDING": return ProviderPaymentStatus.REQUIRES_ACTION; - case 'PROCESSING': + case "PROCESSING": return ProviderPaymentStatus.PROCESSING; default: return ProviderPaymentStatus.PROCESSING; @@ -157,21 +161,19 @@ export class CbeBirrProvider implements PaymentProvider { private signRequest(data: Record): string { const sortedKeys = Object.keys(data).sort(); - const signString = sortedKeys - .map((key) => `${key}=${data[key]}`) - .join('&'); + const signString = sortedKeys.map((key) => `${key}=${data[key]}`).join("&"); return crypto - .createHmac('sha256', this.secretKey) + .createHmac("sha256", this.secretKey) .update(signString) - .digest('hex'); + .digest("hex"); } private async postJson(url: string, body: unknown): Promise { const config: AxiosRequestConfig = { headers: { - 'Content-Type': 'application/json', - 'X-Merchant-Id': this.merchantId, + "Content-Type": "application/json", + "X-Merchant-Id": this.merchantId, }, timeout: 10_000, }; @@ -179,7 +181,9 @@ export class CbeBirrProvider implements PaymentProvider { const started = Date.now(); try { const res = await firstValueFrom(this.http.post(url, body, config)); - this.logger.debug(`CBE Birr POST ${url} status=${res.status} latency=${Date.now() - started}ms`); + this.logger.debug( + `CBE Birr POST ${url} status=${res.status} latency=${Date.now() - started}ms`, + ); return res.data; } catch (err) { if (err instanceof AxiosError) { @@ -187,7 +191,9 @@ export class CbeBirrProvider implements PaymentProvider { `CBE Birr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`, ); } else { - this.logger.error(`CBE Birr POST ${url} threw: ${err instanceof Error ? err.message : err}`); + this.logger.error( + `CBE Birr POST ${url} threw: ${err instanceof Error ? err.message : err}`, + ); } throw err; } @@ -199,18 +205,18 @@ export class CbeBirrProvider implements PaymentProvider { } private get baseUrl(): string { - return this.config.get('cbe.baseUrl') ?? ''; + return this.config.get("cbe.baseUrl") ?? ""; } private get merchantId(): string { - return this.config.get('cbe.merchantId') ?? ''; + return this.config.get("cbe.merchantId") ?? ""; } private get secretKey(): string { - return this.config.get('cbe.secretKey') ?? ''; + return this.config.get("cbe.secretKey") ?? ""; } private get notifyUrl(): string { - return this.config.get('cbe.notifyUrl') ?? ''; + return this.config.get("cbe.notifyUrl") ?? ""; } private get returnUrl(): string { - return this.config.get('cbe.returnUrl') ?? ''; + return this.config.get("cbe.returnUrl") ?? ""; } } diff --git a/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts index fa8455db5..35ca54a3b 100644 --- a/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts +++ b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts @@ -56,7 +56,7 @@ export class DMoneyProvider implements PaymentProvider { constructor( private readonly config: ConfigService, private readonly http: HttpService, - ) { } + ) {} async initiate( input: ProviderInitiationInput, @@ -99,9 +99,9 @@ export class DMoneyProvider implements PaymentProvider { clientAction: response.checkoutUrl ? { type: "REDIRECT", url: response.checkoutUrl } : { - type: "REDIRECT", - url: `${this.baseUrl}/checkout/${response.orderId}`, - }, + type: "REDIRECT", + url: `${this.baseUrl}/checkout/${response.orderId}`, + }, expiresAt, rawInitiation: { request: this.sanitize(requestBody), diff --git a/packages/payment-providers/src/providers/ebirr/ebirr.provider.ts b/packages/payment-providers/src/providers/ebirr/ebirr.provider.ts index ee84bf611..2f8e954cf 100644 --- a/packages/payment-providers/src/providers/ebirr/ebirr.provider.ts +++ b/packages/payment-providers/src/providers/ebirr/ebirr.provider.ts @@ -1,6 +1,6 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { HttpService } from '@nestjs/axios'; +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { HttpService } from "@nestjs/axios"; import { PaymentProvider, ProviderInitiationInput, @@ -8,10 +8,10 @@ import { ProviderStatus, ProviderPaymentStatus, ProviderMethod, -} from '@edr/types'; -import { AxiosError, AxiosRequestConfig } from 'axios'; -import { firstValueFrom } from 'rxjs'; -import * as crypto from 'node:crypto'; +} from "@edr/types"; +import { AxiosError, AxiosRequestConfig } from "axios"; +import { firstValueFrom } from "rxjs"; +import * as crypto from "node:crypto"; interface EBirrInitiateRequest { merchantCode: string; @@ -58,7 +58,9 @@ export class EBirrProvider implements PaymentProvider { private readonly http: HttpService, ) {} - async initiate(input: ProviderInitiationInput): Promise { + async initiate( + input: ProviderInitiationInput, + ): Promise { const amount = input.amountMinor / 100; const timestamp = Date.now(); @@ -70,7 +72,8 @@ export class EBirrProvider implements PaymentProvider { subject: `EDR Ticket`, body: `Order ${input.orderRef}`, notifyUrl: this.notifyUrl, - returnUrl: this.returnUrl, + // Per-transaction browser return target (each calling app has its own UI); config is fallback. + returnUrl: input.returnUrl ?? this.returnUrl, timestamp, sign: this.signRequest({ merchantCode: this.merchantCode, @@ -85,7 +88,7 @@ export class EBirrProvider implements PaymentProvider { requestBody, ); - if (response.code !== '0000' || !response.data?.orderNo) { + if (response.code !== "0000" || !response.data?.orderNo) { throw new Error(`eBirr initiate failed: ${response.message}`); } @@ -93,7 +96,7 @@ export class EBirrProvider implements PaymentProvider { return { providerOrderId: response.data.orderNo, - clientAction: { type: 'REDIRECT', url: response.data.payUrl }, + clientAction: { type: "REDIRECT", url: response.data.payUrl }, expiresAt, rawInitiation: { request: this.sanitize(requestBody), @@ -120,7 +123,7 @@ export class EBirrProvider implements PaymentProvider { requestBody, ); - if (response.code !== '0000' || !response.data) { + if (response.code !== "0000" || !response.data) { throw new Error(`eBirr query failed: ${response.message}`); } @@ -129,20 +132,20 @@ export class EBirrProvider implements PaymentProvider { return { status: mapped, providerTxnId: response.data.tradeNo, - failureCode: mapped === ProviderPaymentStatus.FAILED ? response.data.tradeStatus : undefined, + failureCode: + mapped === ProviderPaymentStatus.FAILED + ? response.data.tradeStatus + : undefined, rawResponse: response as unknown as Record, }; } verifyWebhookSignature(payload: Record): boolean { const { sign, ...data } = payload; - if (!sign || typeof sign !== 'string') return false; + if (!sign || typeof sign !== "string") return false; const expectedSign = this.signRequest(data); - return crypto.timingSafeEqual( - Buffer.from(sign), - Buffer.from(expectedSign), - ); + return crypto.timingSafeEqual(Buffer.from(sign), Buffer.from(expectedSign)); } mapWebhookStatus(tradeStatus: string): ProviderPaymentStatus { @@ -151,17 +154,17 @@ export class EBirrProvider implements PaymentProvider { private mapStatus(tradeStatus: string): ProviderPaymentStatus { switch (tradeStatus?.toUpperCase()) { - case 'TRADE_SUCCESS': - case 'SUCCESS': + case "TRADE_SUCCESS": + case "SUCCESS": return ProviderPaymentStatus.SUCCEEDED; - case 'TRADE_CLOSED': - case 'TRADE_FAILED': - case 'FAILED': + case "TRADE_CLOSED": + case "TRADE_FAILED": + case "FAILED": return ProviderPaymentStatus.FAILED; - case 'WAIT_BUYER_PAY': - case 'PENDING': + case "WAIT_BUYER_PAY": + case "PENDING": return ProviderPaymentStatus.REQUIRES_ACTION; - case 'PROCESSING': + case "PROCESSING": return ProviderPaymentStatus.PROCESSING; default: return ProviderPaymentStatus.PROCESSING; @@ -170,21 +173,21 @@ export class EBirrProvider implements PaymentProvider { private signRequest(data: Record): string { const sortedKeys = Object.keys(data).sort(); - const signString = sortedKeys - .map((key) => `${key}=${data[key]}`) - .join('&') + `&key=${this.secretKey}`; + const signString = + sortedKeys.map((key) => `${key}=${data[key]}`).join("&") + + `&key=${this.secretKey}`; return crypto - .createHash('md5') + .createHash("md5") .update(signString) - .digest('hex') + .digest("hex") .toUpperCase(); } private async postJson(url: string, body: unknown): Promise { const config: AxiosRequestConfig = { headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, timeout: 10_000, }; @@ -192,7 +195,9 @@ export class EBirrProvider implements PaymentProvider { const started = Date.now(); try { const res = await firstValueFrom(this.http.post(url, body, config)); - this.logger.debug(`eBirr POST ${url} status=${res.status} latency=${Date.now() - started}ms`); + this.logger.debug( + `eBirr POST ${url} status=${res.status} latency=${Date.now() - started}ms`, + ); return res.data; } catch (err) { if (err instanceof AxiosError) { @@ -200,7 +205,9 @@ export class EBirrProvider implements PaymentProvider { `eBirr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`, ); } else { - this.logger.error(`eBirr POST ${url} threw: ${err instanceof Error ? err.message : err}`); + this.logger.error( + `eBirr POST ${url} threw: ${err instanceof Error ? err.message : err}`, + ); } throw err; } @@ -212,18 +219,18 @@ export class EBirrProvider implements PaymentProvider { } private get baseUrl(): string { - return this.config.get('ebirr.baseUrl') ?? ''; + return this.config.get("ebirr.baseUrl") ?? ""; } private get merchantCode(): string { - return this.config.get('ebirr.merchantCode') ?? ''; + return this.config.get("ebirr.merchantCode") ?? ""; } private get secretKey(): string { - return this.config.get('ebirr.secretKey') ?? ''; + return this.config.get("ebirr.secretKey") ?? ""; } private get notifyUrl(): string { - return this.config.get('ebirr.notifyUrl') ?? ''; + return this.config.get("ebirr.notifyUrl") ?? ""; } private get returnUrl(): string { - return this.config.get('ebirr.returnUrl') ?? ''; + return this.config.get("ebirr.returnUrl") ?? ""; } } diff --git a/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts b/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts index f2fad00fe..39a0e8e90 100644 --- a/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts +++ b/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts @@ -1,6 +1,6 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { HttpService } from '@nestjs/axios'; +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { HttpService } from "@nestjs/axios"; import { PaymentProvider, ProviderInitiationInput, @@ -8,22 +8,22 @@ import { ProviderStatus, ProviderPaymentStatus, ProviderMethod, -} from '@edr/types'; -import { AxiosError, AxiosRequestConfig } from 'axios'; -import { firstValueFrom } from 'rxjs'; -import * as https from 'node:https'; +} from "@edr/types"; +import { AxiosError, AxiosRequestConfig } from "axios"; +import { firstValueFrom } from "rxjs"; +import * as https from "node:https"; import { createNonceStr, createTimestamp, signRequestObject, verifyRequestObject, -} from './telebirr.crypto'; +} from "./telebirr.crypto"; import { CreateOrderRequest, CreateOrderResponse, FabricTokenResponse, QueryOrderResponse, -} from './telebirr.types'; +} from "./telebirr.types"; const TELEBIRR_HTTP_TIMEOUT_MS = 10_000; @@ -37,17 +37,21 @@ export class TelebirrProvider implements PaymentProvider { private readonly config: ConfigService, private readonly http: HttpService, ) { - const insecure = this.config.get('telebirr.insecureTls'); + const insecure = this.config.get("telebirr.insecureTls"); if (insecure) { - this.logger.warn('TELEBIRR_INSECURE_TLS=true — TLS verification disabled for Telebirr calls. DEV ONLY.'); + this.logger.warn( + "TELEBIRR_INSECURE_TLS=true — TLS verification disabled for Telebirr calls. DEV ONLY.", + ); } this.httpsAgent = new https.Agent({ rejectUnauthorized: !insecure, - secureProtocol: 'TLSv1_2_method', + secureProtocol: "TLSv1_2_method", }); } - async initiate(input: ProviderInitiationInput): Promise { + async initiate( + input: ProviderInitiationInput, + ): Promise { const fabricToken = await this.applyFabricToken(); const requestBody = this.buildCreateOrderRequest(input); const response = await this.requestCreateOrder(fabricToken, requestBody); @@ -59,17 +63,19 @@ export class TelebirrProvider implements PaymentProvider { ); } - const expiresAt = this.computeExpiresAt(requestBody.biz_content.timeout_express); - const platform = input.platform ?? 'web'; + const expiresAt = this.computeExpiresAt( + requestBody.biz_content.timeout_express, + ); + const platform = input.platform ?? "web"; const clientAction = - platform === 'mobile' + platform === "mobile" ? { - type: 'LAUNCH_APP' as const, - appId: this.merchantAppId, - receiveCode: response.biz_content?.receiveCode, - shortCode: this.merchantCode, - } - : { type: 'REDIRECT' as const, url: this.buildCheckoutUrl(prepayId) }; + type: "LAUNCH_APP" as const, + appId: this.merchantAppId, + receiveCode: response.biz_content?.receiveCode, + shortCode: this.merchantCode, + } + : { type: "REDIRECT" as const, url: this.buildCheckoutUrl(prepayId) }; return { providerOrderId: prepayId, @@ -89,8 +95,8 @@ export class TelebirrProvider implements PaymentProvider { `${this.baseUrl}/payment/v1/merchant/queryOrder`, requestBody, { - 'Content-Type': 'application/json', - 'X-APP-Key': this.fabricAppId, + "Content-Type": "application/json", + "X-APP-Key": this.fabricAppId, Authorization: fabricToken, }, ); @@ -104,36 +110,40 @@ export class TelebirrProvider implements PaymentProvider { status: mapped, providerTxnId, failureCode: - mapped === ProviderPaymentStatus.FAILED && tradeStatus ? tradeStatus : undefined, + mapped === ProviderPaymentStatus.FAILED && tradeStatus + ? tradeStatus + : undefined, rawResponse: response as Record, }; } mapTradeStatus(tradeStatus: string | undefined): ProviderPaymentStatus { switch (tradeStatus) { - case 'PAY_SUCCESS': + case "PAY_SUCCESS": return ProviderPaymentStatus.SUCCEEDED; - case 'PAY_FAILED': - case 'ORDER_CLOSED': + case "PAY_FAILED": + case "ORDER_CLOSED": return ProviderPaymentStatus.FAILED; - case 'WAIT_PAY': + case "WAIT_PAY": return ProviderPaymentStatus.REQUIRES_ACTION; - case 'PAYING': + case "PAYING": return ProviderPaymentStatus.PROCESSING; default: return ProviderPaymentStatus.PROCESSING; } } - mapWebhookTradeStatus(tradeStatus: string | undefined): ProviderPaymentStatus { + mapWebhookTradeStatus( + tradeStatus: string | undefined, + ): ProviderPaymentStatus { switch (tradeStatus) { - case 'Completed': + case "Completed": return ProviderPaymentStatus.SUCCEEDED; - case 'Failure': - case 'Expired': + case "Failure": + case "Expired": return ProviderPaymentStatus.FAILED; - case 'Paying': - case 'Pending': + case "Paying": + case "Pending": return ProviderPaymentStatus.PROCESSING; default: return ProviderPaymentStatus.PROCESSING; @@ -142,7 +152,9 @@ export class TelebirrProvider implements PaymentProvider { verifyWebhookSignature(payload: Record): boolean { if (!this.publicKey) { - this.logger.error('TELEBIRR_PUBLIC_KEY not configured; rejecting all webhooks'); + this.logger.error( + "TELEBIRR_PUBLIC_KEY not configured; rejecting all webhooks", + ); return false; } return verifyRequestObject(payload, this.publicKey); @@ -153,12 +165,14 @@ export class TelebirrProvider implements PaymentProvider { `${this.baseUrl}/payment/v1/token`, { appSecret: this.appSecret }, { - 'Content-Type': 'application/json', - 'X-APP-Key': this.fabricAppId, + "Content-Type": "application/json", + "X-APP-Key": this.fabricAppId, }, ); if (!response?.token) { - throw new Error(`Telebirr token request failed: ${JSON.stringify(response)}`); + throw new Error( + `Telebirr token request failed: ${JSON.stringify(response)}`, + ); } return response.token; } @@ -171,51 +185,61 @@ export class TelebirrProvider implements PaymentProvider { `${this.baseUrl}/payment/v1/inapp/createOrder`, body, { - 'Content-Type': 'application/json', - 'X-APP-Key': this.fabricAppId, + "Content-Type": "application/json", + "X-APP-Key": this.fabricAppId, Authorization: fabricToken, }, ); } - private buildCreateOrderRequest(input: ProviderInitiationInput): CreateOrderRequest { + private buildCreateOrderRequest( + input: ProviderInitiationInput, + ): CreateOrderRequest { const totalAmount = String(input.amountMinor / 100); const req = { timestamp: createTimestamp(), nonce_str: createNonceStr(), - method: 'payment.preorder' as const, - version: '1.0' as const, + method: "payment.preorder" as const, + version: "1.0" as const, biz_content: { notify_url: this.notifyUrl, appid: this.merchantAppId, merch_code: this.merchantCode, merch_order_id: input.merchantOrderId, - trade_type: 'Checkout' as const, + trade_type: "Checkout" as const, title: `EDR ${input.orderRef}`, total_amount: totalAmount, trans_currency: input.currency, timeout_express: this.timeoutExpress, - redirect_url: input.redirectUrl + redirect_url: input.redirectUrl, }, }; - const sign = signRequestObject(req as unknown as Record, this.privateKey); - return { ...req, sign, sign_type: 'SHA256WithRSA' }; + const sign = signRequestObject( + req as unknown as Record, + this.privateKey, + ); + return { ...req, sign, sign_type: "SHA256WithRSA" }; } - private buildQueryOrderRequest(merchantOrderId: string): Record { + private buildQueryOrderRequest( + merchantOrderId: string, + ): Record { const req = { timestamp: createTimestamp(), nonce_str: createNonceStr(), - method: 'payment.queryorder', - version: '1.0', + method: "payment.queryorder", + version: "1.0", biz_content: { appid: this.merchantAppId, merch_code: this.merchantCode, merch_order_id: merchantOrderId, }, }; - const sign = signRequestObject(req as Record, this.privateKey); - return { ...req, sign, sign_type: 'SHA256WithRSA' }; + const sign = signRequestObject( + req as Record, + this.privateKey, + ); + return { ...req, sign, sign_type: "SHA256WithRSA" }; } private buildCheckoutUrl(prepayId: string): string { @@ -233,27 +257,34 @@ export class TelebirrProvider implements PaymentProvider { `nonce_str=${map.nonce_str}`, `prepay_id=${map.prepay_id}`, `timestamp=${map.timestamp}`, - 'sign_type=SHA256WithRSA', + "sign_type=SHA256WithRSA", `sign=${sign}`, - 'version=1.0', - 'trade_type=Checkout', - ].join('&'); + "version=1.0", + "trade_type=Checkout", + ].join("&"); return `${this.webBaseUrl}${rawRequest}`; } private computeExpiresAt(timeoutExpress: string): Date { const match = /^(\d+)([smhd])$/.exec(timeoutExpress); - const minutes = match ? this.toMinutes(parseInt(match[1], 10), match[2]) : 15; + const minutes = match + ? this.toMinutes(parseInt(match[1], 10), match[2]) + : 15; return new Date(Date.now() + minutes * 60_000); } private toMinutes(n: number, unit: string): number { switch (unit) { - case 's': return Math.max(1, Math.round(n / 60)); - case 'm': return n; - case 'h': return n * 60; - case 'd': return n * 60 * 24; - default: return 15; + case "s": + return Math.max(1, Math.round(n / 60)); + case "m": + return n; + case "h": + return n * 60; + case "d": + return n * 60 * 24; + default: + return 15; } } @@ -270,7 +301,9 @@ export class TelebirrProvider implements PaymentProvider { const started = Date.now(); try { const res = await firstValueFrom(this.http.post(url, body, config)); - this.logger.debug(`Telebirr POST ${url} status=${res.status} latency=${Date.now() - started}ms`); + this.logger.debug( + `Telebirr POST ${url} status=${res.status} latency=${Date.now() - started}ms`, + ); return res.data; } catch (err) { if (err instanceof AxiosError) { @@ -278,7 +311,9 @@ export class TelebirrProvider implements PaymentProvider { `Telebirr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)} code=${err.code} message=${err.message}`, ); } else { - this.logger.error(`Telebirr POST ${url} threw: ${err instanceof Error ? err.message : err}`); + this.logger.error( + `Telebirr POST ${url} threw: ${err instanceof Error ? err.message : err}`, + ); } throw err; } @@ -289,14 +324,34 @@ export class TelebirrProvider implements PaymentProvider { return rest; } - private get baseUrl(): string { return this.config.get('telebirr.baseUrl') ?? ''; } - private get webBaseUrl(): string { return this.config.get('telebirr.webBaseUrl') ?? ''; } - private get fabricAppId(): string { return this.config.get('telebirr.fabricAppId') ?? ''; } - private get appSecret(): string { return this.config.get('telebirr.appSecret') ?? ''; } - private get merchantAppId(): string { return this.config.get('telebirr.merchantAppId') ?? ''; } - private get merchantCode(): string { return this.config.get('telebirr.merchantCode') ?? ''; } - private get notifyUrl(): string { return this.config.get('telebirr.notifyUrl') ?? ''; } - private get timeoutExpress(): string { return this.config.get('telebirr.timeoutExpress') ?? '15m'; } - private get privateKey(): string { return this.config.get('telebirr.privateKey') ?? ''; } - private get publicKey(): string { return this.config.get('telebirr.publicKey') ?? ''; } + private get baseUrl(): string { + return this.config.get("telebirr.baseUrl") ?? ""; + } + private get webBaseUrl(): string { + return this.config.get("telebirr.webBaseUrl") ?? ""; + } + private get fabricAppId(): string { + return this.config.get("telebirr.fabricAppId") ?? ""; + } + private get appSecret(): string { + return this.config.get("telebirr.appSecret") ?? ""; + } + private get merchantAppId(): string { + return this.config.get("telebirr.merchantAppId") ?? ""; + } + private get merchantCode(): string { + return this.config.get("telebirr.merchantCode") ?? ""; + } + private get notifyUrl(): string { + return this.config.get("telebirr.notifyUrl") ?? ""; + } + private get timeoutExpress(): string { + return this.config.get("telebirr.timeoutExpress") ?? "15m"; + } + private get privateKey(): string { + return this.config.get("telebirr.privateKey") ?? ""; + } + private get publicKey(): string { + return this.config.get("telebirr.publicKey") ?? ""; + } } diff --git a/packages/payment-providers/src/providers/waafi/waafi.provider.ts b/packages/payment-providers/src/providers/waafi/waafi.provider.ts index caabdf251..276ff2de2 100644 --- a/packages/payment-providers/src/providers/waafi/waafi.provider.ts +++ b/packages/payment-providers/src/providers/waafi/waafi.provider.ts @@ -1,6 +1,6 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { HttpService } from '@nestjs/axios'; +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { HttpService } from "@nestjs/axios"; import { PaymentProvider, ProviderInitiationInput, @@ -8,20 +8,20 @@ import { ProviderStatus, ProviderPaymentStatus, ProviderMethod, -} from '@edr/types'; -import { AxiosError, AxiosRequestConfig } from 'axios'; -import { firstValueFrom } from 'rxjs'; -import * as crypto from 'node:crypto'; -import * as https from 'node:https'; +} from "@edr/types"; +import { AxiosError, AxiosRequestConfig } from "axios"; +import { firstValueFrom } from "rxjs"; +import * as crypto from "node:crypto"; +import * as https from "node:https"; import { WaafiGetTranInfoRequest, WaafiGetTranInfoResponse, WaafiHppPurchaseRequest, WaafiHppPurchaseResponse, -} from './waafi.types'; +} from "./waafi.types"; const WAAFI_HTTP_TIMEOUT_MS = 10_000; -const WAAFI_SUCCESS_CODE = '2001'; +const WAAFI_SUCCESS_CODE = "2001"; /** Waafi cancels an unprocessed HPP session after ~5 minutes (RCS_HPP_USERACTION_TIMEOUT). */ const WAAFI_HPP_SESSION_MS = 5 * 60_000; @@ -35,16 +35,18 @@ export class WaafiProvider implements PaymentProvider { private readonly config: ConfigService, private readonly http: HttpService, ) { - const insecure = this.config.get('waafi.insecureTls'); + const insecure = this.config.get("waafi.insecureTls"); if (insecure) { this.logger.warn( - 'WAAFI_INSECURE_TLS=true — TLS verification disabled for Waafi calls. DEV ONLY.', + "WAAFI_INSECURE_TLS=true — TLS verification disabled for Waafi calls. DEV ONLY.", ); } this.httpsAgent = new https.Agent({ rejectUnauthorized: !insecure }); } - async initiate(input: ProviderInitiationInput): Promise { + async initiate( + input: ProviderInitiationInput, + ): Promise { const requestBody = this.buildPurchaseRequest(input); const response = await this.postJson( `${this.baseUrl}/asm`, @@ -57,7 +59,8 @@ export class WaafiProvider implements PaymentProvider { ); } - const checkoutUrl = response.params?.hppUrl ?? response.params?.directPaymentLink; + const checkoutUrl = + response.params?.hppUrl ?? response.params?.directPaymentLink; const orderId = response.params?.orderId; if (!checkoutUrl || !orderId) { throw new Error( @@ -67,7 +70,7 @@ export class WaafiProvider implements PaymentProvider { return { providerOrderId: orderId, - clientAction: { type: 'REDIRECT', url: checkoutUrl }, + clientAction: { type: "REDIRECT", url: checkoutUrl }, expiresAt: new Date(Date.now() + WAAFI_HPP_SESSION_MS), rawInitiation: { request: this.sanitize(requestBody), @@ -91,7 +94,9 @@ export class WaafiProvider implements PaymentProvider { status: mapped, providerTxnId: transactionId, failureCode: - mapped === ProviderPaymentStatus.FAILED && rawState ? rawState : undefined, + mapped === ProviderPaymentStatus.FAILED && rawState + ? rawState + : undefined, rawResponse: response as unknown as Record, }; } @@ -115,61 +120,70 @@ export class WaafiProvider implements PaymentProvider { eventId: string | undefined, ): boolean { if (!this.webhookSecret) { - this.logger.error('WAAFI_WEBHOOK_SECRET not configured; rejecting all webhooks'); + this.logger.error( + "WAAFI_WEBHOOK_SECRET not configured; rejecting all webhooks", + ); return false; } if (!signature || !timestamp || !eventId) { - this.logger.warn('Waafi webhook missing signature/timestamp/event-id headers'); + this.logger.warn( + "Waafi webhook missing signature/timestamp/event-id headers", + ); return false; } const signingString = `${timestamp}.${eventId}.${rawBody}`; const expected = crypto - .createHmac('sha256', this.webhookSecret) + .createHmac("sha256", this.webhookSecret) .update(signingString) - .digest('hex'); + .digest("hex"); - const provided = Buffer.from(signature, 'utf8'); - const computed = Buffer.from(expected, 'utf8'); + const provided = Buffer.from(signature, "utf8"); + const computed = Buffer.from(expected, "utf8"); if (provided.length !== computed.length) return false; return crypto.timingSafeEqual(provided, computed); } private mapStatus(raw: string | undefined): ProviderPaymentStatus { switch (raw?.toUpperCase()) { - case 'APPROVED': - case 'SUCCESS': + case "APPROVED": + case "SUCCESS": return ProviderPaymentStatus.SUCCEEDED; - case 'CANCELED': - case 'CANCELLED': + case "CANCELED": + case "CANCELLED": return ProviderPaymentStatus.CANCELLED; - case 'DECLINED': - case 'FAILED': - case 'EXPIRED': - case 'TIMEOUT': + case "DECLINED": + case "FAILED": + case "EXPIRED": + case "TIMEOUT": return ProviderPaymentStatus.FAILED; - case 'PENDING': - case 'INITIATED': + case "PENDING": + case "INITIATED": return ProviderPaymentStatus.REQUIRES_ACTION; default: return ProviderPaymentStatus.PROCESSING; } } - private buildPurchaseRequest(input: ProviderInitiationInput): WaafiHppPurchaseRequest { + private buildPurchaseRequest( + input: ProviderInitiationInput, + ): WaafiHppPurchaseRequest { return { - schemaVersion: '1.0', + schemaVersion: "1.0", requestId: crypto.randomUUID(), timestamp: this.timestamp(), - channelName: 'WEB', - serviceName: 'HPP_PURCHASE', + channelName: "WEB", + serviceName: "HPP_PURCHASE", serviceParams: { merchantUid: this.merchantUid, storeId: this.storeId, hppKey: this.hppKey, paymentMethod: this.paymentMethod, - hppSuccessCallbackUrl: this.successUrl, - hppFailureCallbackUrl: this.failureUrl, + // Browser bounce-back is per-transaction (each calling app has its own UI), so the + // caller-supplied URLs win; the static config is only a fallback. UX-only — the + // webhook remains the single source of truth for payment state. + hppSuccessCallbackUrl: input.returnUrl ?? this.successUrl, + hppFailureCallbackUrl: input.failureUrl ?? this.failureUrl, hppRespDataFormat: this.respDataFormat, // MWALLET_ACCOUNT requires the payer phone up front; omit if the caller did not supply it // and let the hosted page collect it. See docs/waffi open question on payer-phone sourcing. @@ -187,13 +201,15 @@ export class WaafiProvider implements PaymentProvider { }; } - private buildGetTranInfoRequest(merchantOrderId: string): WaafiGetTranInfoRequest { + private buildGetTranInfoRequest( + merchantOrderId: string, + ): WaafiGetTranInfoRequest { return { - schemaVersion: '1.0', + schemaVersion: "1.0", requestId: crypto.randomUUID(), timestamp: this.timestamp(), - channelName: 'WEB', - serviceName: 'HPP_GETTRANINFO', + channelName: "WEB", + serviceName: "HPP_GETTRANINFO", serviceParams: { merchantUid: this.merchantUid, storeId: this.storeId, @@ -214,7 +230,7 @@ export class WaafiProvider implements PaymentProvider { private async postJson(url: string, body: unknown): Promise { const config: AxiosRequestConfig = { - headers: { 'Content-Type': 'application/json' }, + headers: { "Content-Type": "application/json" }, timeout: WAAFI_HTTP_TIMEOUT_MS, httpsAgent: this.httpsAgent, }; @@ -243,38 +259,40 @@ export class WaafiProvider implements PaymentProvider { private sanitize(body: WaafiHppPurchaseRequest): Record { return { ...body, - serviceParams: { ...body.serviceParams, hppKey: '***REDACTED***' }, + serviceParams: { ...body.serviceParams, hppKey: "***REDACTED***" }, }; } private get baseUrl(): string { - return this.config.get('waafi.baseUrl') ?? 'https://sandbox.waafipay.net'; + return ( + this.config.get("waafi.baseUrl") ?? "https://sandbox.waafipay.net" + ); } private get merchantUid(): string { - return this.config.get('waafi.merchantUid') ?? ''; + return this.config.get("waafi.merchantUid") ?? ""; } private get storeId(): string { - return this.config.get('waafi.storeId') ?? ''; + return this.config.get("waafi.storeId") ?? ""; } private get hppKey(): string { - return this.config.get('waafi.hppKey') ?? ''; + return this.config.get("waafi.hppKey") ?? ""; } private get webhookSecret(): string { - return this.config.get('waafi.webhookSecret') ?? ''; + return this.config.get("waafi.webhookSecret") ?? ""; } private get paymentMethod(): string { - return this.config.get('waafi.paymentMethod') ?? 'MWALLET_ACCOUNT'; + return this.config.get("waafi.paymentMethod") ?? "MWALLET_ACCOUNT"; } private get currency(): string { - return this.config.get('waafi.currency') ?? ''; + return this.config.get("waafi.currency") ?? ""; } private get successUrl(): string { - return this.config.get('waafi.successUrl') ?? ''; + return this.config.get("waafi.successUrl") ?? ""; } private get failureUrl(): string { - return this.config.get('waafi.failureUrl') ?? ''; + return this.config.get("waafi.failureUrl") ?? ""; } private get respDataFormat(): number { - return this.config.get('waafi.respDataFormat') ?? 1; + return this.config.get("waafi.respDataFormat") ?? 1; } } diff --git a/packages/types/src/common/payments.ts b/packages/types/src/common/payments.ts index 0cf2174c3..02930d151 100644 --- a/packages/types/src/common/payments.ts +++ b/packages/types/src/common/payments.ts @@ -52,6 +52,8 @@ export interface ProviderInitiationInput { /** Optional caller-supplied redirect targets for redirect/HPP-style providers. */ returnUrl?: string; redirectUrl?: string; + /** Where the browser lands when the hosted page fails/cancels (UX only — never trusted). */ + failureUrl?: string; } export interface ProviderInitiationResult { @@ -94,11 +96,6 @@ export enum PaymentReferenceType { SHIPMENT = "SHIPMENT", } -/** `merchant_order_id` prefix per owning service — lets a webhook be routed before a DB lookup. */ -export const MERCHANT_ORDER_PREFIX: Record = { - [PaymentService.PASSENGER]: "PSG-", - [PaymentService.FREIGHT]: "FRT-", -}; /** Body of `POST /payments/initiate` on the payment service (internal, service-authenticated). */ export interface InitiatePaymentRequest { @@ -114,6 +111,16 @@ export interface InitiatePaymentRequest { provider: ProviderMethod; platform?: PaymentPlatform; payerAccount?: string; + /** + * Where the provider's hosted page sends the BROWSER back after success — each calling app + * passes its own UI URL (passenger portal vs freight portal). Per-transaction and UX-only: + * the redirect never confirms payment (only the webhook / status query does), so per-app + * values are safe even though the server-to-server webhook URL is one per merchant. + * Falls back to the payment service's provider config when omitted. + */ + returnUrl?: string; + /** Failure/cancel counterpart of returnUrl. */ + failureUrl?: string; /** Optional caller key to dedupe retried initiations beyond the per-reference upsert. */ idempotencyKey?: string; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d4aa9a32..b6771011e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -392,9 +392,6 @@ importers: apps/edr-passenger-api: dependencies: - '@edr/payment-providers': - specifier: workspace:* - version: link:../../packages/payment-providers '@edr/types': specifier: workspace:* version: link:../../packages/types