diff --git a/CLAUDE.md b/CLAUDE.md index c06000dac..d67e6c3d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,7 @@ Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Fre | `edr-freight-web/portal` | `@edr/freight-portal` | React frontend for freight customer/portal users | 5173 | | `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React frontend for freight backoffice employees | 5183 | | `edr-passenger-api` | `@edr/passenger-api` | NestJS API for passenger management | 3002 | +| `edr-payment-api` | `@edr/payment-api` | NestJS payment microservice (intents, webhooks) | 3003 | | `edr-passenger-web/portal` | `@edr/passenger-portal` | React frontend for passenger customer/portal users | 5174 | | `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | React frontend for passenger backoffice employees | 5184 | @@ -73,6 +74,7 @@ The `@CurrentUser`, `@Roles`, and `@Public` decorators in `@edr/api-common` are - `edr-freight-web/portal`: 5173 - `edr-freight-web/backoffice`: 5183 - `edr-passenger-api`: 3002 +- `edr-payment-api`: 3003 - `edr-passenger-web/portal`: 5174 - `edr-passenger-web/backoffice`: 5184 @@ -80,6 +82,7 @@ The `@CurrentUser`, `@Roles`, and `@Public` decorators in `@edr/api-common` are - `postgres-freight` (port 5433): database `edr_freight` — freight API only. - `postgres-passenger` (port 5434): database `edr_passenger` — passenger API only. +- `edr_payment` schema — lives in the same Postgres database as the domain system (whatever the passenger `DATABASE_URL` points at) but is owned exclusively by `apps/edr-payment-api`. Dedicated DB user, no cross-schema FKs, domain apps have no grants on it (see `docs/payment-service/`). - Each app owns its own DB. No cross-database joins; cross-domain data flows through API calls or message queues. ## Adding a new module to a NestJS app diff --git a/apps/edr-passenger-api/.env.example b/apps/edr-passenger-api/.env.example index aaf37c3e4..5ea89f4e5 100644 --- a/apps/edr-passenger-api/.env.example +++ b/apps/edr-passenger-api/.env.example @@ -65,13 +65,25 @@ CARD_WEBHOOK_SECRET= CARD_WEBHOOK_URL= CARD_RETURN_URL= -# Waafi (Djibouti Mobile Money) -WAAFI_BASE_URL=https://api.waafipay.net +# Waafi (Djibouti Mobile Money — Hosted Payment Page) +# Sandbox: https://sandbox.waafipay.net | Production: https://api.waafipay.net +WAAFI_BASE_URL=https://sandbox.waafipay.net WAAFI_MERCHANT_UID= -WAAFI_API_USER_ID= -WAAFI_API_KEY= +WAAFI_STORE_ID= +WAAFI_HPP_KEY= +# HMAC secret returned once by WEBHOOK_REGISTER — verifies inbound webhooks +WAAFI_WEBHOOK_SECRET= +WAAFI_PAYMENT_METHOD=MWALLET_ACCOUNT +# Waafi has no ETB; overrides booking currency (USD/DJF/SLSH) +WAAFI_CURRENCY=DJF +WAAFI_HPP_SUCCESS_URL= +WAAFI_HPP_FAILURE_URL= +# 1 = POST, 2 = GET, 4 = Result Token +WAAFI_HPP_RESP_FORMAT=1 +# Registered webhook URL (registration done out-of-band) WAAFI_NOTIFY_URL= -WAAFI_RETURN_URL= +# DEV ONLY — disable TLS cert verification (sandbox serves a *.waafi.com cert). Never true in prod. +WAAFI_INSECURE_TLS=false # Payment Configuration PAYMENT_PROVIDERS_ENABLED=TELEBIRR,CBE_BIRR,EBIRR,CARD,WALLET,WAAFI diff --git a/apps/edr-passenger-api/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/config/waafi.config.ts b/apps/edr-passenger-api/src/config/waafi.config.ts index c3afab7ce..639e66e2b 100644 --- a/apps/edr-passenger-api/src/config/waafi.config.ts +++ b/apps/edr-passenger-api/src/config/waafi.config.ts @@ -1,10 +1,27 @@ import { registerAs } from '@nestjs/config'; export default registerAs('waafi', () => ({ - baseUrl: process.env.WAAFI_BASE_URL ?? 'https://api.waafipay.net', + // `/asm` is appended in the provider; use sandbox by default, switch to + // https://api.waafipay.net in production. + baseUrl: process.env.WAAFI_BASE_URL ?? 'https://sandbox.waafipay.net', + // HPP credentials (Hosted Payment Page family). merchantUid: process.env.WAAFI_MERCHANT_UID ?? '', - apiUserId: process.env.WAAFI_API_USER_ID ?? '', - apiKey: process.env.WAAFI_API_KEY ?? '', + storeId: process.env.WAAFI_STORE_ID ?? '', + hppKey: process.env.WAAFI_HPP_KEY ?? '', + // HMAC secret returned once by WEBHOOK_REGISTER; verifies inbound webhooks. + webhookSecret: process.env.WAAFI_WEBHOOK_SECRET ?? '', + // Wallet payment method (EVC/ZAAD/Sahal) — MWALLET_ACCOUNT requires the payer phone up front. + paymentMethod: process.env.WAAFI_PAYMENT_METHOD ?? 'MWALLET_ACCOUNT', + // Waafi has no ETB; when set this overrides the booking currency (USD/DJF/SLSH). + currency: process.env.WAAFI_CURRENCY ?? 'DJF', + // Browser redirect targets after the hosted page completes/fails (UX only; webhook is source of truth). + successUrl: process.env.WAAFI_HPP_SUCCESS_URL ?? '', + failureUrl: process.env.WAAFI_HPP_FAILURE_URL ?? '', + // Callback data format: 1 = POST, 2 = GET, 4 = Result Token. + respDataFormat: Number(process.env.WAAFI_HPP_RESP_FORMAT ?? '1'), + // Registered webhook URL (reference only; registration is performed out-of-band). notifyUrl: process.env.WAAFI_NOTIFY_URL ?? '', - returnUrl: process.env.WAAFI_RETURN_URL ?? '', + // DEV ONLY: disable TLS cert verification. The Waafi sandbox serves a *.waafi.com cert that + // does not match sandbox.waafipay.net (ERR_TLS_CERT_ALTNAME_INVALID). Never enable in prod. + insecureTls: process.env.WAAFI_INSECURE_TLS === 'true', })); diff --git a/apps/edr-passenger-api/src/main.ts b/apps/edr-passenger-api/src/main.ts index fe853ef76..a7bd85f81 100644 --- a/apps/edr-passenger-api/src/main.ts +++ b/apps/edr-passenger-api/src/main.ts @@ -8,7 +8,9 @@ import { ResponseTransformInterceptor } from "./common/interceptors/response-tra import { SessionActivityInterceptor } from "./common/interceptors/session-activity.interceptor"; async function bootstrap() { - const app = await NestFactory.create(AppModule); + // rawBody: true buffers the unparsed request body onto req.rawBody so webhook handlers + // (e.g. Waafi HMAC verification) can sign over the exact bytes the provider signed. + const app = await NestFactory.create(AppModule, { rawBody: true }); app.enableCors({ origin: [ diff --git a/apps/edr-passenger-api/src/modules/payments/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 972b09b3d..000000000 --- a/apps/edr-passenger-api/src/modules/payments/webhooks/waafi-webhook.service.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { - WaafiProvider, - WaafiWebhookPayload, - ProviderPaymentStatus, -} from '@edr/payment-providers'; -import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client'; -import { PrismaService } from '../../../common/prisma.service'; -import { PaymentsService } from '../payments.service'; - -@Injectable() -export class WaafiWebhookService { - private readonly logger = new Logger(WaafiWebhookService.name); - - constructor( - private prisma: PrismaService, - private paymentsService: PaymentsService, - private waafiProvider: WaafiProvider, - ) {} - - async handleWebhook(payload: WaafiWebhookPayload): Promise<{ received: boolean }> { - this.logger.log( - `Waafi webhook received: event=${payload.eventType} ref=${payload.params?.referenceId}`, - ); - - const signatureValid = this.waafiProvider.verifyWebhookSignature( - payload as unknown as Record, - ); - - const merchantOrderId = payload.params?.referenceId; - const transactionId = payload.params?.transactionId; - const state = payload.params?.state; - - await this.prisma.paymentWebhookEvent.create({ - data: { - provider: PaymentMethodType.WAAFI, - externalEventId: payload.requestId, - merchantOrderId, - providerTxnId: transactionId, - signatureValid, - status: state || 'UNKNOWN', - payload: payload as any, - }, - }); - - if (!signatureValid) { - this.logger.warn(`Waafi webhook signature invalid for ref=${merchantOrderId}`); - return { received: true }; - } - - if (!merchantOrderId) { - this.logger.error('Waafi webhook missing referenceId'); - return { received: true }; - } - - const intent = await this.prisma.paymentIntent.findFirst({ - where: { merchantOrderId }, - }); - - if (!intent) { - this.logger.warn(`No PaymentIntent found for merchantOrderId=${merchantOrderId}`); - return { received: true }; - } - - const mappedStatus = this.waafiProvider.mapState(state); - - if (mappedStatus === ProviderPaymentStatus.SUCCEEDED) { - await this.paymentsService.finalizePaymentSuccess({ - intentId: intent.id, - providerTxnId: transactionId, - }); - this.logger.log(`Waafi payment succeeded: intent=${intent.id} txn=${transactionId}`); - } else if (mappedStatus === ProviderPaymentStatus.FAILED) { - await this.paymentsService.markPaymentFailed({ - intentId: intent.id, - failureCode: state, - failureMessage: payload.params?.description, - }); - this.logger.log(`Waafi payment failed: intent=${intent.id} state=${state}`); - } else { - await this.prisma.paymentIntent.update({ - where: { id: intent.id }, - data: { - status: mappedStatus as unknown as PaymentIntentStatus, - providerTxnId: transactionId, - }, - }); - this.logger.log(`Waafi payment status updated: intent=${intent.id} status=${mappedStatus}`); - } - - return { received: true }; - } -} 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 4dd340d06..000000000 --- a/apps/edr-passenger-api/src/modules/payments/webhooks/webhooks.controller.ts +++ /dev/null @@ -1,115 +0,0 @@ -import {All, Body, Controller, Headers, HttpCode, HttpStatus, Logger, Post} from '@nestjs/common'; -import { ApiOperation, ApiTags } from '@nestjs/swagger'; -import { - TelebirrWebhookPayload, - CbeBirrWebhookPayload, - EBirrWebhookPayload, - CardWebhookPayload, -} 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: any) { - try { - await this.waafi.handleWebhook(payload); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - this.logger.error(`Waafi webhook handler threw: ${message}`); - } - return { responseCode: '2001', responseMsg: 'Success' }; - } -} diff --git a/apps/edr-payment-api/nest-cli.json b/apps/edr-payment-api/nest-cli.json new file mode 100644 index 000000000..89d7d6c57 --- /dev/null +++ b/apps/edr-payment-api/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": false + } +} diff --git a/apps/edr-payment-api/package.json b/apps/edr-payment-api/package.json new file mode 100644 index 000000000..3ce7804a9 --- /dev/null +++ b/apps/edr-payment-api/package.json @@ -0,0 +1,74 @@ +{ + "name": "@edr/payment-api", + "version": "0.0.0", + "private": true, + "description": "EDR Payment Microservice — owns provider integration, payment intents, webhooks, and outbox notifications for the whole platform", + "scripts": { + "clean": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true}); fs.rmSync('.tsbuildinfo',{force:true});\"", + "predev": "pnpm run clean", + "dev": "nest start --watch", + "prebuild": "pnpm run clean", + "build": "nest build", + "start": "node dist/main.js", + "lint": "eslint src", + "test": "jest", + "type-check": "tsc --noEmit", + "migration:run": "ts-node src/scripts/migrate.ts", + "migration:revert": "ts-node src/scripts/migrate-revert.ts" + }, + "dependencies": { + "@edr/api-common": "workspace:*", + "@edr/payment-providers": "workspace:*", + "@edr/types": "workspace:*", + "@nestjs/axios": "^4.0.1", + "@nestjs/common": "^11.0.0", + "@nestjs/config": "^4.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/platform-express": "^11.0.0", + "@nestjs/schedule": "^6.0.0", + "@nestjs/swagger": "^11.4.2", + "@nestjs/typeorm": "^11.0.1", + "axios": "^1.16.1", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "dotenv": "^17.4.2", + "pg": "^8.13.0", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "typeorm": "0.3.30" + }, + "devDependencies": { + "@edr/eslint-config": "workspace:*", + "@edr/tsconfig": "workspace:*", + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@nestjs/testing": "^11.0.0", + "@types/express": "^5.0.0", + "@types/jest": "^29.5.13", + "@types/node": "^20.14.0", + "@types/pg": "^8.6.7", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "ts-loader": "^9.5.1", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.5.4" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coverageDirectory": "../coverage", + "testEnvironment": "node" + } +} diff --git a/apps/edr-payment-api/src/app.module.ts b/apps/edr-payment-api/src/app.module.ts new file mode 100644 index 000000000..4a99472d0 --- /dev/null +++ b/apps/edr-payment-api/src/app.module.ts @@ -0,0 +1,51 @@ +import { Module } from "@nestjs/common"; +import { ConfigModule, ConfigService } from "@nestjs/config"; +import { ScheduleModule } from "@nestjs/schedule"; +import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm"; +import appConfig from "./config/app.config"; +import databaseConfig from "./config/database.config"; +import notifierConfig from "./config/notifier.config"; +import telebirrConfig from "./config/telebirr.config"; +import waafiConfig from "./config/waafi.config"; +import cbeConfig from "./config/cbe.config"; +import ebirrConfig from "./config/ebirr.config"; +import cardConfig from "./config/card.config"; +import dmoneyConfig from "./config/dmoney.config"; +import { HealthModule } from "./modules/health/health.module"; +import { IntentsModule } from "./modules/intents/intents.module"; +import { OutboxModule } from "./modules/outbox/outbox.module"; +import { ProvidersModule } from "./modules/providers/providers.module"; +import { ReconciliationModule } from "./modules/reconciliation/reconciliation.module"; +import { WebhooksModule } from "./modules/webhooks/webhooks.module"; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [ + appConfig, + databaseConfig, + notifierConfig, + telebirrConfig, + waafiConfig, + cbeConfig, + ebirrConfig, + cardConfig, + dmoneyConfig, + ], + }), + TypeOrmModule.forRootAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService) => + config.get("database") as TypeOrmModuleOptions, + }), + ScheduleModule.forRoot(), + HealthModule, + ProvidersModule, + IntentsModule, + WebhooksModule, + OutboxModule, + ReconciliationModule, + ], +}) +export class AppModule {} diff --git a/apps/edr-payment-api/src/common/guards/service-auth.guard.ts b/apps/edr-payment-api/src/common/guards/service-auth.guard.ts new file mode 100644 index 000000000..5b5fb39c5 --- /dev/null +++ b/apps/edr-payment-api/src/common/guards/service-auth.guard.ts @@ -0,0 +1,55 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + Logger, + UnauthorizedException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { timingSafeEqual } from "node:crypto"; +import { Request } from "express"; + +/** + * Shared-secret service-to-service auth for the internal surface (/payments/*). + * Callers send `x-service-token: ` (or `Authorization: Bearer …`). + * Webhook endpoints are intentionally NOT behind this guard — they are provider-facing and + * authenticate via signature verification instead. + */ +@Injectable() +export class ServiceAuthGuard implements CanActivate { + private readonly logger = new Logger(ServiceAuthGuard.name); + private readonly token: string; + private warned = false; + + constructor(config: ConfigService) { + this.token = config.get("app.serviceAuthToken") ?? ""; + 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-payment-api/src/config/app.config.ts b/apps/edr-payment-api/src/config/app.config.ts new file mode 100644 index 000000000..c1128a872 --- /dev/null +++ b/apps/edr-payment-api/src/config/app.config.ts @@ -0,0 +1,21 @@ +import { registerAs } from "@nestjs/config"; + +export default registerAs("app", () => ({ + port: parseInt(process.env.PORT ?? "3003", 10), + /** + * Shared secret for service-to-service auth (apps -> /payments/*, payment -> mark-paid). + * Required in production; in development an empty value disables the guard with a warning. + * TODO: integrate @tria-plc IAM / mTLS as the long-term mechanism (docs/payment-service §14). + */ + serviceAuthToken: process.env.SERVICE_AUTH_TOKEN ?? "", + reconciliation: { + /** How often the stale-intent sweep runs. */ + sweepIntervalMs: parseInt( + process.env.RECONCILE_SWEEP_INTERVAL_MS ?? "60000", + 10, + ), + /** An intent is "stale" when non-terminal and untouched for this long. */ + staleAfterMs: parseInt(process.env.RECONCILE_STALE_AFTER_MS ?? "60000", 10), + batchSize: parseInt(process.env.RECONCILE_BATCH_SIZE ?? "20", 10), + }, +})); diff --git a/apps/edr-payment-api/src/config/card.config.ts b/apps/edr-payment-api/src/config/card.config.ts new file mode 100644 index 000000000..fa9d4b30c --- /dev/null +++ b/apps/edr-payment-api/src/config/card.config.ts @@ -0,0 +1,9 @@ +import { registerAs } from "@nestjs/config"; + +export default registerAs("card", () => ({ + baseUrl: process.env.CARD_BASE_URL || "", + apiKey: process.env.CARD_API_KEY || "", + webhookSecret: process.env.CARD_WEBHOOK_SECRET || "", + webhookUrl: process.env.CARD_WEBHOOK_URL || "", + returnUrl: process.env.CARD_RETURN_URL || "", +})); diff --git a/apps/edr-payment-api/src/config/cbe.config.ts b/apps/edr-payment-api/src/config/cbe.config.ts new file mode 100644 index 000000000..eb2cd689f --- /dev/null +++ b/apps/edr-payment-api/src/config/cbe.config.ts @@ -0,0 +1,9 @@ +import { registerAs } from "@nestjs/config"; + +export default registerAs("cbe", () => ({ + baseUrl: process.env.CBE_BASE_URL || "", + merchantId: process.env.CBE_MERCHANT_ID || "", + secretKey: process.env.CBE_SECRET_KEY || "", + notifyUrl: process.env.CBE_NOTIFY_URL || "", + returnUrl: process.env.CBE_RETURN_URL || "", +})); diff --git a/apps/edr-payment-api/src/config/database.config.ts b/apps/edr-payment-api/src/config/database.config.ts new file mode 100644 index 000000000..43d8ca564 --- /dev/null +++ b/apps/edr-payment-api/src/config/database.config.ts @@ -0,0 +1,36 @@ +import { registerAs } from "@nestjs/config"; +import { TypeOrmModuleOptions } from "@nestjs/typeorm"; +import { DataSourceOptions } from "typeorm"; + +/** + * Shared connection options for the Nest TypeORM module and the standalone DataSource + * (migration CLI). Payment tables live in the SAME Postgres database as the domain system + * (edr_database by default) but in the dedicated `edr_payment` schema; logical ownership is + * enforced with a dedicated DB user in non-dev environments (grants only on this schema). + */ +export function buildDataSourceOptions(): DataSourceOptions { + return { + type: "postgres", + host: process.env.DB_HOST ?? "localhost", + port: parseInt(process.env.DB_PORT ?? "5432", 10), + username: process.env.DB_USER ?? "edr", + password: process.env.DB_PASSWORD ?? "", + database: process.env.DB_NAME ?? "edr_database", + schema: process.env.DB_SCHEMA ?? "edr_payment", + entities: [__dirname + "/../**/*.entity.{ts,js}"], + migrations: [__dirname + "/../migrations/*.{ts,js}"], + // Schema changes go through migrations only — never synchronize (house rule). + synchronize: false, + logging: process.env.NODE_ENV === "development", + }; +} + +export default registerAs( + "database", + (): TypeOrmModuleOptions => ({ + ...buildDataSourceOptions(), + autoLoadEntities: true, + // Run pending migrations on boot (main.ts ensures the database/schema exist first). + migrationsRun: true, + }), +); diff --git a/apps/edr-payment-api/src/config/dmoney.config.ts b/apps/edr-payment-api/src/config/dmoney.config.ts new file mode 100644 index 000000000..78751f8d5 --- /dev/null +++ b/apps/edr-payment-api/src/config/dmoney.config.ts @@ -0,0 +1,10 @@ +import { registerAs } from "@nestjs/config"; + +export default registerAs("dmoney", () => ({ + baseUrl: process.env.DMONEY_BASE_URL ?? "", + appId: process.env.DMONEY_APP_ID ?? "", + appSecret: process.env.DMONEY_APP_SECRET ?? "", + publicKey: process.env.DMONEY_PUBLIC_KEY ?? "", + privateKey: process.env.DMONEY_PRIVATE_KEY ?? "", + notifyUrl: process.env.DMONEY_NOTIFY_URL ?? "", +})); diff --git a/apps/edr-payment-api/src/config/ebirr.config.ts b/apps/edr-payment-api/src/config/ebirr.config.ts new file mode 100644 index 000000000..4c5dd68e6 --- /dev/null +++ b/apps/edr-payment-api/src/config/ebirr.config.ts @@ -0,0 +1,9 @@ +import { registerAs } from "@nestjs/config"; + +export default registerAs("ebirr", () => ({ + baseUrl: process.env.EBIRR_BASE_URL || "", + merchantCode: process.env.EBIRR_MERCHANT_CODE || "", + secretKey: process.env.EBIRR_SECRET_KEY || "", + notifyUrl: process.env.EBIRR_NOTIFY_URL || "", + returnUrl: process.env.EBIRR_RETURN_URL || "", +})); diff --git a/apps/edr-payment-api/src/config/ensure-schema.ts b/apps/edr-payment-api/src/config/ensure-schema.ts new file mode 100644 index 000000000..9f7a5bb52 --- /dev/null +++ b/apps/edr-payment-api/src/config/ensure-schema.ts @@ -0,0 +1,52 @@ +import { Client } from "pg"; + +const IDENTIFIER = /^[a-z_][a-z0-9_]*$/; + +function connectionEnv() { + return { + host: process.env.DB_HOST ?? "localhost", + port: parseInt(process.env.DB_PORT ?? "5432", 10), + user: process.env.DB_USER ?? "edr", + password: process.env.DB_PASSWORD ?? "", + }; +} + +/** + * Dev/bootstrap convenience: make sure the `edr_payment` schema exists in the shared + * database before TypeORM initializes (the migrations table itself lives in the schema, so + * migrations cannot create it). In production the schema/grants are provisioned out-of-band + * by ops; this is then a no-op. + */ +export async function ensurePaymentSchema(): Promise { + const database = process.env.DB_NAME ?? "edr_database"; + const schema = process.env.DB_SCHEMA ?? "edr_payment"; + if (!IDENTIFIER.test(database) || !IDENTIFIER.test(schema)) { + throw new Error( + `Invalid DB_NAME/DB_SCHEMA identifier: ${database}/${schema}`, + ); + } + + let client = new Client({ ...connectionEnv(), database }); + try { + await client.connect(); + } catch (err) { + // 3D000 = database does not exist — create it from the maintenance DB, then reconnect. + if ((err as { code?: string }).code !== "3D000") throw err; + await client.end().catch(() => undefined); + const admin = new Client({ ...connectionEnv(), database: "postgres" }); + await admin.connect(); + try { + await admin.query(`CREATE DATABASE "${database}"`); + } finally { + await admin.end(); + } + client = new Client({ ...connectionEnv(), database }); + await client.connect(); + } + + try { + await client.query(`CREATE SCHEMA IF NOT EXISTS "${schema}"`); + } finally { + await client.end(); + } +} diff --git a/apps/edr-payment-api/src/config/notifier.config.ts b/apps/edr-payment-api/src/config/notifier.config.ts new file mode 100644 index 000000000..cddc66761 --- /dev/null +++ b/apps/edr-payment-api/src/config/notifier.config.ts @@ -0,0 +1,15 @@ +import { registerAs } from "@nestjs/config"; + +export default registerAs("notifier", () => ({ + /** mark-paid callback URL per owning service (PaymentService discriminator routes here). */ + passengerUrl: + process.env.PAYMENT_NOTIFY_PASSENGER_URL ?? + "http://localhost:3002/internal/payments/mark-paid", + freightUrl: + process.env.PAYMENT_NOTIFY_FREIGHT_URL ?? + "http://localhost:3001/internal/payments/mark-paid", + relayIntervalMs: parseInt(process.env.OUTBOX_RELAY_INTERVAL_MS ?? "5000", 10), + maxAttempts: parseInt(process.env.OUTBOX_MAX_ATTEMPTS ?? "10", 10), + httpTimeoutMs: parseInt(process.env.NOTIFY_HTTP_TIMEOUT_MS ?? "10000", 10), + relayBatchSize: parseInt(process.env.OUTBOX_RELAY_BATCH_SIZE ?? "20", 10), +})); diff --git a/apps/edr-payment-api/src/config/telebirr.config.ts b/apps/edr-payment-api/src/config/telebirr.config.ts new file mode 100644 index 000000000..8e5d1712a --- /dev/null +++ b/apps/edr-payment-api/src/config/telebirr.config.ts @@ -0,0 +1,16 @@ +import { registerAs } from "@nestjs/config"; + +export default registerAs("telebirr", () => ({ + baseUrl: process.env.TELEBIRR_BASE_URL ?? "", + webBaseUrl: process.env.TELEBIRR_WEB_BASE_URL ?? "", + fabricAppId: process.env.TELEBIRR_FABRIC_APP_ID ?? "", + appSecret: process.env.TELEBIRR_APP_SECRET ?? "", + merchantAppId: process.env.TELEBIRR_MERCHANT_APP_ID ?? "", + merchantCode: process.env.TELEBIRR_MERCHANT_CODE ?? "", + notifyUrl: process.env.TELEBIRR_NOTIFY_URL ?? "", + returnUrl: process.env.TELEBIRR_RETURN_URL ?? "", + timeoutExpress: process.env.TELEBIRR_TIMEOUT_EXPRESS ?? "15m", + privateKey: process.env.TELEBIRR_PRIVATE_KEY ?? "", + publicKey: process.env.TELEBIRR_PUBLIC_KEY ?? "", + insecureTls: process.env.TELEBIRR_INSECURE_TLS === "true", +})); diff --git a/apps/edr-payment-api/src/config/waafi.config.ts b/apps/edr-payment-api/src/config/waafi.config.ts new file mode 100644 index 000000000..05624922f --- /dev/null +++ b/apps/edr-payment-api/src/config/waafi.config.ts @@ -0,0 +1,27 @@ +import { registerAs } from "@nestjs/config"; + +export default registerAs("waafi", () => ({ + // `/asm` is appended in the provider; use sandbox by default, switch to + // https://api.waafipay.net in production. + baseUrl: process.env.WAAFI_BASE_URL ?? "https://sandbox.waafipay.net", + // HPP credentials (Hosted Payment Page family). + merchantUid: process.env.WAAFI_MERCHANT_UID ?? "", + storeId: process.env.WAAFI_STORE_ID ?? "", + hppKey: process.env.WAAFI_HPP_KEY ?? "", + // HMAC secret returned once by WEBHOOK_REGISTER; verifies inbound webhooks. + webhookSecret: process.env.WAAFI_WEBHOOK_SECRET ?? "", + // Wallet payment method (EVC/ZAAD/Sahal) — MWALLET_ACCOUNT requires the payer phone up front. + paymentMethod: process.env.WAAFI_PAYMENT_METHOD ?? "MWALLET_ACCOUNT", + // Waafi has no ETB; when set this overrides the asserted currency (USD/DJF/SLSH). + currency: process.env.WAAFI_CURRENCY ?? "DJF", + // Browser redirect targets after the hosted page completes/fails (UX only; webhook is source of truth). + successUrl: process.env.WAAFI_HPP_SUCCESS_URL ?? "", + failureUrl: process.env.WAAFI_HPP_FAILURE_URL ?? "", + // Callback data format: 1 = POST, 2 = GET, 4 = Result Token. + respDataFormat: Number(process.env.WAAFI_HPP_RESP_FORMAT ?? "1"), + // Registered webhook URL (reference only; registration is performed out-of-band). + notifyUrl: process.env.WAAFI_NOTIFY_URL ?? "", + // DEV ONLY: disable TLS cert verification. The Waafi sandbox serves a *.waafi.com cert that + // does not match sandbox.waafipay.net (ERR_TLS_CERT_ALTNAME_INVALID). Never enable in prod. + insecureTls: process.env.WAAFI_INSECURE_TLS === "true", +})); diff --git a/apps/edr-payment-api/src/data-source.ts b/apps/edr-payment-api/src/data-source.ts new file mode 100644 index 000000000..2aa708300 --- /dev/null +++ b/apps/edr-payment-api/src/data-source.ts @@ -0,0 +1,8 @@ +import "dotenv/config"; +import { DataSource } from "typeorm"; +import { buildDataSourceOptions } from "./config/database.config"; + +/** Standalone DataSource for the TypeORM CLI and the migrate script. */ +export const AppDataSource = new DataSource(buildDataSourceOptions()); + +export default AppDataSource; diff --git a/apps/edr-payment-api/src/main.ts b/apps/edr-payment-api/src/main.ts new file mode 100644 index 000000000..c9ad69494 --- /dev/null +++ b/apps/edr-payment-api/src/main.ts @@ -0,0 +1,54 @@ +import "reflect-metadata"; +import "dotenv/config"; +import { NestFactory } from "@nestjs/core"; +import { ValidationPipe } from "@nestjs/common"; +import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; +import { AppModule } from "./app.module"; +import { ensurePaymentSchema } from "./config/ensure-schema"; + +async function bootstrap() { + // The edr_payment database/schema must exist before TypeORM boots (migrationsRun: true). + await ensurePaymentSchema(); + + // rawBody: true buffers the unparsed request body onto req.rawBody so webhook handlers + // (e.g. Waafi HMAC verification) can sign over the exact bytes the provider signed. + const app = await NestFactory.create(AppModule, { rawBody: true }); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidUnknownValues: false, + }), + ); + + const config = new DocumentBuilder() + .setTitle("EDR Payment API") + .setDescription( + "Platform payment microservice: payment intents, provider integration, the single " + + "registered webhook per provider, and reliable (outbox) notification of the owning app. " + + "Internal endpoints (/payments/*) require the x-service-token header; /webhooks/* is the " + + "only public surface. See docs/payment-service/.", + ) + .setVersion("1.0.0") + .addApiKey( + { type: "apiKey", name: "x-service-token", in: "header" }, + "service-token", + ) + .build(); + SwaggerModule.setup( + "api-docs", + app, + SwaggerModule.createDocument(app, config), + { + customSiteTitle: "EDR Payment API", + swaggerOptions: { persistAuthorization: true }, + }, + ); + + const port = process.env.PORT ?? 3003; + await app.listen(port); + console.log(`🚀 EDR Payment API running on port ${port}`); + console.log(`📚 Swagger: http://localhost:${port}/api-docs`); +} +bootstrap(); diff --git a/apps/edr-payment-api/src/migrations/1781136000000-InitPaymentSchema.ts b/apps/edr-payment-api/src/migrations/1781136000000-InitPaymentSchema.ts new file mode 100644 index 000000000..bbff2e7f1 --- /dev/null +++ b/apps/edr-payment-api/src/migrations/1781136000000-InitPaymentSchema.ts @@ -0,0 +1,124 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Initial edr_payment schema: payment_intent, payment_webhook_event, notification_outbox. + * + * Enum-valued columns are varchar on purpose (values mirror the @edr/types enums) so new + * providers/statuses never need an ALTER TYPE. uuid defaults use gen_random_uuid() (built into + * Postgres 13+; no extension required). + */ +export class InitPaymentSchema1781136000000 implements MigrationInterface { + name = "InitPaymentSchema1781136000000"; + + public async up(queryRunner: QueryRunner): Promise { + // Defensive — bootstrap (ensure-schema) normally creates this before migrations run. + await queryRunner.query(`CREATE SCHEMA IF NOT EXISTS "edr_payment"`); + + await queryRunner.query(` + CREATE TABLE "edr_payment"."payment_intent" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + "deleted_at" timestamptz, + "service" varchar(16) NOT NULL, + "reference_type" varchar(16) NOT NULL, + "reference_id" varchar(64) NOT NULL, + "merchant_order_id" varchar(64) NOT NULL, + "provider" varchar(16) NOT NULL, + "provider_order_id" varchar(128), + "provider_txn_id" varchar(128), + "amount_minor" integer NOT NULL, + "confirmed_amount_minor" integer, + "currency" varchar(8) NOT NULL, + "status" varchar(24) NOT NULL DEFAULT 'REQUIRES_ACTION', + "client_action" jsonb, + "failure_code" varchar(64), + "failure_message" text, + "idempotency_key" varchar(128), + "expires_at" timestamptz, + "paid_at" timestamptz, + "raw_initiation" jsonb, + CONSTRAINT "pk_payment_intent" PRIMARY KEY ("id"), + CONSTRAINT "uq_payment_intent_merchant_order_id" UNIQUE ("merchant_order_id") + ) + `); + // One ACTIVE intent per domain order; terminal-failed attempts remain as audit rows. + await queryRunner.query(` + CREATE UNIQUE INDEX "uq_payment_intent_active_reference" + ON "edr_payment"."payment_intent" ("service", "reference_type", "reference_id") + WHERE status NOT IN ('FAILED','CANCELLED') AND deleted_at IS NULL + `); + await queryRunner.query(` + CREATE INDEX "idx_payment_intent_provider_txn" + ON "edr_payment"."payment_intent" ("provider_txn_id") + `); + await queryRunner.query(` + CREATE INDEX "idx_payment_intent_sweep" + ON "edr_payment"."payment_intent" ("status", "updated_at") + `); + await queryRunner.query(` + CREATE INDEX "idx_payment_intent_idempotency" + ON "edr_payment"."payment_intent" ("service", "idempotency_key") + `); + + await queryRunner.query(` + CREATE TABLE "edr_payment"."payment_webhook_event" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + "deleted_at" timestamptz, + "provider" varchar(16) NOT NULL, + "external_event_id" varchar(191) NOT NULL, + "merchant_order_id" varchar(64), + "provider_txn_id" varchar(128), + "signature_valid" boolean NOT NULL DEFAULT false, + "status" varchar(64), + "payload" jsonb NOT NULL, + "received_at" timestamptz NOT NULL DEFAULT now(), + "processed_at" timestamptz, + "processing_error" text, + CONSTRAINT "pk_payment_webhook_event" PRIMARY KEY ("id") + ) + `); + // The webhook dedupe key: duplicate provider deliveries hit this and short-circuit. + await queryRunner.query(` + CREATE UNIQUE INDEX "uq_payment_webhook_event_external" + ON "edr_payment"."payment_webhook_event" ("provider", "external_event_id") + `); + + await queryRunner.query(` + CREATE TABLE "edr_payment"."notification_outbox" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + "deleted_at" timestamptz, + "event_type" varchar(32) NOT NULL, + "service" varchar(16) NOT NULL, + "intent_id" uuid NOT NULL, + "reference_type" varchar(16) NOT NULL, + "reference_id" varchar(64) NOT NULL, + "payload" jsonb NOT NULL, + "status" varchar(16) NOT NULL DEFAULT 'PENDING', + "attempts" integer NOT NULL DEFAULT 0, + "next_retry_at" timestamptz, + "last_error" text, + "sent_at" timestamptz, + CONSTRAINT "pk_notification_outbox" PRIMARY KEY ("id") + ) + `); + await queryRunner.query(` + CREATE INDEX "idx_notification_outbox_relay" + ON "edr_payment"."notification_outbox" ("status", "next_retry_at") + `); + await queryRunner.query(` + CREATE INDEX "idx_notification_outbox_intent" + ON "edr_payment"."notification_outbox" ("intent_id") + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "edr_payment"."notification_outbox"`); + await queryRunner.query(`DROP TABLE "edr_payment"."payment_webhook_event"`); + await queryRunner.query(`DROP TABLE "edr_payment"."payment_intent"`); + } +} diff --git a/apps/edr-payment-api/src/modules/health/health.controller.ts b/apps/edr-payment-api/src/modules/health/health.controller.ts new file mode 100644 index 000000000..86eee1cc0 --- /dev/null +++ b/apps/edr-payment-api/src/modules/health/health.controller.ts @@ -0,0 +1,16 @@ +import { Controller, Get } from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; + +@ApiTags("Health") +@Controller("health") +export class HealthController { + @Get() + @ApiOperation({ summary: "Liveness probe" }) + check() { + return { + status: "ok", + service: "edr-payment-api", + timestamp: new Date().toISOString(), + }; + } +} diff --git a/apps/edr-payment-api/src/modules/health/health.module.ts b/apps/edr-payment-api/src/modules/health/health.module.ts new file mode 100644 index 000000000..40b7bdfae --- /dev/null +++ b/apps/edr-payment-api/src/modules/health/health.module.ts @@ -0,0 +1,7 @@ +import { Module } from "@nestjs/common"; +import { HealthController } from "./health.controller"; + +@Module({ + controllers: [HealthController], +}) +export class HealthModule {} 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 new file mode 100644 index 000000000..13a2f71a4 --- /dev/null +++ b/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts @@ -0,0 +1,119 @@ +import { + IsEnum, + IsIn, + IsInt, + IsOptional, + IsPositive, + IsString, + Length, + MaxLength, +} from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { + InitiatePaymentRequest, + PaymentPlatform, + PaymentReferenceType, + PaymentService, + ProviderMethod, +} from "@edr/types"; + +/** Wire shape is the shared `InitiatePaymentRequest` contract from @edr/types. */ +export class InitiatePaymentRequestDto implements InitiatePaymentRequest { + @ApiProperty({ enum: PaymentService }) + @IsEnum(PaymentService) + service!: PaymentService; + + @ApiProperty({ enum: PaymentReferenceType }) + @IsEnum(PaymentReferenceType) + referenceType!: PaymentReferenceType; + + @ApiProperty({ + description: + "Domain order id (booking/shipment id) — already validated by the calling app", + }) + @IsString() + @Length(1, 64) + referenceId!: string; + + @ApiPropertyOptional({ + description: + "Human-readable order ref shown on provider pages; defaults to referenceId", + }) + @IsOptional() + @IsString() + @MaxLength(64) + orderRef?: string; + + @ApiProperty({ + description: + "Authoritative amount in minor units, computed server-side by the app", + }) + @IsInt() + @IsPositive() + amountMinor!: number; + + @ApiProperty({ example: "ETB" }) + @IsString() + @Length(3, 8) + currency!: string; + + @ApiProperty({ enum: ProviderMethod }) + @IsEnum(ProviderMethod) + provider!: ProviderMethod; + + @ApiPropertyOptional({ enum: ["web", "mobile"] }) + @IsOptional() + @IsIn(["web", "mobile"]) + platform?: PaymentPlatform; + + @ApiPropertyOptional({ + description: + "Payer wallet MSISDN for providers that pre-fill it (e.g. Waafi MWALLET_ACCOUNT)", + }) + @IsOptional() + @IsString() + @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", + }) + @IsOptional() + @IsString() + @MaxLength(128) + idempotencyKey?: string; +} + +export class IntentReferenceQueryDto { + @ApiProperty({ enum: PaymentService }) + @IsEnum(PaymentService) + service!: PaymentService; + + @ApiProperty({ enum: PaymentReferenceType }) + @IsEnum(PaymentReferenceType) + referenceType!: PaymentReferenceType; + + @ApiProperty() + @IsString() + @Length(1, 64) + referenceId!: string; +} diff --git a/apps/edr-payment-api/src/modules/intents/entities/payment-intent.entity.ts b/apps/edr-payment-api/src/modules/intents/entities/payment-intent.entity.ts new file mode 100644 index 000000000..68c519604 --- /dev/null +++ b/apps/edr-payment-api/src/modules/intents/entities/payment-intent.entity.ts @@ -0,0 +1,135 @@ +import { Column, Entity, Index } from "typeorm"; +import { BaseEntity } from "@edr/api-common"; +import { + ClientAction, + PaymentReferenceType, + PaymentService, + ProviderMethod, + ProviderPaymentStatus, +} from "@edr/types"; + +/** + * One payment attempt for one domain order — the platform-wide source of truth for payment + * state. `reference_id` is a soft reference into the owning app's schema (never a FK; see + * docs/payment-service/architecture.md §5). + * + * Enum-valued columns are stored as varchar (values mirror the shared @edr/types enums) so + * adding a provider/status never needs an ALTER TYPE migration. + */ +@Entity({ name: "payment_intent" }) +// One ACTIVE intent per domain order; FAILED/CANCELLED attempts may accumulate as audit rows. +@Index( + "uq_payment_intent_active_reference", + ["service", "referenceType", "referenceId"], + { + unique: true, + where: `status NOT IN ('FAILED','CANCELLED') AND deleted_at IS NULL`, + }, +) +@Index("idx_payment_intent_sweep", ["status", "updatedAt"]) +@Index("idx_payment_intent_idempotency", ["service", "idempotencyKey"]) +export class PaymentIntent extends BaseEntity { + /** Owning domain app — routing discriminator for notifications. */ + @Column({ name: "service", type: "varchar", length: 16 }) + service!: PaymentService; + + @Column({ name: "reference_type", type: "varchar", length: 16 }) + referenceType!: PaymentReferenceType; + + /** Domain order id (booking/shipment). Soft reference — no cross-schema FK. */ + @Column({ name: "reference_id", type: "varchar", length: 64 }) + referenceId!: string; + + /** Provider-facing reference, prefixed PSG-/FRT- so webhooks route before a DB lookup. */ + @Column({ + name: "merchant_order_id", + type: "varchar", + length: 64, + unique: true, + }) + merchantOrderId!: string; + + @Column({ name: "provider", type: "varchar", length: 16 }) + provider!: ProviderMethod; + + /** Provider-side order/session id (prepay id, HPP orderId, …). */ + @Column({ + name: "provider_order_id", + type: "varchar", + length: 128, + nullable: true, + }) + providerOrderId?: string | null; + + /** Final provider transaction id, set on terminal success. */ + @Index("idx_payment_intent_provider_txn") + @Column({ + name: "provider_txn_id", + type: "varchar", + length: 128, + nullable: true, + }) + providerTxnId?: string | null; + + /** App-asserted authoritative amount in minor units. */ + @Column({ name: "amount_minor", type: "integer" }) + amountMinor!: number; + + /** Provider-reported amount; reconciled against amount_minor (e.g. Waafi truncates decimals). */ + @Column({ name: "confirmed_amount_minor", type: "integer", nullable: true }) + confirmedAmountMinor?: number | null; + + @Column({ name: "currency", type: "varchar", length: 8 }) + currency!: string; + + /** State machine: REQUIRES_ACTION → PROCESSING → SUCCEEDED | FAILED | CANCELLED (absorbing). */ + @Column({ + name: "status", + type: "varchar", + length: 24, + default: ProviderPaymentStatus.REQUIRES_ACTION, + }) + status!: ProviderPaymentStatus; + + /** Redirect/launch payload returned to the app for the user to complete payment. */ + @Column({ name: "client_action", type: "jsonb", nullable: true }) + clientAction?: ClientAction | null; + + @Column({ name: "failure_code", type: "varchar", length: 64, nullable: true }) + failureCode?: string | null; + + @Column({ name: "failure_message", type: "text", nullable: true }) + failureMessage?: string | null; + + /** Caller-supplied initiate dedupe key (in addition to the per-reference upsert). */ + @Column({ + name: "idempotency_key", + type: "varchar", + length: 128, + nullable: true, + }) + idempotencyKey?: string | null; + + @Column({ name: "expires_at", type: "timestamptz", nullable: true }) + expiresAt?: Date | null; + + @Column({ name: "paid_at", type: "timestamptz", nullable: true }) + paidAt?: Date | null; + + /** Audit copy of the provider initiation request/response (secrets redacted upstream). */ + @Column({ name: "raw_initiation", type: "jsonb", nullable: true }) + rawInitiation?: Record | null; +} + +/** Statuses that keep the per-reference unique index "active" (block a new intent). */ +export const ACTIVE_INTENT_STATUSES = [ + ProviderPaymentStatus.REQUIRES_ACTION, + ProviderPaymentStatus.PROCESSING, + ProviderPaymentStatus.SUCCEEDED, +] as const; + +export const TERMINAL_INTENT_STATUSES = [ + ProviderPaymentStatus.SUCCEEDED, + ProviderPaymentStatus.FAILED, + ProviderPaymentStatus.CANCELLED, +] as const; diff --git a/apps/edr-payment-api/src/modules/intents/intents.controller.ts b/apps/edr-payment-api/src/modules/intents/intents.controller.ts new file mode 100644 index 000000000..858ad3bae --- /dev/null +++ b/apps/edr-payment-api/src/modules/intents/intents.controller.ts @@ -0,0 +1,69 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, + UseGuards, +} from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { PaymentIntentSnapshot } from "@edr/types"; +import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; +import { + InitiatePaymentRequestDto, + IntentReferenceQueryDto, +} from "./dto/initiate-payment.dto"; +import { IntentsService } from "./intents.service"; + +/** + * Internal surface — called only by the domain apps (service-authenticated), never by + * browsers. Domain validation ("is this booking payable", authoritative amount) has already + * happened in the calling app. + */ +@ApiTags("Payments (internal)") +@UseGuards(ServiceAuthGuard) +@Controller("payments") +export class IntentsController { + constructor(private readonly intentsService: IntentsService) {} + + @Post("initiate") + @ApiOperation({ + summary: + "Create (or idempotently reuse) a payment intent and open a provider session", + description: + "One active intent per (service, referenceType, referenceId). Re-initiating a non-terminal intent returns the existing clientAction.", + }) + async initiate( + @Body() dto: InitiatePaymentRequestDto, + ): Promise { + return this.intentsService.initiate(dto); + } + + @Get("intents/:id") + @ApiOperation({ + summary: "Intent status by id (pull/reconcile)", + description: + "Stale non-terminal intents trigger a provider status query before returning.", + }) + async getIntent( + @Param("id", ParseUUIDPipe) id: string, + ): Promise { + return this.intentsService.getIntent(id); + } + + @Get("intents") + @ApiOperation({ + summary: "Active intent status by domain reference (pull/reconcile)", + }) + async getIntentByReference( + @Query() query: IntentReferenceQueryDto, + ): Promise { + return this.intentsService.getIntentByReference( + query.service, + query.referenceType, + query.referenceId, + ); + } +} diff --git a/apps/edr-payment-api/src/modules/intents/intents.module.ts b/apps/edr-payment-api/src/modules/intents/intents.module.ts new file mode 100644 index 000000000..9e9774ad9 --- /dev/null +++ b/apps/edr-payment-api/src/modules/intents/intents.module.ts @@ -0,0 +1,21 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { ProvidersModule } from "../providers/providers.module"; +import { NotificationOutbox } from "../outbox/entities/notification-outbox.entity"; +import { PaymentIntent } from "./entities/payment-intent.entity"; +import { IntentsController } from "./intents.controller"; +import { IntentsRepository } from "./intents.repository"; +import { IntentsService } from "./intents.service"; + +@Module({ + // NotificationOutbox is registered here because terminal transitions insert outbox rows + // inside the intent-finalizing transaction (transactional outbox). + imports: [ + TypeOrmModule.forFeature([PaymentIntent, NotificationOutbox]), + ProvidersModule, + ], + controllers: [IntentsController], + providers: [IntentsService, IntentsRepository], + exports: [IntentsService, IntentsRepository], +}) +export class IntentsModule {} diff --git a/apps/edr-payment-api/src/modules/intents/intents.repository.ts b/apps/edr-payment-api/src/modules/intents/intents.repository.ts new file mode 100644 index 000000000..a17407069 --- /dev/null +++ b/apps/edr-payment-api/src/modules/intents/intents.repository.ts @@ -0,0 +1,73 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { In, LessThan, Not, Repository } from "typeorm"; +import { BaseRepository } from "@edr/api-common"; +import { + PaymentReferenceType, + PaymentService, + ProviderPaymentStatus, +} from "@edr/types"; +import { PaymentIntent } from "./entities/payment-intent.entity"; + +@Injectable() +export class IntentsRepository extends BaseRepository { + constructor( + @InjectRepository(PaymentIntent) + repository: Repository, + ) { + super(repository); + } + + /** The single non-FAILED/CANCELLED intent for a domain order (matches the partial unique index). */ + async findActiveByReference( + service: PaymentService, + referenceType: PaymentReferenceType, + referenceId: string, + ): Promise { + return this.repository.findOne({ + where: { + service, + referenceType, + referenceId, + status: Not( + In([ProviderPaymentStatus.FAILED, ProviderPaymentStatus.CANCELLED]), + ), + }, + order: { createdAt: "DESC" }, + }); + } + + async findByMerchantOrderId( + merchantOrderId: string, + ): Promise { + return this.repository.findOne({ where: { merchantOrderId } }); + } + + async findByIdempotencyKey( + service: PaymentService, + idempotencyKey: string, + ): Promise { + return this.repository.findOne({ + where: { service, idempotencyKey }, + order: { createdAt: "DESC" }, + }); + } + + /** Non-terminal intents untouched since `updatedBefore` — input for the reconciliation sweep. */ + async findStale( + updatedBefore: Date, + limit: number, + ): Promise { + return this.repository.find({ + where: { + status: In([ + ProviderPaymentStatus.REQUIRES_ACTION, + ProviderPaymentStatus.PROCESSING, + ]), + updatedAt: LessThan(updatedBefore), + }, + order: { updatedAt: "ASC" }, + take: limit, + }); + } +} diff --git a/apps/edr-payment-api/src/modules/intents/intents.service.ts b/apps/edr-payment-api/src/modules/intents/intents.service.ts new file mode 100644 index 000000000..492a86b78 --- /dev/null +++ b/apps/edr-payment-api/src/modules/intents/intents.service.ts @@ -0,0 +1,343 @@ +import { + BadRequestException, + Inject, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { DataSource, QueryFailedError } from "typeorm"; +import { createMerchantOrderId } from "@edr/payment-providers"; +import { + InitiatePaymentRequest, + PaymentIntentSnapshot, + PaymentReferenceType, + PaymentService, + ProviderPaymentStatus, + ProviderStatus, +} from "@edr/types"; +import { + PAYMENT_PROVIDER_MAP, + PaymentProviderMap, +} from "../providers/providers.module"; +import { NotificationOutbox } from "../outbox/entities/notification-outbox.entity"; +import { buildOutboxRow } from "../outbox/payment-event.factory"; +import { + PaymentIntent, + TERMINAL_INTENT_STATUSES, +} from "./entities/payment-intent.entity"; +import { IntentsRepository } from "./intents.repository"; + +const PG_UNIQUE_VIOLATION = "23505"; +/** Don't hit the provider again if the intent was refreshed this recently. */ +const REFRESH_MIN_AGE_MS = 5_000; + +/** Result of a provider signal (webhook or status query) applied to the state machine. */ +export interface ProviderResultInput { + status: ProviderPaymentStatus; + providerTxnId?: string; + paidAt?: Date; + confirmedAmountMinor?: number; + failureCode?: string; + failureMessage?: string; +} + +@Injectable() +export class IntentsService { + private readonly logger = new Logger(IntentsService.name); + + constructor( + private readonly intentsRepository: IntentsRepository, + // DataSource is used only for the finalize transaction (intent update + outbox insert + // must commit atomically); routine access still goes through the custom repository. + private readonly dataSource: DataSource, + @Inject(PAYMENT_PROVIDER_MAP) + private readonly providers: PaymentProviderMap, + ) {} + + /* ------------------------------------------------------------------ initiate */ + + async initiate( + request: InitiatePaymentRequest, + ): Promise { + + if (request.idempotencyKey) { + const byKey = await this.intentsRepository.findByIdempotencyKey( + request.service, + request.idempotencyKey, + ); + if (byKey) return this.toSnapshot(byKey); + } + + const existing = await this.intentsRepository.findActiveByReference( + request.service, + request.referenceType, + request.referenceId, + ); + if (existing) { + const reusable = await this.reuseOrRetire(existing); + if (reusable) return this.toSnapshot(reusable); + } + + const provider = this.providers.get(request.provider); + if (!provider) { + throw new BadRequestException( + `Unsupported payment provider: ${request.provider}`, + ); + } + + const merchantOrderId = createMerchantOrderId(); + const result = await provider.initiate({ + merchantOrderId, + orderRef: request.orderRef ?? request.referenceId, + amountMinor: request.amountMinor, + currency: request.currency, + platform: request.platform, + payerAccount: request.payerAccount, + returnUrl: request.returnUrl, + redirectUrl: request.returnUrl, + failureUrl: request.failureUrl, + }); + + try { + const intent = await this.intentsRepository.create({ + service: request.service, + referenceType: request.referenceType, + referenceId: request.referenceId, + merchantOrderId, + provider: request.provider, + providerOrderId: result.providerOrderId, + amountMinor: request.amountMinor, + currency: request.currency, + status: ProviderPaymentStatus.REQUIRES_ACTION, + clientAction: result.clientAction, + idempotencyKey: request.idempotencyKey ?? null, + expiresAt: result.expiresAt, + rawInitiation: result.rawInitiation, + }); + this.logger.log( + `intent ${intent.id} created: ${request.service}/${request.referenceType}/${request.referenceId} via ${request.provider} (${merchantOrderId})`, + ); + return this.toSnapshot(intent); + } catch (err) { + if ( + err instanceof QueryFailedError && + (err.driverError as { code?: string })?.code === PG_UNIQUE_VIOLATION + ) { + const winner = await this.intentsRepository.findActiveByReference( + request.service, + request.referenceType, + request.referenceId, + ); + if (winner) return this.toSnapshot(winner); + } + throw err; + } + } + + /** + * Decide whether an existing active intent can be returned as-is. An expired + * REQUIRES_ACTION intent is retired (CANCELLED, no notification — nothing was paid) + * so a fresh provider session can be opened. + */ + private async reuseOrRetire( + intent: PaymentIntent, + ): Promise { + const expired = + intent.status === ProviderPaymentStatus.REQUIRES_ACTION && + intent.expiresAt != null && + intent.expiresAt.getTime() < Date.now(); + if (!expired) return intent; + + await this.intentsRepository.update(intent.id, { + status: ProviderPaymentStatus.CANCELLED, + failureCode: "EXPIRED", + failureMessage: "Provider session expired before the payer acted", + }); + return null; + } + + /* ------------------------------------------------------------------ lookups */ + + async getIntent(id: string): Promise { + const intent = await this.intentsRepository.findById(id); + if (!intent) throw new NotFoundException("PaymentIntent not found"); + return this.toSnapshot(await this.refreshIfStale(intent)); + } + + async getIntentByReference( + service: PaymentService, + referenceType: PaymentReferenceType, + referenceId: string, + ): Promise { + const intent = await this.intentsRepository.findActiveByReference( + service, + referenceType, + referenceId, + ); + if (!intent) throw new NotFoundException("PaymentIntent not found"); + return this.toSnapshot(await this.refreshIfStale(intent)); + } + + /** + * Pull-side reconciliation: when a polled intent is non-terminal and stale, ask the + * provider for the truth and run the answer through the state machine. The browser + * redirect never confirms payment — this query (or a webhook) does. + */ + private async refreshIfStale(intent: PaymentIntent): Promise { + const refreshable = + intent.status === ProviderPaymentStatus.REQUIRES_ACTION || + intent.status === ProviderPaymentStatus.PROCESSING; + const stale = intent.updatedAt.getTime() < Date.now() - REFRESH_MIN_AGE_MS; + const provider = this.providers.get(intent.provider); + if (!refreshable || !stale || !provider) return intent; + + try { + const status = await provider.queryStatus(intent.merchantOrderId); + await this.applyProviderResult( + intent.id, + this.fromProviderStatus(status), + ); + return (await this.intentsRepository.findById(intent.id)) ?? intent; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.warn( + `queryStatus failed for intent ${intent.id}: ${message}; returning cached`, + ); + return intent; + } + } + + fromProviderStatus(status: ProviderStatus): ProviderResultInput { + return { + status: status.status, + providerTxnId: status.providerTxnId, + failureCode: status.failureCode, + failureMessage: status.failureMessage, + }; + } + + /* ------------------------------------------------------------------ state machine */ + + /** + * Advance the intent state machine with a verified provider signal. Terminal states are + * absorbing; a terminal transition writes the notification_outbox row IN THE SAME + * TRANSACTION as the intent update (transactional outbox — architecture.md §8). + */ + async applyProviderResult( + intentId: string, + result: ProviderResultInput, + ): Promise<{ alreadyTerminal: boolean }> { + return this.dataSource.transaction(async (manager) => { + const intent = await manager + .getRepository(PaymentIntent) + .createQueryBuilder("intent") + .setLock("pessimistic_write") + .where("intent.id = :intentId", { intentId }) + .getOne(); + if (!intent) throw new NotFoundException("PaymentIntent not found"); + + if ( + (TERMINAL_INTENT_STATUSES as readonly ProviderPaymentStatus[]).includes( + intent.status, + ) + ) { + return { alreadyTerminal: true }; + } + + if (result.status === ProviderPaymentStatus.SUCCEEDED) { + const paidAt = result.paidAt ?? new Date(); + intent.status = ProviderPaymentStatus.SUCCEEDED; + intent.providerTxnId = result.providerTxnId ?? intent.providerTxnId; + intent.paidAt = paidAt; + intent.confirmedAmountMinor = + result.confirmedAmountMinor ?? intent.confirmedAmountMinor; + intent.failureCode = null; + intent.failureMessage = null; + await manager.save(intent); + await manager.getRepository(NotificationOutbox).save( + buildOutboxRow(intent, { + eventType: "payment.succeeded", + providerTxnId: intent.providerTxnId ?? undefined, + paidAt, + }), + ); + if ( + result.confirmedAmountMinor != null && + result.confirmedAmountMinor !== intent.amountMinor + ) { + this.logger.error( + `intent ${intent.id} amount mismatch: asserted=${intent.amountMinor} confirmed=${result.confirmedAmountMinor}`, + ); + } + this.logger.log( + `intent ${intent.id} SUCCEEDED (txn=${intent.providerTxnId ?? "n/a"})`, + ); + return { alreadyTerminal: false }; + } + + if ( + result.status === ProviderPaymentStatus.FAILED || + result.status === ProviderPaymentStatus.CANCELLED + ) { + intent.status = result.status; + intent.providerTxnId = result.providerTxnId ?? intent.providerTxnId; + intent.failureCode = result.failureCode ?? null; + intent.failureMessage = result.failureMessage ?? null; + await manager.save(intent); + await manager.getRepository(NotificationOutbox).save( + buildOutboxRow(intent, { + eventType: "payment.failed", + failureCode: result.failureCode, + failureMessage: result.failureMessage, + }), + ); + this.logger.log( + `intent ${intent.id} ${result.status} (${result.failureCode ?? "n/a"})`, + ); + return { alreadyTerminal: false }; + } + + // Non-terminal: REQUIRES_ACTION may move to PROCESSING; never the reverse. + if ( + result.status === ProviderPaymentStatus.PROCESSING && + intent.status === ProviderPaymentStatus.REQUIRES_ACTION + ) { + intent.status = ProviderPaymentStatus.PROCESSING; + } + intent.providerTxnId = result.providerTxnId ?? intent.providerTxnId; + await manager.save(intent); + return { alreadyTerminal: false }; + }); + } + + /** Expire an abandoned intent (reconciliation sweep) — CANCELLED + payment.failed event. */ + async expireIntent(intentId: string): Promise { + await this.applyProviderResult(intentId, { + status: ProviderPaymentStatus.CANCELLED, + failureCode: "EXPIRED", + failureMessage: "Payment session expired before completion", + }); + } + + /* ------------------------------------------------------------------ mapping */ + + toSnapshot(intent: PaymentIntent): PaymentIntentSnapshot { + return { + intentId: intent.id, + service: intent.service, + referenceType: intent.referenceType, + referenceId: intent.referenceId, + merchantOrderId: intent.merchantOrderId, + provider: intent.provider, + status: intent.status, + amountMinor: intent.amountMinor, + currency: intent.currency, + clientAction: intent.clientAction ?? undefined, + providerTxnId: intent.providerTxnId ?? undefined, + paidAt: intent.paidAt?.toISOString(), + failureCode: intent.failureCode ?? undefined, + failureMessage: intent.failureMessage ?? undefined, + expiresAt: intent.expiresAt?.toISOString(), + }; + } +} diff --git a/apps/edr-payment-api/src/modules/outbox/entities/notification-outbox.entity.ts b/apps/edr-payment-api/src/modules/outbox/entities/notification-outbox.entity.ts new file mode 100644 index 000000000..55b34e968 --- /dev/null +++ b/apps/edr-payment-api/src/modules/outbox/entities/notification-outbox.entity.ts @@ -0,0 +1,55 @@ +import { Column, Entity, Index } from "typeorm"; +import { BaseEntity } from "@edr/api-common"; +import { + PaymentEvent, + PaymentEventType, + PaymentReferenceType, + PaymentService, +} from "@edr/types"; + +export type OutboxStatus = "PENDING" | "SENT" | "FAILED"; + +/** + * Transactional outbox: a row is inserted in the SAME transaction that finalizes an intent, + * so "payment succeeded" and "a notification is owed" commit or roll back together. The relay + * drains PENDING rows and retries until acked (at-least-once delivery; consumers are idempotent). + */ +@Entity({ name: "notification_outbox" }) +@Index("idx_notification_outbox_relay", ["status", "nextRetryAt"]) +export class NotificationOutbox extends BaseEntity { + @Column({ name: "event_type", type: "varchar", length: 32 }) + eventType!: PaymentEventType; + + /** Routing discriminator — which app's mark-paid endpoint the relay delivers to. */ + @Column({ name: "service", type: "varchar", length: 16 }) + service!: PaymentService; + + @Index("idx_notification_outbox_intent") + @Column({ name: "intent_id", type: "uuid" }) + intentId!: string; + + @Column({ name: "reference_type", type: "varchar", length: 16 }) + referenceType!: PaymentReferenceType; + + @Column({ name: "reference_id", type: "varchar", length: 64 }) + referenceId!: string; + + /** The full versioned event envelope delivered verbatim to the consumer. */ + @Column({ name: "payload", type: "jsonb" }) + payload!: PaymentEvent; + + @Column({ name: "status", type: "varchar", length: 16, default: "PENDING" }) + status!: OutboxStatus; + + @Column({ name: "attempts", type: "integer", default: 0 }) + attempts!: number; + + @Column({ name: "next_retry_at", type: "timestamptz", nullable: true }) + nextRetryAt?: Date | null; + + @Column({ name: "last_error", type: "text", nullable: true }) + lastError?: string | null; + + @Column({ name: "sent_at", type: "timestamptz", nullable: true }) + sentAt?: Date | null; +} diff --git a/apps/edr-payment-api/src/modules/outbox/outbox-relay.service.ts b/apps/edr-payment-api/src/modules/outbox/outbox-relay.service.ts new file mode 100644 index 000000000..f1d8076a5 --- /dev/null +++ b/apps/edr-payment-api/src/modules/outbox/outbox-relay.service.ts @@ -0,0 +1,119 @@ +import { + Inject, + Injectable, + Logger, + OnModuleDestroy, + OnModuleInit, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { SchedulerRegistry } from "@nestjs/schedule"; +import { NotificationOutbox } from "./entities/notification-outbox.entity"; +import { OutboxRepository } from "./outbox.repository"; +import { + PAYMENT_EVENT_PUBLISHER, + PaymentEventPublisher, +} from "./publisher/payment-event-publisher"; + +const RELAY_INTERVAL_NAME = "outbox-relay"; +/** Retry backoff: base doubles per attempt, capped. */ +const BACKOFF_BASE_MS = 10_000; +const BACKOFF_CAP_MS = 10 * 60_000; + +/** + * Drains the transactional outbox: PENDING rows are published (HTTP now, RabbitMQ later), + * marked SENT on ack, retried with exponential backoff on failure, and flagged FAILED after + * OUTBOX_MAX_ATTEMPTS (an alertable condition — delivery is at-least-once, never dropped + * silently). A crash between commit and publish only delays delivery. + */ +@Injectable() +export class OutboxRelayService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(OutboxRelayService.name); + private readonly intervalMs: number; + private readonly maxAttempts: number; + private readonly batchSize: number; + private draining = false; + + constructor( + config: ConfigService, + private readonly outboxRepository: OutboxRepository, + private readonly schedulerRegistry: SchedulerRegistry, + @Inject(PAYMENT_EVENT_PUBLISHER) + private readonly publisher: PaymentEventPublisher, + ) { + this.intervalMs = config.get("notifier.relayIntervalMs") ?? 5_000; + this.maxAttempts = config.get("notifier.maxAttempts") ?? 10; + this.batchSize = config.get("notifier.relayBatchSize") ?? 20; + } + + onModuleInit(): void { + const interval = setInterval(() => void this.drain(), this.intervalMs); + this.schedulerRegistry.addInterval(RELAY_INTERVAL_NAME, interval); + } + + onModuleDestroy(): void { + if (this.schedulerRegistry.doesExist("interval", RELAY_INTERVAL_NAME)) { + this.schedulerRegistry.deleteInterval(RELAY_INTERVAL_NAME); + } + } + + /** One relay pass; re-entrant ticks are skipped so slow deliveries don't overlap. */ + async drain(): Promise { + if (this.draining) return; + this.draining = true; + try { + const due = await this.outboxRepository.findDue(this.batchSize); + for (const row of due) { + await this.deliver(row); + } + } catch (err) { + this.logger.error( + `relay pass failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } finally { + this.draining = false; + } + } + + private async deliver(row: NotificationOutbox): Promise { + try { + await this.publisher.publish(row.payload); + await this.outboxRepository.markSent(row.id); + } catch (err) { + const message = this.describeError(err); + const attempts = row.attempts + 1; + const exhausted = attempts >= this.maxAttempts; + const backoffMs = Math.min( + BACKOFF_BASE_MS * 2 ** row.attempts, + BACKOFF_CAP_MS, + ); + await this.outboxRepository.markAttemptFailed( + row, + message, + exhausted ? null : new Date(Date.now() + backoffMs), + exhausted, + ); + if (exhausted) { + // ALERT: a paid order may not be confirmed in the owning app — needs operator action. + this.logger.error( + `outbox ${row.id} (${row.eventType} intent=${row.intentId}) FAILED after ${attempts} attempts: ${message}`, + ); + } else { + this.logger.warn( + `outbox ${row.id} delivery attempt ${attempts} failed (retry in ${backoffMs}ms): ${message}`, + ); + } + } + } + + /** Connection failures surface as AggregateError with an empty message — dig out the code. */ + private describeError(err: unknown): string { + if (err instanceof Error) { + if (err.message) return err.message; + const code = (err as { code?: string }).code; + if (code) return code; + const inner = (err as { errors?: unknown[] }).errors?.[0]; + if (inner instanceof Error && inner.message) return inner.message; + } + return String(err); + } +} diff --git a/apps/edr-payment-api/src/modules/outbox/outbox.module.ts b/apps/edr-payment-api/src/modules/outbox/outbox.module.ts new file mode 100644 index 000000000..85f464afe --- /dev/null +++ b/apps/edr-payment-api/src/modules/outbox/outbox.module.ts @@ -0,0 +1,20 @@ +import { Module } from "@nestjs/common"; +import { HttpModule } from "@nestjs/axios"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { NotificationOutbox } from "./entities/notification-outbox.entity"; +import { OutboxRelayService } from "./outbox-relay.service"; +import { OutboxRepository } from "./outbox.repository"; +import { HttpPaymentEventPublisher } from "./publisher/http-payment-event-publisher"; +import { PAYMENT_EVENT_PUBLISHER } from "./publisher/payment-event-publisher"; + +@Module({ + imports: [TypeOrmModule.forFeature([NotificationOutbox]), HttpModule], + providers: [ + OutboxRepository, + OutboxRelayService, + // Swap to RabbitPaymentEventPublisher here when the broker lands — nothing else changes. + { provide: PAYMENT_EVENT_PUBLISHER, useClass: HttpPaymentEventPublisher }, + ], + exports: [OutboxRepository], +}) +export class OutboxModule {} diff --git a/apps/edr-payment-api/src/modules/outbox/outbox.repository.ts b/apps/edr-payment-api/src/modules/outbox/outbox.repository.ts new file mode 100644 index 000000000..20000d9c6 --- /dev/null +++ b/apps/edr-payment-api/src/modules/outbox/outbox.repository.ts @@ -0,0 +1,58 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { BaseRepository } from "@edr/api-common"; +import { NotificationOutbox } from "./entities/notification-outbox.entity"; + +@Injectable() +export class OutboxRepository extends BaseRepository { + constructor( + @InjectRepository(NotificationOutbox) + repository: Repository, + ) { + super(repository); + } + + /** + * PENDING rows whose retry time has come, oldest first. The relay runs as a single + * non-overlapping loop per instance; with multiple service instances this should move to a + * SELECT … FOR UPDATE SKIP LOCKED claim. + */ + async findDue(limit: number): Promise { + return this.repository + .createQueryBuilder("outbox") + .where(`outbox.status = 'PENDING'`) + .andWhere( + "(outbox.next_retry_at IS NULL OR outbox.next_retry_at <= now())", + ) + .orderBy("outbox.created_at", "ASC") + .take(limit) + .getMany(); + } + + async markSent(id: string): Promise { + await this.update(id, { + status: "SENT", + sentAt: new Date(), + lastError: null, + }); + } + + async markAttemptFailed( + row: NotificationOutbox, + error: string, + nextRetryAt: Date | null, + exhausted: boolean, + ): Promise { + await this.update(row.id, { + attempts: row.attempts + 1, + lastError: error, + nextRetryAt, + status: exhausted ? "FAILED" : "PENDING", + }); + } + + async countBacklog(): Promise { + return this.repository.count({ where: { status: "PENDING" } }); + } +} diff --git a/apps/edr-payment-api/src/modules/outbox/payment-event.factory.ts b/apps/edr-payment-api/src/modules/outbox/payment-event.factory.ts new file mode 100644 index 000000000..85b67c444 --- /dev/null +++ b/apps/edr-payment-api/src/modules/outbox/payment-event.factory.ts @@ -0,0 +1,66 @@ +import { randomUUID } from "node:crypto"; +import { + PaymentEvent, + PaymentFailedEvent, + PaymentSucceededEvent, +} from "@edr/types"; +import { PaymentIntent } from "../intents/entities/payment-intent.entity"; +import { NotificationOutbox } from "./entities/notification-outbox.entity"; + +/** + * Build a ready-to-insert outbox row for a terminal intent. Pure (no DI) so the intents + * state machine can insert it inside its own DB transaction without a module cycle. + * The row id is generated here because the event envelope embeds it as `eventId`. + */ +export function buildOutboxRow( + intent: PaymentIntent, + terminal: + | { eventType: "payment.succeeded"; providerTxnId?: string; paidAt: Date } + | { + eventType: "payment.failed"; + failureCode?: string; + failureMessage?: string; + }, +): Partial { + const id = randomUUID(); + const base = { + version: 1 as const, + eventId: id, + occurredAt: new Date().toISOString(), + service: intent.service, + intentId: intent.id, + referenceType: intent.referenceType, + referenceId: intent.referenceId, + merchantOrderId: intent.merchantOrderId, + provider: intent.provider, + amountMinor: intent.amountMinor, + currency: intent.currency, + }; + + const event: PaymentEvent = + terminal.eventType === "payment.succeeded" + ? ({ + ...base, + eventType: "payment.succeeded", + providerTxnId: terminal.providerTxnId, + paidAt: terminal.paidAt.toISOString(), + } satisfies PaymentSucceededEvent) + : ({ + ...base, + eventType: "payment.failed", + failureCode: terminal.failureCode, + failureMessage: terminal.failureMessage, + } satisfies PaymentFailedEvent); + + return { + id, + eventType: event.eventType, + service: intent.service, + intentId: intent.id, + referenceType: intent.referenceType, + referenceId: intent.referenceId, + payload: event, + status: "PENDING", + attempts: 0, + }; +} diff --git a/apps/edr-payment-api/src/modules/outbox/publisher/http-payment-event-publisher.ts b/apps/edr-payment-api/src/modules/outbox/publisher/http-payment-event-publisher.ts new file mode 100644 index 000000000..867119d09 --- /dev/null +++ b/apps/edr-payment-api/src/modules/outbox/publisher/http-payment-event-publisher.ts @@ -0,0 +1,53 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { HttpService } from "@nestjs/axios"; +import { firstValueFrom } from "rxjs"; +import { PaymentEvent, PaymentService } from "@edr/types"; +import { PaymentEventPublisher } from "./payment-event-publisher"; + +/** + * Delivers events by POSTing to the owning app's idempotent mark-paid endpoint, routed by + * the `service` discriminator. Authenticated with the shared service token (the same secret + * the apps use to call /payments/initiate). + */ +@Injectable() +export class HttpPaymentEventPublisher implements PaymentEventPublisher { + private readonly logger = new Logger(HttpPaymentEventPublisher.name); + private readonly routes: Record; + private readonly timeoutMs: number; + private readonly serviceToken: string; + + constructor( + config: ConfigService, + private readonly http: HttpService, + ) { + this.routes = { + [PaymentService.PASSENGER]: + config.get("notifier.passengerUrl") ?? "", + [PaymentService.FREIGHT]: config.get("notifier.freightUrl") ?? "", + }; + this.timeoutMs = config.get("notifier.httpTimeoutMs") ?? 10_000; + this.serviceToken = config.get("app.serviceAuthToken") ?? ""; + } + + async publish(event: PaymentEvent): Promise { + const url = this.routes[event.service]; + if (!url) { + throw new Error( + `No mark-paid URL configured for service ${event.service}`, + ); + } + + const response = await firstValueFrom( + this.http.post(url, event, { + timeout: this.timeoutMs, + headers: this.serviceToken + ? { "x-service-token": this.serviceToken } + : {}, + }), + ); + this.logger.log( + `delivered ${event.eventType} (${event.eventId}) to ${event.service} — HTTP ${response.status}`, + ); + } +} diff --git a/apps/edr-payment-api/src/modules/outbox/publisher/payment-event-publisher.ts b/apps/edr-payment-api/src/modules/outbox/publisher/payment-event-publisher.ts new file mode 100644 index 000000000..7586c1509 --- /dev/null +++ b/apps/edr-payment-api/src/modules/outbox/publisher/payment-event-publisher.ts @@ -0,0 +1,13 @@ +import { PaymentEvent } from "@edr/types"; + +/** + * Publisher port (architecture.md §12): how a payment event leaves this service. + * HTTP implementation now; a RabbitMQ implementation later is a DI swap only — the outbox + * and relay stay exactly as they are. + */ +export interface PaymentEventPublisher { + /** Deliver one event; throw on failure so the relay can retry with backoff. */ + publish(event: PaymentEvent): Promise; +} + +export const PAYMENT_EVENT_PUBLISHER = Symbol("PAYMENT_EVENT_PUBLISHER"); diff --git a/apps/edr-payment-api/src/modules/providers/providers.module.ts b/apps/edr-payment-api/src/modules/providers/providers.module.ts new file mode 100644 index 000000000..af2983ee4 --- /dev/null +++ b/apps/edr-payment-api/src/modules/providers/providers.module.ts @@ -0,0 +1,46 @@ +import { Module } from "@nestjs/common"; +import { HttpModule } from "@nestjs/axios"; +import { + CardProvider, + CbeBirrProvider, + DMoneyProvider, + EBirrProvider, + PaymentProvider, + TelebirrProvider, + WaafiProvider, +} from "@edr/payment-providers"; +import { ProviderMethod } from "@edr/types"; + +/** Injection token for the Map used to select a gateway. */ +export const PAYMENT_PROVIDER_MAP = Symbol("PAYMENT_PROVIDER_MAP"); + +export type PaymentProviderMap = Map; + +const providerClasses = [ + TelebirrProvider, + CbeBirrProvider, + EBirrProvider, + CardProvider, + WaafiProvider, + DMoneyProvider, +]; + +/** + * Thin DI wiring around @edr/payment-providers — the exact provider set the passenger app + * used to construct, relocated here. After cutover this service is the only consumer of the + * provider SDK and of the provider secrets (config/{waafi,telebirr,…}.config.ts). + */ +@Module({ + imports: [HttpModule.register({ timeout: 10_000 })], + providers: [ + ...providerClasses, + { + provide: PAYMENT_PROVIDER_MAP, + useFactory: (...providers: PaymentProvider[]): PaymentProviderMap => + new Map(providers.map((provider) => [provider.method, provider])), + inject: providerClasses, + }, + ], + exports: [PAYMENT_PROVIDER_MAP, ...providerClasses], +}) +export class ProvidersModule {} diff --git a/apps/edr-payment-api/src/modules/reconciliation/reconciliation.module.ts b/apps/edr-payment-api/src/modules/reconciliation/reconciliation.module.ts new file mode 100644 index 000000000..b424dcfec --- /dev/null +++ b/apps/edr-payment-api/src/modules/reconciliation/reconciliation.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { IntentsModule } from "../intents/intents.module"; +import { ProvidersModule } from "../providers/providers.module"; +import { ReconciliationService } from "./reconciliation.service"; + +@Module({ + imports: [IntentsModule, ProvidersModule], + providers: [ReconciliationService], +}) +export class ReconciliationModule {} diff --git a/apps/edr-payment-api/src/modules/reconciliation/reconciliation.service.ts b/apps/edr-payment-api/src/modules/reconciliation/reconciliation.service.ts new file mode 100644 index 000000000..5216798f2 --- /dev/null +++ b/apps/edr-payment-api/src/modules/reconciliation/reconciliation.service.ts @@ -0,0 +1,114 @@ +import { + Inject, + Injectable, + Logger, + OnModuleDestroy, + OnModuleInit, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { SchedulerRegistry } from "@nestjs/schedule"; +import { ProviderPaymentStatus } from "@edr/types"; +import { + PAYMENT_PROVIDER_MAP, + PaymentProviderMap, +} from "../providers/providers.module"; +import { PaymentIntent } from "../intents/entities/payment-intent.entity"; +import { IntentsRepository } from "../intents/intents.repository"; +import { IntentsService } from "../intents/intents.service"; + +const SWEEP_INTERVAL_NAME = "reconciliation-sweep"; + +/** + * Safety net (architecture.md §7.4): webhooks get lost, users abandon hosted pages. The sweep + * queries the provider for stale non-terminal intents and feeds the answer through the same + * state machine the webhooks use; intents whose provider session expired are CANCELLED. + */ +@Injectable() +export class ReconciliationService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(ReconciliationService.name); + private readonly intervalMs: number; + private readonly staleAfterMs: number; + private readonly batchSize: number; + private sweeping = false; + + constructor( + config: ConfigService, + private readonly intentsRepository: IntentsRepository, + private readonly intentsService: IntentsService, + private readonly schedulerRegistry: SchedulerRegistry, + @Inject(PAYMENT_PROVIDER_MAP) + private readonly providers: PaymentProviderMap, + ) { + this.intervalMs = + config.get("app.reconciliation.sweepIntervalMs") ?? 60_000; + this.staleAfterMs = + config.get("app.reconciliation.staleAfterMs") ?? 60_000; + this.batchSize = config.get("app.reconciliation.batchSize") ?? 20; + } + + onModuleInit(): void { + const interval = setInterval(() => void this.sweep(), this.intervalMs); + this.schedulerRegistry.addInterval(SWEEP_INTERVAL_NAME, interval); + } + + onModuleDestroy(): void { + if (this.schedulerRegistry.doesExist("interval", SWEEP_INTERVAL_NAME)) { + this.schedulerRegistry.deleteInterval(SWEEP_INTERVAL_NAME); + } + } + + async sweep(): Promise { + if (this.sweeping) return; + this.sweeping = true; + try { + const cutoff = new Date(Date.now() - this.staleAfterMs); + const stale = await this.intentsRepository.findStale( + cutoff, + this.batchSize, + ); + for (const intent of stale) { + await this.reconcileIntent(intent); + } + } catch (err) { + this.logger.error( + `sweep failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } finally { + this.sweeping = false; + } + } + + private async reconcileIntent(intent: PaymentIntent): Promise { + try { + const provider = this.providers.get(intent.provider); + if (provider) { + const status = await provider.queryStatus(intent.merchantOrderId); + const result = this.intentsService.fromProviderStatus(status); + if (result.status !== intent.status || result.providerTxnId) { + await this.intentsService.applyProviderResult(intent.id, result); + } + if ( + result.status === ProviderPaymentStatus.SUCCEEDED || + result.status === ProviderPaymentStatus.FAILED || + result.status === ProviderPaymentStatus.CANCELLED + ) { + this.logger.log(`reconciled intent ${intent.id} → ${result.status}`); + return; + } + } + + // Provider still says pending (or is unknown): expire only once the session is dead. + if (intent.expiresAt && intent.expiresAt.getTime() < Date.now()) { + await this.intentsService.expireIntent(intent.id); + this.logger.log( + `expired abandoned intent ${intent.id} (${intent.merchantOrderId})`, + ); + } + } catch (err) { + // Per-intent failures must not stall the sweep; the row stays stale and is retried. + this.logger.warn( + `reconcile failed for intent ${intent.id}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } +} diff --git a/apps/edr-payment-api/src/modules/webhooks/entities/payment-webhook-event.entity.ts b/apps/edr-payment-api/src/modules/webhooks/entities/payment-webhook-event.entity.ts new file mode 100644 index 000000000..2e104bf17 --- /dev/null +++ b/apps/edr-payment-api/src/modules/webhooks/entities/payment-webhook-event.entity.ts @@ -0,0 +1,57 @@ +import { Column, Entity, Index } from "typeorm"; +import { BaseEntity } from "@edr/api-common"; +import { ProviderMethod } from "@edr/types"; + +/** + * Idempotency + audit record for every inbound provider webhook. The unique + * (provider, external_event_id) pair is the dedupe key: a duplicate insert hits the unique + * violation and the handler short-circuits with a 200 ack. + */ +@Entity({ name: "payment_webhook_event" }) +@Index("uq_payment_webhook_event_external", ["provider", "externalEventId"], { + unique: true, +}) +export class PaymentWebhookEvent extends BaseEntity { + @Column({ name: "provider", type: "varchar", length: 16 }) + provider!: ProviderMethod; + + /** Provider event id when given (e.g. Waafi X-Webhook-Event-Id), else derived from the payload. */ + @Column({ name: "external_event_id", type: "varchar", length: 191 }) + externalEventId!: string; + + @Column({ + name: "merchant_order_id", + type: "varchar", + length: 64, + nullable: true, + }) + merchantOrderId?: string | null; + + @Column({ + name: "provider_txn_id", + type: "varchar", + length: 128, + nullable: true, + }) + providerTxnId?: string | null; + + @Column({ name: "signature_valid", type: "boolean", default: false }) + signatureValid!: boolean; + + /** Raw provider status string as sent (pre-mapping). */ + @Column({ name: "status", type: "varchar", length: 64, nullable: true }) + status?: string | null; + + /** Full webhook body — hostile input, stored verbatim for audit/replay analysis. */ + @Column({ name: "payload", type: "jsonb" }) + payload!: Record; + + @Column({ name: "received_at", type: "timestamptz", default: () => "now()" }) + receivedAt!: Date; + + @Column({ name: "processed_at", type: "timestamptz", nullable: true }) + processedAt?: Date | null; + + @Column({ name: "processing_error", type: "text", nullable: true }) + processingError?: string | null; +} diff --git a/apps/edr-payment-api/src/modules/webhooks/handlers/card-webhook.service.ts b/apps/edr-payment-api/src/modules/webhooks/handlers/card-webhook.service.ts new file mode 100644 index 000000000..8f9c9a9ab --- /dev/null +++ b/apps/edr-payment-api/src/modules/webhooks/handlers/card-webhook.service.ts @@ -0,0 +1,35 @@ +import { Injectable } from "@nestjs/common"; +import { CardProvider, CardWebhookPayload } from "@edr/payment-providers"; +import { WebhookProcessorService } from "../webhook-processor.service"; + +@Injectable() +export class CardWebhookService { + constructor( + private readonly provider: CardProvider, + private readonly processor: WebhookProcessorService, + ) {} + + async handle(payload: CardWebhookPayload, signature: string): Promise { + const signatureValid = this.provider.verifyWebhookSignature( + payload as unknown as Record, + signature, + ); + const object = payload.data.object; + const mapped = this.provider.mapWebhookStatus(object.status); + + await this.processor.process({ + provider: this.provider.method, + externalEventId: `${payload.id}_${payload.type}`, + merchantOrderId: object.metadata.merchantOrderId, + providerTxnId: object.transaction_id, + signatureValid, + rawStatus: object.status, + payload: payload as unknown as Record, + result: { + status: mapped, + providerTxnId: object.transaction_id, + failureCode: object.status, + }, + }); + } +} diff --git a/apps/edr-payment-api/src/modules/webhooks/handlers/cbe-birr-webhook.service.ts b/apps/edr-payment-api/src/modules/webhooks/handlers/cbe-birr-webhook.service.ts new file mode 100644 index 000000000..6621aa3b2 --- /dev/null +++ b/apps/edr-payment-api/src/modules/webhooks/handlers/cbe-birr-webhook.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from "@nestjs/common"; +import { CbeBirrProvider, CbeBirrWebhookPayload } from "@edr/payment-providers"; +import { WebhookProcessorService } from "../webhook-processor.service"; + +@Injectable() +export class CbeBirrWebhookService { + constructor( + private readonly provider: CbeBirrProvider, + private readonly processor: WebhookProcessorService, + ) {} + + async handle(payload: CbeBirrWebhookPayload): Promise { + const signatureValid = this.provider.verifyWebhookSignature( + payload as unknown as Record, + ); + const mapped = this.provider.mapWebhookStatus(payload.status); + const providerTxnId = payload.transactionId ?? payload.orderId; + + await this.processor.process({ + provider: this.provider.method, + externalEventId: `${payload.orderId}_${payload.status}`, + merchantOrderId: payload.merchantOrderId, + providerTxnId, + signatureValid, + rawStatus: payload.status, + payload: payload as unknown as Record, + result: { status: mapped, providerTxnId, failureCode: payload.status }, + }); + } +} diff --git a/apps/edr-payment-api/src/modules/webhooks/handlers/dmoney-webhook.service.ts b/apps/edr-payment-api/src/modules/webhooks/handlers/dmoney-webhook.service.ts new file mode 100644 index 000000000..51937dee4 --- /dev/null +++ b/apps/edr-payment-api/src/modules/webhooks/handlers/dmoney-webhook.service.ts @@ -0,0 +1,34 @@ +import { Injectable } from "@nestjs/common"; +import { DMoneyProvider, DMoneyWebhookPayload } from "@edr/payment-providers"; +import { WebhookProcessorService } from "../webhook-processor.service"; + +@Injectable() +export class DMoneyWebhookService { + constructor( + private readonly provider: DMoneyProvider, + private readonly processor: WebhookProcessorService, + ) {} + + async handle(payload: DMoneyWebhookPayload): Promise { + const signatureValid = this.provider.verifyWebhookSignature( + payload as unknown as Record, + ); + const mapped = this.provider.mapWebhookStatus(payload.status); + + await this.processor.process({ + provider: this.provider.method, + externalEventId: `${payload.orderId}_${payload.status}`, + merchantOrderId: payload.merchantOrderId, + providerTxnId: payload.transactionId, + signatureValid, + rawStatus: payload.status, + payload: payload as unknown as Record, + result: { + status: mapped, + providerTxnId: payload.transactionId, + paidAt: payload.paidAt ? new Date(payload.paidAt) : undefined, + failureCode: payload.status, + }, + }); + } +} diff --git a/apps/edr-payment-api/src/modules/webhooks/handlers/ebirr-webhook.service.ts b/apps/edr-payment-api/src/modules/webhooks/handlers/ebirr-webhook.service.ts new file mode 100644 index 000000000..3f8b923cd --- /dev/null +++ b/apps/edr-payment-api/src/modules/webhooks/handlers/ebirr-webhook.service.ts @@ -0,0 +1,33 @@ +import { Injectable } from "@nestjs/common"; +import { EBirrProvider, EBirrWebhookPayload } from "@edr/payment-providers"; +import { WebhookProcessorService } from "../webhook-processor.service"; + +@Injectable() +export class EBirrWebhookService { + constructor( + private readonly provider: EBirrProvider, + private readonly processor: WebhookProcessorService, + ) {} + + async handle(payload: EBirrWebhookPayload): Promise { + const signatureValid = this.provider.verifyWebhookSignature( + payload as unknown as Record, + ); + const mapped = this.provider.mapWebhookStatus(payload.tradeStatus); + + await this.processor.process({ + provider: this.provider.method, + externalEventId: `${payload.orderNo}_${payload.tradeStatus}_${payload.timestamp}`, + merchantOrderId: payload.orderNo, + providerTxnId: payload.tradeNo, + signatureValid, + rawStatus: payload.tradeStatus, + payload: payload as unknown as Record, + result: { + status: mapped, + providerTxnId: payload.tradeNo, + failureCode: payload.tradeStatus, + }, + }); + } +} diff --git a/apps/edr-payment-api/src/modules/webhooks/handlers/telebirr-webhook.service.ts b/apps/edr-payment-api/src/modules/webhooks/handlers/telebirr-webhook.service.ts new file mode 100644 index 000000000..e207e1d21 --- /dev/null +++ b/apps/edr-payment-api/src/modules/webhooks/handlers/telebirr-webhook.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from "@nestjs/common"; +import { + TelebirrProvider, + TelebirrWebhookPayload, +} from "@edr/payment-providers"; +import { WebhookProcessorService } from "../webhook-processor.service"; + +@Injectable() +export class TelebirrWebhookService { + constructor( + private readonly provider: TelebirrProvider, + private readonly processor: WebhookProcessorService, + ) {} + + async handle(payload: TelebirrWebhookPayload): Promise { + // TODO: re-enable Telebirr public-key signature verification — skipped for now + // (carried over from the passenger handler; see telebirr.provider verifyWebhookSignature). + const signatureValid = true; + + const mapped = this.provider.mapWebhookTradeStatus(payload.trade_status); + const providerTxnId = payload.trans_id ?? payload.payment_order_id; + + await this.processor.process({ + provider: this.provider.method, + externalEventId: `${payload.payment_order_id}_${payload.trade_status}`, + merchantOrderId: payload.merch_order_id, + providerTxnId, + signatureValid, + rawStatus: payload.trade_status, + payload: payload as unknown as Record, + result: { + status: mapped, + providerTxnId, + paidAt: this.parseEpochSeconds(payload.trans_end_time), + failureCode: payload.trade_status, + }, + }); + } + + 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-payment-api/src/modules/webhooks/handlers/waafi-webhook.service.ts b/apps/edr-payment-api/src/modules/webhooks/handlers/waafi-webhook.service.ts new file mode 100644 index 000000000..232957f79 --- /dev/null +++ b/apps/edr-payment-api/src/modules/webhooks/handlers/waafi-webhook.service.ts @@ -0,0 +1,82 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { + WaafiProvider, + WaafiWebhookHeaders, + WaafiWebhookPayload, +} from "@edr/payment-providers"; +import { WebhookProcessorService } from "../webhook-processor.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 provider: WaafiProvider, + private readonly processor: WebhookProcessorService, + ) {} + + async handle( + payload: WaafiWebhookPayload, + rawBody: string, + headers: WaafiWebhookHeaders, + ): Promise { + // 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; + } + + const { payment } = payload; + 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, + ); + + const mapped = this.provider.mapWebhookStatus(payment.status); + + await this.processor.process({ + provider: this.provider.method, + // X-Webhook-Event-Id is unique per event; fall back to a derived id if absent. + externalEventId: eventId ?? `${payment.transaction_id}_${payment.status}`, + merchantOrderId: payment.reference_id, + providerTxnId: payment.transaction_id, + signatureValid, + rawStatus: payment.status, + payload: payload as unknown as Record, + result: { + status: mapped, + providerTxnId: payment.transaction_id, + paidAt: this.parseDate(payment.date), + failureCode: payment.status, + failureMessage: payment.description, + }, + }); + } + + /** 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-payment-api/src/modules/webhooks/webhook-events.repository.ts b/apps/edr-payment-api/src/modules/webhooks/webhook-events.repository.ts new file mode 100644 index 000000000..7d4aeff95 --- /dev/null +++ b/apps/edr-payment-api/src/modules/webhooks/webhook-events.repository.ts @@ -0,0 +1,44 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { QueryFailedError, Repository } from "typeorm"; +import { BaseRepository } from "@edr/api-common"; +import { PaymentWebhookEvent } from "./entities/payment-webhook-event.entity"; + +const PG_UNIQUE_VIOLATION = "23505"; + +@Injectable() +export class WebhookEventsRepository extends BaseRepository { + constructor( + @InjectRepository(PaymentWebhookEvent) + repository: Repository, + ) { + super(repository); + } + + /** + * Insert the event, relying on the unique (provider, external_event_id) index for dedupe. + * Returns null when the event was already recorded (duplicate delivery / provider replay). + */ + async createDeduped( + data: Partial, + ): Promise { + try { + return await this.create(data); + } catch (err) { + if ( + err instanceof QueryFailedError && + (err.driverError as { code?: string })?.code === PG_UNIQUE_VIOLATION + ) { + return null; + } + throw err; + } + } + + async markProcessed(id: string, processingError?: string): Promise { + await this.update(id, { + processedAt: new Date(), + processingError: processingError ?? null, + }); + } +} 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 new file mode 100644 index 000000000..b8b54579a --- /dev/null +++ b/apps/edr-payment-api/src/modules/webhooks/webhook-processor.service.ts @@ -0,0 +1,94 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { ProviderMethod } from "@edr/types"; +import { IntentsRepository } from "../intents/intents.repository"; +import { + IntentsService, + ProviderResultInput, +} from "../intents/intents.service"; +import { WebhookEventsRepository } from "./webhook-events.repository"; + +/** A provider webhook reduced to the fields the shared pipeline needs. */ +export interface NormalizedWebhook { + provider: ProviderMethod; + /** Provider event id (or a deterministic derivation) — the dedupe key. */ + externalEventId: string; + merchantOrderId: string; + providerTxnId?: string; + signatureValid: boolean; + /** Raw provider status string, stored for audit. */ + rawStatus: string; + payload: Record; + /** Mapped outcome to feed the intent state machine. */ + result: ProviderResultInput; +} + +/** + * The shared webhook pipeline every provider handler funnels into: + * persist+dedupe → signature gate → intent lookup → prefix/service cross-check → + * state machine → mark processed. Always returns (never throws) so controllers can + * ack 200 fast — providers like Waafi time out at 5s and do not retry. + */ +@Injectable() +export class WebhookProcessorService { + private readonly logger = new Logger(WebhookProcessorService.name); + + constructor( + private readonly webhookEvents: WebhookEventsRepository, + private readonly intentsRepository: IntentsRepository, + private readonly intentsService: IntentsService, + ) {} + + async process(webhook: NormalizedWebhook): Promise { + const { provider, merchantOrderId } = webhook; + + const eventRow = await this.webhookEvents.createDeduped({ + provider, + externalEventId: webhook.externalEventId, + merchantOrderId, + providerTxnId: webhook.providerTxnId ?? null, + signatureValid: webhook.signatureValid, + status: webhook.rawStatus, + payload: webhook.payload, + }); + if (!eventRow) { + this.logger.log( + `${provider} webhook duplicate: ${webhook.externalEventId} — short-circuit OK`, + ); + return; + } + + if (!webhook.signatureValid) { + this.logger.warn( + `${provider} webhook signature invalid/stale for ref=${merchantOrderId}`, + ); + await this.webhookEvents.markProcessed(eventRow.id, "signature-invalid"); + return; + } + + const intent = + await this.intentsRepository.findByMerchantOrderId(merchantOrderId); + if (!intent) { + // Tolerated: webhook may have raced the intent commit, or the reference is foreign. + // The provider gets a 200; retry/poll/reconciliation converges later. + this.logger.warn( + `${provider} webhook: no PaymentIntent for ref=${merchantOrderId}`, + ); + await this.webhookEvents.markProcessed(eventRow.id, "intent-not-found"); + return; + } + + try { + await this.intentsService.applyProviderResult(intent.id, webhook.result); + await this.webhookEvents.markProcessed(eventRow.id); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.error( + `${provider} webhook processing failed for ${merchantOrderId}: ${message}`, + ); + await this.webhookEvents.markProcessed( + eventRow.id, + `processing-error: ${message}`, + ); + } + } +} diff --git a/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts b/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts new file mode 100644 index 000000000..8f1d1bd9e --- /dev/null +++ b/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts @@ -0,0 +1,145 @@ +import { + All, + Body, + Controller, + Headers, + HttpCode, + HttpStatus, + Logger, + Post, + Req, +} from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { + CardWebhookPayload, + CbeBirrWebhookPayload, + DMoneyWebhookPayload, + EBirrWebhookPayload, + TelebirrWebhookPayload, + WaafiWebhookHeaders, + WaafiWebhookPayload, +} from "@edr/payment-providers"; +import { TelebirrWebhookService } from "./handlers/telebirr-webhook.service"; +import { CbeBirrWebhookService } from "./handlers/cbe-birr-webhook.service"; +import { EBirrWebhookService } from "./handlers/ebirr-webhook.service"; +import { CardWebhookService } from "./handlers/card-webhook.service"; +import { WaafiWebhookService } from "./handlers/waafi-webhook.service"; +import { DMoneyWebhookService } from "./handlers/dmoney-webhook.service"; + +/** + * The ONLY public surface of the payment service — the single registered webhook URL per + * provider for the whole platform. No service auth here (provider-facing); trust comes from + * signature verification inside each handler. Every route acks 2xx fast and never rethrows: + * Waafi times out at 5s and does NOT retry. + */ +@ApiTags("Provider Webhooks") +@Controller("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, + private readonly dMoney: DMoneyWebhookService, + ) {} + + @All("telebirr") + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: "Telebirr payment notification callback (Ethiopia)", + }) + async receiveTelebirr(@Body() payload: TelebirrWebhookPayload) { + this.logger.log("Telebirr webhook called"); + try { + await this.telebirr.handle(payload); + } catch (err) { + this.logger.error(`Telebirr webhook handler threw: ${this.message(err)}`); + } + return { code: "0", message: "OK" }; + } + + @Post("cbe-birr") + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: "CBE Birr payment notification callback (Ethiopia)", + }) + async receiveCbeBirr(@Body() payload: CbeBirrWebhookPayload) { + try { + await this.cbeBirr.handle(payload); + } catch (err) { + this.logger.error(`CBE Birr webhook handler threw: ${this.message(err)}`); + } + return { success: true }; + } + + @Post("ebirr") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "eBirr payment notification callback (Ethiopia)" }) + async receiveEBirr(@Body() payload: EBirrWebhookPayload) { + try { + await this.eBirr.handle(payload); + } catch (err) { + this.logger.error(`eBirr webhook handler threw: ${this.message(err)}`); + } + return { code: "0000", message: "success" }; + } + + @Post("card") + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: "Card payment notification callback (International)", + }) + async receiveCard( + @Body() payload: CardWebhookPayload, + @Headers("stripe-signature") signature: string, + ) { + try { + await this.card.handle(payload, signature); + } catch (err) { + this.logger.error(`Card webhook handler threw: ${this.message(err)}`); + } + return { received: true }; + } + + @Post("waafi") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Waafi payment notification callback (Djibouti)" }) + async receiveWaafi( + @Body() payload: WaafiWebhookPayload, + @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"}`, + ); + 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.handle(payload, rawBody, headers); + } catch (err) { + this.logger.error(`Waafi webhook handler threw: ${this.message(err)}`); + } + return { responseCode: "2001", responseMsg: "Success" }; + } + + @Post("dmoney") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "D-Money payment notification callback (Djibouti)" }) + async receiveDMoney(@Body() payload: DMoneyWebhookPayload) { + try { + await this.dMoney.handle(payload); + } catch (err) { + this.logger.error(`D-Money webhook handler threw: ${this.message(err)}`); + } + return { success: true }; + } + + private message(err: unknown): string { + return err instanceof Error ? err.message : String(err); + } +} diff --git a/apps/edr-payment-api/src/modules/webhooks/webhooks.module.ts b/apps/edr-payment-api/src/modules/webhooks/webhooks.module.ts new file mode 100644 index 000000000..86b94032b --- /dev/null +++ b/apps/edr-payment-api/src/modules/webhooks/webhooks.module.ts @@ -0,0 +1,34 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { IntentsModule } from "../intents/intents.module"; +import { ProvidersModule } from "../providers/providers.module"; +import { PaymentWebhookEvent } from "./entities/payment-webhook-event.entity"; +import { WebhookEventsRepository } from "./webhook-events.repository"; +import { WebhookProcessorService } from "./webhook-processor.service"; +import { WebhooksController } from "./webhooks.controller"; +import { TelebirrWebhookService } from "./handlers/telebirr-webhook.service"; +import { CbeBirrWebhookService } from "./handlers/cbe-birr-webhook.service"; +import { EBirrWebhookService } from "./handlers/ebirr-webhook.service"; +import { CardWebhookService } from "./handlers/card-webhook.service"; +import { WaafiWebhookService } from "./handlers/waafi-webhook.service"; +import { DMoneyWebhookService } from "./handlers/dmoney-webhook.service"; + +@Module({ + imports: [ + TypeOrmModule.forFeature([PaymentWebhookEvent]), + IntentsModule, + ProvidersModule, + ], + controllers: [WebhooksController], + providers: [ + WebhookEventsRepository, + WebhookProcessorService, + TelebirrWebhookService, + CbeBirrWebhookService, + EBirrWebhookService, + CardWebhookService, + WaafiWebhookService, + DMoneyWebhookService, + ], +}) +export class WebhooksModule {} diff --git a/apps/edr-payment-api/src/scripts/migrate-revert.ts b/apps/edr-payment-api/src/scripts/migrate-revert.ts new file mode 100644 index 000000000..88b1500e5 --- /dev/null +++ b/apps/edr-payment-api/src/scripts/migrate-revert.ts @@ -0,0 +1,18 @@ +import "dotenv/config"; +import { AppDataSource } from "../data-source"; + +/** `pnpm --filter @edr/payment-api migration:revert` — undo the most recent migration. */ +async function main(): Promise { + await AppDataSource.initialize(); + try { + await AppDataSource.undoLastMigration(); + console.log("reverted last migration"); + } finally { + await AppDataSource.destroy(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/apps/edr-payment-api/src/scripts/migrate.ts b/apps/edr-payment-api/src/scripts/migrate.ts new file mode 100644 index 000000000..05f59f1b9 --- /dev/null +++ b/apps/edr-payment-api/src/scripts/migrate.ts @@ -0,0 +1,23 @@ +import "dotenv/config"; +import { AppDataSource } from "../data-source"; +import { ensurePaymentSchema } from "../config/ensure-schema"; + +/** `pnpm --filter @edr/payment-api migration:run` — ensure schema, then run pending migrations. */ +async function main(): Promise { + await ensurePaymentSchema(); + await AppDataSource.initialize(); + try { + const applied = await AppDataSource.runMigrations(); + for (const migration of applied) { + console.log(`applied: ${migration.name}`); + } + if (applied.length === 0) console.log("no pending migrations"); + } finally { + await AppDataSource.destroy(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/apps/edr-payment-api/tsconfig.build.json b/apps/edr-payment-api/tsconfig.build.json new file mode 100644 index 000000000..64f86c6bd --- /dev/null +++ b/apps/edr-payment-api/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/apps/edr-payment-api/tsconfig.json b/apps/edr-payment-api/tsconfig.json new file mode 100644 index 000000000..467c474ee --- /dev/null +++ b/apps/edr-payment-api/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "@edr/tsconfig/nestjs.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src", + "noEmit": false, + "incremental": true, + "tsBuildInfoFile": "./.tsbuildinfo", + "module": "node16", + "moduleResolution": "node16" + }, + "include": ["src"] +} diff --git a/package.json b/package.json index 399ded0bf..575de8c41 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "dev": "turbo run dev", "dev:freight": "turbo run dev --filter=@edr/freight-api... --filter=@edr/freight-portal... --filter=@edr/freight-backoffice... --filter=@edr/ui-common...", "dev:passenger": "turbo run dev --filter=@edr/passenger-api... --filter=@edr/passenger-portal... --filter=@edr/passenger-backoffice...", + "dev:payment": "turbo run dev --filter=@edr/payment-api...", "build": "turbo run build", "build:freight": "turbo run build --filter=@edr/freight-api... --filter=@edr/freight-portal... --filter=@edr/freight-backoffice...", "build:passenger": "turbo run build --filter=@edr/passenger-api... --filter=@edr/passenger-portal... --filter=@edr/passenger-backoffice...", diff --git a/packages/payment-providers/src/index.ts b/packages/payment-providers/src/index.ts index ef308a44a..7b77b5684 100644 --- a/packages/payment-providers/src/index.ts +++ b/packages/payment-providers/src/index.ts @@ -40,12 +40,28 @@ export type { TelebirrTradeStatus, } from './providers/telebirr/telebirr.types'; +// Waafi HPP request/response types (exported for apps that build/inspect requests directly) +export type { + WaafiState, + WaafiHppPurchaseRequest, + WaafiHppPurchaseResponse, + WaafiGetTranInfoRequest, + WaafiGetTranInfoResponse, +} from './providers/waafi/waafi.types'; + // Webhook payload types export type { TelebirrWebhookPayload } from './webhooks/telebirr-webhook.types'; export type { CbeBirrWebhookPayload } from './webhooks/cbe-birr-webhook.types'; export type { EBirrWebhookPayload } from './webhooks/ebirr-webhook.types'; export type { CardWebhookPayload } from './webhooks/card-webhook.types'; -export type { WaafiWebhookPayload } from './webhooks/waafi-webhook.types'; +export type { + WaafiWebhookPayload, + WaafiWebhookTransactionPayload, + WaafiWebhookTestPayload, + WaafiWebhookHeaders, + WaafiWebhookEvent, + WaafiWebhookStatus, +} from './webhooks/waafi-webhook.types'; export type { DMoneyWebhookPayload } from './webhooks/dmoney-webhook.types'; // DI token for injecting all providers as an array (future multi-provider wiring) 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 ea16eccb5..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,111 +8,70 @@ import { ProviderStatus, ProviderPaymentStatus, ProviderMethod, -} from '@edr/types'; -import { AxiosError, AxiosRequestConfig } from 'axios'; -import { firstValueFrom } from 'rxjs'; +} 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"; const WAAFI_HTTP_TIMEOUT_MS = 10_000; - -interface WaafiInitiateRequest { - schemaVersion: string; - requestId: string; - timestamp: string; - channelName: string; - serviceName: string; - serviceParams: { - merchantUid: string; - apiUserId: string; - apiKey: string; - paymentMethod: string; - payerInfo: { - accountNo: string; - }; - transactionInfo: { - referenceId: string; - invoiceId: string; - amount: number; - currency: string; - description: string; - }; - }; -} - -interface WaafiInitiateResponse { - responseCode: string; - responseMsg: string; - params?: { - state: string; - referenceId: string; - transactionId: string; - checkoutUrl?: string; - }; -} - -interface WaafiQueryRequest { - schemaVersion: string; - requestId: string; - timestamp: string; - channelName: string; - serviceName: string; - serviceParams: { - merchantUid: string; - apiUserId: string; - apiKey: string; - transactionId?: string; - referenceId?: string; - }; -} - -interface WaafiQueryResponse { - responseCode: string; - responseMsg: string; - params?: { - state: string; - referenceId: string; - transactionId: string; - amount: number; - currency: string; - paidAmount?: number; - }; -} +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; @Injectable() export class WaafiProvider implements PaymentProvider { readonly method = ProviderMethod.WAAFI; private readonly logger = new Logger(WaafiProvider.name); + private readonly httpsAgent: https.Agent; constructor( private readonly config: ConfigService, private readonly http: HttpService, - ) {} + ) { + const insecure = this.config.get("waafi.insecureTls"); + if (insecure) { + this.logger.warn( + "WAAFI_INSECURE_TLS=true — TLS verification disabled for Waafi calls. DEV ONLY.", + ); + } + this.httpsAgent = new https.Agent({ rejectUnauthorized: !insecure }); + } - async initiate(input: ProviderInitiationInput): Promise { - const requestBody = this.buildInitiateRequest(input); - const response = await this.postJson( + async initiate( + input: ProviderInitiationInput, + ): Promise { + const requestBody = this.buildPurchaseRequest(input); + const response = await this.postJson( `${this.baseUrl}/asm`, requestBody, ); - if (response.responseCode !== '2001') { + if (response.responseCode !== WAAFI_SUCCESS_CODE) { throw new Error( - `Waafi initiate failed: ${response.responseCode} - ${response.responseMsg}`, + `Waafi HPP_PURCHASE failed: responseCode=${response.responseCode} errorCode=${response.errorCode} msg=${response.responseMsg}`, ); } - const transactionId = response.params?.transactionId; - const checkoutUrl = response.params?.checkoutUrl || `${this.baseUrl}/checkout?ref=${transactionId}`; - - if (!transactionId) { - throw new Error(`Waafi returned no transactionId: ${JSON.stringify(response)}`); + const checkoutUrl = + response.params?.hppUrl ?? response.params?.directPaymentLink; + const orderId = response.params?.orderId; + if (!checkoutUrl || !orderId) { + throw new Error( + `Waafi HPP_PURCHASE succeeded but returned no hppUrl/orderId: ${JSON.stringify(response)}`, + ); } - const expiresAt = new Date(Date.now() + 15 * 60_000); // 15 minutes - return { - providerOrderId: transactionId, - clientAction: { type: 'REDIRECT', url: checkoutUrl }, - expiresAt, + providerOrderId: orderId, + clientAction: { type: "REDIRECT", url: checkoutUrl }, + expiresAt: new Date(Date.now() + WAAFI_HPP_SESSION_MS), rawInitiation: { request: this.sanitize(requestBody), response, @@ -121,114 +80,159 @@ export class WaafiProvider implements PaymentProvider { } async queryStatus(merchantOrderId: string): Promise { - const requestBody = this.buildQueryRequest(merchantOrderId); - const response = await this.postJson( + const requestBody = this.buildGetTranInfoRequest(merchantOrderId); + const response = await this.postJson( `${this.baseUrl}/asm`, requestBody, ); - const state = response.params?.state; + const rawState = response.params?.status ?? response.params?.tranStatusDesc; const transactionId = response.params?.transactionId; - const mapped = this.mapState(state); + const mapped = this.mapStatus(rawState); return { status: mapped, providerTxnId: transactionId, - failureCode: mapped === ProviderPaymentStatus.FAILED && state ? state : undefined, + failureCode: + mapped === ProviderPaymentStatus.FAILED && rawState + ? rawState + : undefined, rawResponse: response as unknown as Record, }; } - mapState(state: string | undefined): ProviderPaymentStatus { - switch (state) { - case 'APPROVED': - case 'SUCCESS': + /** Map a webhook `payment.status` to the shared status enum. */ + mapWebhookStatus(status: string | undefined): ProviderPaymentStatus { + return this.mapStatus(status); + } + + /** + * Verify an HMAC-SHA256 webhook signature. + * + * Signing string is `{timestamp}.{eventId}.{rawBody}` over the *raw* request body bytes — the + * caller must pass the unparsed body string. Returns false (never throws) on any mismatch so + * callers can treat verification as a boolean gate. + */ + verifyWebhookSignature( + rawBody: string, + signature: string | undefined, + timestamp: string | undefined, + eventId: string | undefined, + ): boolean { + if (!this.webhookSecret) { + 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", + ); + return false; + } + + const signingString = `${timestamp}.${eventId}.${rawBody}`; + const expected = crypto + .createHmac("sha256", this.webhookSecret) + .update(signingString) + .digest("hex"); + + 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": return ProviderPaymentStatus.SUCCEEDED; - case 'FAILED': - case 'DECLINED': - case 'CANCELLED': - case 'EXPIRED': + case "CANCELED": + case "CANCELLED": + return ProviderPaymentStatus.CANCELLED; + case "DECLINED": + case "FAILED": + case "EXPIRED": + case "TIMEOUT": return ProviderPaymentStatus.FAILED; - case 'PENDING': - case 'INITIATED': + case "PENDING": + case "INITIATED": return ProviderPaymentStatus.REQUIRES_ACTION; - case 'PROCESSING': - return ProviderPaymentStatus.PROCESSING; default: return ProviderPaymentStatus.PROCESSING; } } - verifyWebhookSignature(payload: Record): boolean { - // Waafi webhook signature verification - // Implementation depends on Waafi's webhook signature mechanism - const signature = payload.signature as string; - const apiKey = this.apiKey; - - if (!signature || !apiKey) { - this.logger.error('Waafi webhook missing signature or API key not configured'); - return false; - } - - // TODO: Implement actual signature verification based on Waafi documentation - // For now, basic validation - return signature.length > 0; - } - - private buildInitiateRequest(input: ProviderInitiationInput): WaafiInitiateRequest { - const amount = input.amountMinor / 100; // Convert minor units to major - + private buildPurchaseRequest( + input: ProviderInitiationInput, + ): WaafiHppPurchaseRequest { return { - schemaVersion: '1.0', - requestId: this.generateRequestId(), - timestamp: new Date().toISOString(), - channelName: 'WEB', - serviceName: 'API_PURCHASE', + schemaVersion: "1.0", + requestId: crypto.randomUUID(), + timestamp: this.timestamp(), + channelName: "WEB", + serviceName: "HPP_PURCHASE", serviceParams: { merchantUid: this.merchantUid, - apiUserId: this.apiUserId, - apiKey: this.apiKey, - paymentMethod: 'MWALLET_ACCOUNT', - payerInfo: { - accountNo: 'CUSTOMER', // Customer enters their number on Waafi page - }, + storeId: this.storeId, + hppKey: this.hppKey, + paymentMethod: this.paymentMethod, + // 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. + ...(input.payerAccount + ? { payerInfo: { subscriptionId: input.payerAccount } } + : {}), transactionInfo: { referenceId: input.merchantOrderId, - invoiceId: input.orderRef, - amount, - currency: input.currency === 'ETB' ? 'DJF' : input.currency, // Convert ETB to DJF + amount: this.toAmount(input.amountMinor), + // Waafi has no ETB; `waafi.currency` overrides the booking currency when set. + currency: this.currency || input.currency, description: `EDR ${input.orderRef}`, }, }, }; } - private buildQueryRequest(merchantOrderId: string): WaafiQueryRequest { + private buildGetTranInfoRequest( + merchantOrderId: string, + ): WaafiGetTranInfoRequest { return { - schemaVersion: '1.0', - requestId: this.generateRequestId(), - timestamp: new Date().toISOString(), - channelName: 'WEB', - serviceName: 'API_QUERY', + schemaVersion: "1.0", + requestId: crypto.randomUUID(), + timestamp: this.timestamp(), + channelName: "WEB", + serviceName: "HPP_GETTRANINFO", serviceParams: { merchantUid: this.merchantUid, - apiUserId: this.apiUserId, - apiKey: this.apiKey, + storeId: this.storeId, + hppKey: this.hppKey, referenceId: merchantOrderId, }, }; } - private generateRequestId(): string { - return `EDR-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + /** Convert integer minor units to a 2-decimal major amount (truncated, never rounded up). */ + private toAmount(amountMinor: number): number { + return Math.trunc(amountMinor) / 100; + } + + private timestamp(): string { + return Math.round(Date.now() / 1000).toString(); } 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, }; const started = Date.now(); @@ -252,24 +256,43 @@ export class WaafiProvider implements PaymentProvider { } } - private sanitize(body: WaafiInitiateRequest): Record { - const sanitized = { ...body }; - if (sanitized.serviceParams?.apiKey) { - sanitized.serviceParams.apiKey = '***REDACTED***'; - } - return sanitized as unknown as Record; + private sanitize(body: WaafiHppPurchaseRequest): Record { + return { + ...body, + serviceParams: { ...body.serviceParams, hppKey: "***REDACTED***" }, + }; } private get baseUrl(): string { - return this.config.get('waafi.baseUrl') ?? 'https://api.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 apiUserId(): string { - return this.config.get('waafi.apiUserId') ?? ''; + private get storeId(): string { + return this.config.get("waafi.storeId") ?? ""; } - private get apiKey(): string { - return this.config.get('waafi.apiKey') ?? ''; + private get hppKey(): string { + return this.config.get("waafi.hppKey") ?? ""; + } + private get webhookSecret(): string { + return this.config.get("waafi.webhookSecret") ?? ""; + } + private get paymentMethod(): string { + return this.config.get("waafi.paymentMethod") ?? "MWALLET_ACCOUNT"; + } + private get currency(): string { + return this.config.get("waafi.currency") ?? ""; + } + private get successUrl(): string { + return this.config.get("waafi.successUrl") ?? ""; + } + private get failureUrl(): string { + return this.config.get("waafi.failureUrl") ?? ""; + } + private get respDataFormat(): number { + return this.config.get("waafi.respDataFormat") ?? 1; } } diff --git a/packages/payment-providers/src/providers/waafi/waafi.types.ts b/packages/payment-providers/src/providers/waafi/waafi.types.ts new file mode 100644 index 000000000..3f3c6f3cf --- /dev/null +++ b/packages/payment-providers/src/providers/waafi/waafi.types.ts @@ -0,0 +1,103 @@ +/** + * WaafiPay (Hosted Payment Page) request/response types. + * + * WaafiPay multiplexes every operation through a single `POST /asm` endpoint, discriminated by + * `serviceName`. We use the HPP family (`HPP_PURCHASE`, `HPP_GETTRANINFO`) which returns a hosted + * redirect URL and supports webhooks — see docs/waffi/intro.md. + */ + +/** Terminal/intermediate transaction states reported by Waafi (sync `state` / `HPP_GETTRANINFO`). */ +export type WaafiState = + | 'APPROVED' + | 'DECLINED' + | 'FAILED' + | 'CANCELED' + | 'EXPIRED' + | 'TIMEOUT' + | string; + +/** Common request envelope shared by every `/asm` call. */ +export interface WaafiRequestEnvelope { + schemaVersion: '1.0'; + requestId: string; + timestamp: string; + channelName: 'WEB'; + serviceName: string; + serviceParams: TServiceParams; +} + +/** Common response envelope. `responseCode === '2001'` means the request was processed (not paid). */ +export interface WaafiResponseEnvelope { + schemaVersion: string; + timestamp: string; + responseId: string; + responseCode: string; + errorCode: string; + responseMsg: string; + params?: TParams; +} + +// --- HPP_PURCHASE ----------------------------------------------------------------------------- + +export interface WaafiHppPurchaseServiceParams { + merchantUid: string; + storeId: string; + hppKey: string; + paymentMethod: string; + hppSuccessCallbackUrl: string; + hppFailureCallbackUrl: string; + /** Callback data format: 1 = POST, 2 = GET, 4 = Result Token. */ + hppRespDataFormat: number; + /** Required for MWALLET_ACCOUNT — pre-fills (and locks) the payer's phone on the hosted page. */ + payerInfo?: { + subscriptionId: string; + }; + transactionInfo: { + referenceId: string; + amount: number; + currency: string; + description?: string; + }; +} + +export type WaafiHppPurchaseRequest = WaafiRequestEnvelope; + +export interface WaafiHppPurchaseParams { + hppUrl: string; + directPaymentLink?: string; + orderId: string; + referenceId: string; +} + +export type WaafiHppPurchaseResponse = WaafiResponseEnvelope; + +// --- HPP_GETTRANINFO -------------------------------------------------------------------------- + +export interface WaafiGetTranInfoServiceParams { + merchantUid: string; + storeId: string; + hppKey: string; + /** Either the merchant referenceId or the Waafi transactionId may be supplied. */ + referenceId?: string; + transactionId?: string; +} + +export type WaafiGetTranInfoRequest = WaafiRequestEnvelope; + +export interface WaafiGetTranInfoParams { + tranStatusDesc?: string; + amount?: string; + payerId?: string; + paymentMethod?: string; + description?: string; + tranDate?: string; + currency?: string; + invoiceId?: string; + referenceId?: string; + tranAmount?: string; + transactionId?: string; + tranStatusId?: string; + status?: WaafiState; +} + +export type WaafiGetTranInfoResponse = WaafiResponseEnvelope; diff --git a/packages/payment-providers/src/webhooks/waafi-webhook.types.ts b/packages/payment-providers/src/webhooks/waafi-webhook.types.ts index 247c46d40..658b293cd 100644 --- a/packages/payment-providers/src/webhooks/waafi-webhook.types.ts +++ b/packages/payment-providers/src/webhooks/waafi-webhook.types.ts @@ -1,15 +1,66 @@ -export interface WaafiWebhookPayload { - schemaVersion: string; - requestId: string; - timestamp: string; - eventType: string; - params: { - state: string; - referenceId: string; - transactionId: string; - amount: number; - currency: string; - description?: string; - }; - signature?: string; +/** + * WaafiPay webhook payloads (HPP authorization / refund / test). + * + * Webhooks are HMAC-SHA256 signed over `{timestamp}.{event_id}.{raw_body}` except `webhook.test`, + * which is unsigned and only sent to validate endpoint reachability. See docs/waffi/intro.md. + */ + +export type WaafiWebhookEvent = 'authorization' | 'refund' | 'webhook.test'; + +export type WaafiWebhookStatus = + | 'APPROVED' + | 'FAILED' + | 'DECLINED' + | 'CANCELED' + | 'EXPIRED' + | 'TIMEOUT' + | string; + +/** Headers Waafi sends alongside signed webhooks (lowercased, as exposed by NestJS). */ +export interface WaafiWebhookHeaders { + 'x-webhook-timestamp'?: string; + 'x-webhook-event-id'?: string; + 'x-webhook-signature'?: string; + 'x-webhook-signature-alg'?: string; } + +/** Nested payment object present on `authorization` and `refund` events. */ +export interface WaafiWebhookPayment { + transaction_id: string; + /** Present on authorization events (optional). */ + order_id?: string; + transfer_code: string; + amount: number; + currency: string; + /** Present on authorization events. */ + payment_method?: string; + status: WaafiWebhookStatus; + /** Our merchantOrderId. */ + reference_id: string; + /** Present on authorization events. */ + channel?: string; + description?: string; + date: string; +} + +/** Unsigned validation ping sent on webhook registration/update. */ +export interface WaafiWebhookTestPayload { + event: 'webhook.test'; + message?: string; + merchant_uid: string; +} + +/** Real transaction notification (authorization or refund). */ +export interface WaafiWebhookTransactionPayload { + event: 'authorization' | 'refund'; + merchant_id: number; + merchant_uid: string; + user_id: string; + /** Authorization only. */ + customer_identity?: string; + /** Authorization only (optional). */ + cardholder_name?: string; + payment: WaafiWebhookPayment; +} + +export type WaafiWebhookPayload = WaafiWebhookTestPayload | WaafiWebhookTransactionPayload; diff --git a/packages/types/src/common/payments.ts b/packages/types/src/common/payments.ts index 66a6dead3..02930d151 100644 --- a/packages/types/src/common/payments.ts +++ b/packages/types/src/common/payments.ts @@ -43,8 +43,17 @@ export interface ProviderInitiationInput { amountMinor: number; currency: string; platform?: PaymentPlatform; - returnUrl?: string - redirectUrl?: string + /** + * Payer account identifier (e.g. mobile-wallet MSISDN in full international format). + * Optional and provider-specific: some wallet providers (e.g. Waafi HPP with + * MWALLET_ACCOUNT) require the payer's phone number up front to pre-fill the hosted page. + */ + payerAccount?: string; + /** 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 { @@ -67,3 +76,103 @@ export interface PaymentProvider { initiate(input: ProviderInitiationInput): Promise; queryStatus(merchantOrderId: string): Promise; } + +/* ------------------------------------------------------------------------------------------------ + * Payment microservice contracts (docs/payment-service) + * + * Shared shapes exchanged between the payment microservice (apps/edr-payment-api) and the + * domain apps (passenger/freight). Both sides import these so the wire format cannot drift. + * ---------------------------------------------------------------------------------------------- */ + +/** Which domain app owns the order being paid for. Routing discriminator on every intent. */ +export enum PaymentService { + PASSENGER = "PASSENGER", + FREIGHT = "FREIGHT", +} + +/** What kind of domain order the intent references (soft reference — never a cross-schema FK). */ +export enum PaymentReferenceType { + BOOKING = "BOOKING", + SHIPMENT = "SHIPMENT", +} + + +/** Body of `POST /payments/initiate` on the payment service (internal, service-authenticated). */ +export interface InitiatePaymentRequest { + service: PaymentService; + referenceType: PaymentReferenceType; + /** Domain order id (booking/shipment id). Soft reference; the app has already validated it. */ + referenceId: string; + /** Human-readable order ref (e.g. booking ref) shown on provider pages. Defaults to referenceId. */ + orderRef?: string; + /** App-asserted authoritative amount in minor units (computed server-side by the domain app). */ + amountMinor: number; + currency: string; + 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; +} + +/** Response of `POST /payments/initiate` and shape of intent lookups. */ +export interface PaymentIntentSnapshot { + intentId: string; + service: PaymentService; + referenceType: PaymentReferenceType; + referenceId: string; + merchantOrderId: string; + provider: ProviderMethod; + status: ProviderPaymentStatus; + amountMinor: number; + currency: string; + clientAction?: ClientAction; + providerTxnId?: string; + paidAt?: string; + failureCode?: string; + failureMessage?: string; + expiresAt?: string; +} + +export type PaymentEventType = "payment.succeeded" | "payment.failed"; + +/** Versioned envelope delivered (at-least-once) to the owning app's mark-paid consumer. */ +interface PaymentEventBase { + version: 1; + /** Outbox row id — stable across redeliveries; consumers may use it as a dedupe key. */ + eventId: string; + eventType: PaymentEventType; + occurredAt: string; + service: PaymentService; + intentId: string; + referenceType: PaymentReferenceType; + referenceId: string; + merchantOrderId: string; + provider: ProviderMethod; + amountMinor: number; + currency: string; +} + +export interface PaymentSucceededEvent extends PaymentEventBase { + eventType: "payment.succeeded"; + providerTxnId?: string; + paidAt: string; +} + +export interface PaymentFailedEvent extends PaymentEventBase { + eventType: "payment.failed"; + failureCode?: string; + failureMessage?: string; +} + +export type PaymentEvent = PaymentSucceededEvent | PaymentFailedEvent; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b91c2013..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 @@ -672,6 +669,112 @@ importers: specifier: ^5.5.4 version: 5.9.3 + apps/edr-payment-api: + dependencies: + '@edr/api-common': + specifier: workspace:* + version: link:../../packages/api-common + '@edr/payment-providers': + specifier: workspace:* + version: link:../../packages/payment-providers + '@edr/types': + specifier: workspace:* + version: link:../../packages/types + '@nestjs/axios': + specifier: ^4.0.1 + version: 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) + '@nestjs/common': + specifier: ^11.0.0 + version: 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/config': + specifier: ^4.0.0 + version: 4.0.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) + '@nestjs/core': + specifier: ^11.0.0 + version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/platform-express': + specifier: ^11.0.0 + version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) + '@nestjs/schedule': + specifier: ^6.0.0 + version: 6.1.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) + '@nestjs/swagger': + specifier: ^11.4.2 + version: 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + '@nestjs/typeorm': + specifier: ^11.0.1 + version: 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) + axios: + specifier: ^1.16.1 + version: 1.17.0 + class-transformer: + specifier: ^0.5.1 + version: 0.5.1 + class-validator: + specifier: ^0.14.1 + version: 0.14.4 + dotenv: + specifier: ^17.4.2 + version: 17.4.2 + pg: + specifier: ^8.13.0 + version: 8.21.0 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.1 + version: 7.8.2 + typeorm: + specifier: 0.3.30 + version: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + devDependencies: + '@edr/eslint-config': + specifier: workspace:* + version: link:../../packages/config/eslint-config + '@edr/tsconfig': + specifier: workspace:* + version: link:../../packages/config/tsconfig + '@nestjs/cli': + specifier: ^11.0.0 + version: 11.0.21(@types/node@20.19.42)(prettier@3.8.3) + '@nestjs/schematics': + specifier: ^11.0.0 + version: 11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@5.9.3) + '@nestjs/testing': + specifier: ^11.0.0 + version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24) + '@types/express': + specifier: ^5.0.0 + version: 5.0.6 + '@types/jest': + specifier: ^29.5.13 + version: 29.5.14 + '@types/node': + specifier: ^20.14.0 + version: 20.19.42 + '@types/pg': + specifier: ^8.6.7 + version: 8.20.0 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@20.19.42)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + ts-jest: + specifier: ^29.2.5 + version: 29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.42)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))(typescript@5.9.3) + ts-loader: + specifier: ^9.5.1 + version: 9.6.0(loader-utils@1.4.2)(typescript@5.9.3)(webpack@5.106.0) + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@20.19.42)(typescript@5.9.3) + tsconfig-paths: + specifier: ^4.2.0 + version: 4.2.0 + typescript: + specifier: ^5.5.4 + version: 5.9.3 + packages/api-common: dependencies: '@edr/types':