Merge pull request #139 from Tria-plc/feat/payment-microservice

Feat/payment microservice
This commit is contained in:
Eyob T.
2026-06-12 11:53:49 +03:00
committed by GitHub
85 changed files with 4682 additions and 1656 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -21,7 +21,6 @@
},
"dependencies": {
"@edr/payment-providers": "workspace:*",
"@edr/types": "workspace:*",
"@nestjs/axios": "^4.0.1",
"@nestjs/common": "^11.0.0",

View File

@@ -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<Request>();
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;
}
}

View File

@@ -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',
}));

View File

@@ -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: [

View File

@@ -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<MarkPaidResponseDto> {
return this.paymentsService.handlePaymentEvent(event);
}
}

View File

@@ -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;
}

View File

@@ -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<PaymentIntentSnapshot> {
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<PaymentIntentSnapshot | null> {
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<T>(
method: "GET" | "POST",
path: string,
body?: unknown,
): Promise<T> {
const url = `${this.baseUrl}${path}`;
try {
const response = await firstValueFrom(
this.http.request<T>({
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");
}
}
}

View File

@@ -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<GatewayResult> {
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<GatewayResult> {
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<GatewayResult> {
await new Promise((r) => setTimeout(r, 150));
return { success: true, providerRef: `CBE-${ref}-${Date.now()}` };
}
export async function eBirrAdapter(
_a: number,
ref: string,
): Promise<GatewayResult> {
await new Promise((r) => setTimeout(r, 150));
return { success: true, providerRef: `EB-${ref}-${Date.now()}` };
}
export async function cardAdapter(
_a: number,
ref: string,
): Promise<GatewayResult> {
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<GatewayResult> {
return { success: balance >= amount, providerRef: `WALLET-${Date.now()}` };
}
export async function cbeBirrAdapter(_a: number, ref: string): Promise<GatewayResult> { await new Promise((r) => setTimeout(r, 150)); return { success: true, providerRef: `CBE-${ref}-${Date.now()}` }; }
export async function eBirrAdapter(_a: number, ref: string): Promise<GatewayResult> { await new Promise((r) => setTimeout(r, 150)); return { success: true, providerRef: `EB-${ref}-${Date.now()}` }; }
export async function cardAdapter(_a: number, ref: string): Promise<GatewayResult> { 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<GatewayResult> { return { success: balance >= amount, providerRef: `WALLET-${Date.now()}` }; }

View File

@@ -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, '&quot;');
const escaped = url.replace(/\"/g, "&quot;");
return `<!DOCTYPE html>
<html lang="en">
<head>

View File

@@ -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;

View File

@@ -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>(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/*).
});

View File

@@ -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 {}

View File

@@ -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>(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,
);
});

View File

@@ -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<PaymentMethodType, PaymentProvider>;
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, PaymentProvider>([
[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<InitiateResponseDto> {
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<Record<string, never>>,
): 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<IntentStatusDto> {
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<void> {
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<MarkPaidResponseDto> {
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,
},

View File

@@ -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";

View File

@@ -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<void> {
const merchantOrderId = payload.data.object.metadata.merchantOrderId;
const externalEventId = `${payload.id}_${payload.type}`;
const signatureValid = this.provider.verifyWebhookSignature(
payload as unknown as Record<string, unknown>,
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<void> {
await this.prisma.paymentWebhookEvent.update({
where: { id: eventId },
data: { processedAt: new Date(), processingError },
});
}
}

View File

@@ -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<void> {
const merchantOrderId = payload.merchantOrderId;
const externalEventId = `${payload.orderId}_${payload.status}`;
const signatureValid = this.provider.verifyWebhookSignature(
payload as unknown as Record<string, unknown>,
);
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<void> {
await this.prisma.paymentWebhookEvent.update({
where: { id: eventId },
data: { processedAt: new Date(), processingError },
});
}
}

View File

@@ -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<void> {
const merchantOrderId = payload.orderNo;
const externalEventId = `${payload.orderNo}_${payload.tradeStatus}_${payload.timestamp}`;
const signatureValid = this.provider.verifyWebhookSignature(
payload as unknown as Record<string, unknown>,
);
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<void> {
await this.prisma.paymentWebhookEvent.update({
where: { id: eventId },
data: { processedAt: new Date(), processingError },
});
}
}

View File

@@ -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<void> {
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<string, unknown>,
// );
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<void> {
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);
}
}

View File

@@ -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<string, unknown>,
);
const merchantOrderId = payload.params?.referenceId;
const transactionId = payload.params?.transactionId;
const state = payload.params?.state;
await this.prisma.paymentWebhookEvent.create({
data: {
provider: PaymentMethodType.WAAFI,
externalEventId: payload.requestId,
merchantOrderId,
providerTxnId: transactionId,
signatureValid,
status: state || 'UNKNOWN',
payload: payload as any,
},
});
if (!signatureValid) {
this.logger.warn(`Waafi webhook signature invalid for ref=${merchantOrderId}`);
return { received: true };
}
if (!merchantOrderId) {
this.logger.error('Waafi webhook missing referenceId');
return { received: true };
}
const intent = await this.prisma.paymentIntent.findFirst({
where: { merchantOrderId },
});
if (!intent) {
this.logger.warn(`No PaymentIntent found for merchantOrderId=${merchantOrderId}`);
return { received: true };
}
const mappedStatus = this.waafiProvider.mapState(state);
if (mappedStatus === 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 };
}
}

View File

@@ -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' };
}
}

View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": false
}
}

View File

@@ -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"
}
}

View File

@@ -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<TypeOrmModuleOptions>("database") as TypeOrmModuleOptions,
}),
ScheduleModule.forRoot(),
HealthModule,
ProvidersModule,
IntentsModule,
WebhooksModule,
OutboxModule,
ReconciliationModule,
],
})
export class AppModule {}

View File

@@ -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: <SERVICE_AUTH_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<string>("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<Request>();
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;
}
}

View File

@@ -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),
},
}));

View File

@@ -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 || "",
}));

View File

@@ -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 || "",
}));

View File

@@ -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,
}),
);

View File

@@ -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 ?? "",
}));

View File

@@ -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 || "",
}));

View File

@@ -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<void> {
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();
}
}

View File

@@ -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),
}));

View File

@@ -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",
}));

View File

@@ -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",
}));

View File

@@ -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;

View File

@@ -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();

View File

@@ -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<void> {
// 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<void> {
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"`);
}
}

View File

@@ -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(),
};
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from "@nestjs/common";
import { HealthController } from "./health.controller";
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View File

@@ -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;
}

View File

@@ -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<string, unknown> | 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;

View File

@@ -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<PaymentIntentSnapshot> {
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<PaymentIntentSnapshot> {
return this.intentsService.getIntent(id);
}
@Get("intents")
@ApiOperation({
summary: "Active intent status by domain reference (pull/reconcile)",
})
async getIntentByReference(
@Query() query: IntentReferenceQueryDto,
): Promise<PaymentIntentSnapshot> {
return this.intentsService.getIntentByReference(
query.service,
query.referenceType,
query.referenceId,
);
}
}

View File

@@ -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 {}

View File

@@ -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<PaymentIntent> {
constructor(
@InjectRepository(PaymentIntent)
repository: Repository<PaymentIntent>,
) {
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<PaymentIntent | null> {
return this.repository.findOne({
where: {
service,
referenceType,
referenceId,
status: Not(
In([ProviderPaymentStatus.FAILED, ProviderPaymentStatus.CANCELLED]),
),
},
order: { createdAt: "DESC" },
});
}
async findByMerchantOrderId(
merchantOrderId: string,
): Promise<PaymentIntent | null> {
return this.repository.findOne({ where: { merchantOrderId } });
}
async findByIdempotencyKey(
service: PaymentService,
idempotencyKey: string,
): Promise<PaymentIntent | null> {
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<PaymentIntent[]> {
return this.repository.find({
where: {
status: In([
ProviderPaymentStatus.REQUIRES_ACTION,
ProviderPaymentStatus.PROCESSING,
]),
updatedAt: LessThan(updatedBefore),
},
order: { updatedAt: "ASC" },
take: limit,
});
}
}

View File

@@ -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<PaymentIntentSnapshot> {
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<PaymentIntent | null> {
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<PaymentIntentSnapshot> {
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<PaymentIntentSnapshot> {
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<PaymentIntent> {
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<void> {
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(),
};
}
}

View File

@@ -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;
}

View File

@@ -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<number>("notifier.relayIntervalMs") ?? 5_000;
this.maxAttempts = config.get<number>("notifier.maxAttempts") ?? 10;
this.batchSize = config.get<number>("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<void> {
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<void> {
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);
}
}

View File

@@ -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 {}

View File

@@ -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<NotificationOutbox> {
constructor(
@InjectRepository(NotificationOutbox)
repository: Repository<NotificationOutbox>,
) {
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<NotificationOutbox[]> {
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<void> {
await this.update(id, {
status: "SENT",
sentAt: new Date(),
lastError: null,
});
}
async markAttemptFailed(
row: NotificationOutbox,
error: string,
nextRetryAt: Date | null,
exhausted: boolean,
): Promise<void> {
await this.update(row.id, {
attempts: row.attempts + 1,
lastError: error,
nextRetryAt,
status: exhausted ? "FAILED" : "PENDING",
});
}
async countBacklog(): Promise<number> {
return this.repository.count({ where: { status: "PENDING" } });
}
}

View File

@@ -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<NotificationOutbox> {
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,
};
}

View File

@@ -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<PaymentService, string>;
private readonly timeoutMs: number;
private readonly serviceToken: string;
constructor(
config: ConfigService,
private readonly http: HttpService,
) {
this.routes = {
[PaymentService.PASSENGER]:
config.get<string>("notifier.passengerUrl") ?? "",
[PaymentService.FREIGHT]: config.get<string>("notifier.freightUrl") ?? "",
};
this.timeoutMs = config.get<number>("notifier.httpTimeoutMs") ?? 10_000;
this.serviceToken = config.get<string>("app.serviceAuthToken") ?? "";
}
async publish(event: PaymentEvent): Promise<void> {
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}`,
);
}
}

View File

@@ -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<void>;
}
export const PAYMENT_EVENT_PUBLISHER = Symbol("PAYMENT_EVENT_PUBLISHER");

View File

@@ -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<ProviderMethod, PaymentProvider> used to select a gateway. */
export const PAYMENT_PROVIDER_MAP = Symbol("PAYMENT_PROVIDER_MAP");
export type PaymentProviderMap = Map<ProviderMethod, PaymentProvider>;
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 {}

View File

@@ -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 {}

View File

@@ -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<number>("app.reconciliation.sweepIntervalMs") ?? 60_000;
this.staleAfterMs =
config.get<number>("app.reconciliation.staleAfterMs") ?? 60_000;
this.batchSize = config.get<number>("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<void> {
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<void> {
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)}`,
);
}
}
}

View File

@@ -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<string, unknown>;
@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;
}

View File

@@ -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<void> {
const signatureValid = this.provider.verifyWebhookSignature(
payload as unknown as Record<string, unknown>,
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<string, unknown>,
result: {
status: mapped,
providerTxnId: object.transaction_id,
failureCode: object.status,
},
});
}
}

View File

@@ -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<void> {
const signatureValid = this.provider.verifyWebhookSignature(
payload as unknown as Record<string, unknown>,
);
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<string, unknown>,
result: { status: mapped, providerTxnId, failureCode: payload.status },
});
}
}

View File

@@ -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<void> {
const signatureValid = this.provider.verifyWebhookSignature(
payload as unknown as Record<string, unknown>,
);
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<string, unknown>,
result: {
status: mapped,
providerTxnId: payload.transactionId,
paidAt: payload.paidAt ? new Date(payload.paidAt) : undefined,
failureCode: payload.status,
},
});
}
}

View File

@@ -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<void> {
const signatureValid = this.provider.verifyWebhookSignature(
payload as unknown as Record<string, unknown>,
);
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<string, unknown>,
result: {
status: mapped,
providerTxnId: payload.tradeNo,
failureCode: payload.tradeStatus,
},
});
}
}

View File

@@ -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<void> {
// 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<string, unknown>,
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);
}
}

View File

@@ -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<void> {
// 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<string, unknown>,
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;
}
}

View File

@@ -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<PaymentWebhookEvent> {
constructor(
@InjectRepository(PaymentWebhookEvent)
repository: Repository<PaymentWebhookEvent>,
) {
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<PaymentWebhookEvent>,
): Promise<PaymentWebhookEvent | null> {
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<void> {
await this.update(id, {
processedAt: new Date(),
processingError: processingError ?? null,
});
}
}

View File

@@ -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<string, unknown>;
/** 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<void> {
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}`,
);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -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<void> {
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);
});

View File

@@ -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<void> {
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);
});

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

View File

@@ -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"]
}

View File

@@ -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...",

View File

@@ -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)

View File

@@ -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<ProviderInitiationResult> {
async initiate(
input: ProviderInitiationInput,
): Promise<ProviderInitiationResult> {
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<string, unknown>, signature: string): boolean {
verifyWebhookSignature(
payload: Record<string, unknown>,
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<T>(url: string, body: unknown): Promise<T> {
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<T>(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<T>(url: string): Promise<T> {
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<T>(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<string>('card.baseUrl') ?? '';
return this.config.get<string>("card.baseUrl") ?? "";
}
private get apiKey(): string {
return this.config.get<string>('card.apiKey') ?? '';
return this.config.get<string>("card.apiKey") ?? "";
}
private get webhookSecret(): string {
return this.config.get<string>('card.webhookSecret') ?? '';
return this.config.get<string>("card.webhookSecret") ?? "";
}
private get webhookUrl(): string {
return this.config.get<string>('card.webhookUrl') ?? '';
return this.config.get<string>("card.webhookUrl") ?? "";
}
private get returnUrl(): string {
return this.config.get<string>('card.returnUrl') ?? '';
return this.config.get<string>("card.returnUrl") ?? "";
}
}

View File

@@ -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<ProviderInitiationResult> {
async initiate(
input: ProviderInitiationInput,
): Promise<ProviderInitiationResult> {
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<string, unknown>,
};
}
verifyWebhookSignature(payload: Record<string, unknown>): 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, unknown>): 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<T>(url: string, body: unknown): Promise<T> {
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<T>(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<string>('cbe.baseUrl') ?? '';
return this.config.get<string>("cbe.baseUrl") ?? "";
}
private get merchantId(): string {
return this.config.get<string>('cbe.merchantId') ?? '';
return this.config.get<string>("cbe.merchantId") ?? "";
}
private get secretKey(): string {
return this.config.get<string>('cbe.secretKey') ?? '';
return this.config.get<string>("cbe.secretKey") ?? "";
}
private get notifyUrl(): string {
return this.config.get<string>('cbe.notifyUrl') ?? '';
return this.config.get<string>("cbe.notifyUrl") ?? "";
}
private get returnUrl(): string {
return this.config.get<string>('cbe.returnUrl') ?? '';
return this.config.get<string>("cbe.returnUrl") ?? "";
}
}

View File

@@ -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),

View File

@@ -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<ProviderInitiationResult> {
async initiate(
input: ProviderInitiationInput,
): Promise<ProviderInitiationResult> {
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<string, unknown>,
};
}
verifyWebhookSignature(payload: Record<string, unknown>): 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, unknown>): 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<T>(url: string, body: unknown): Promise<T> {
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<T>(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<string>('ebirr.baseUrl') ?? '';
return this.config.get<string>("ebirr.baseUrl") ?? "";
}
private get merchantCode(): string {
return this.config.get<string>('ebirr.merchantCode') ?? '';
return this.config.get<string>("ebirr.merchantCode") ?? "";
}
private get secretKey(): string {
return this.config.get<string>('ebirr.secretKey') ?? '';
return this.config.get<string>("ebirr.secretKey") ?? "";
}
private get notifyUrl(): string {
return this.config.get<string>('ebirr.notifyUrl') ?? '';
return this.config.get<string>("ebirr.notifyUrl") ?? "";
}
private get returnUrl(): string {
return this.config.get<string>('ebirr.returnUrl') ?? '';
return this.config.get<string>("ebirr.returnUrl") ?? "";
}
}

View File

@@ -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<boolean>('telebirr.insecureTls');
const insecure = this.config.get<boolean>("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<ProviderInitiationResult> {
async initiate(
input: ProviderInitiationInput,
): Promise<ProviderInitiationResult> {
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<string, unknown>,
};
}
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<string, unknown>): 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<string, unknown>, this.privateKey);
return { ...req, sign, sign_type: 'SHA256WithRSA' };
const sign = signRequestObject(
req as unknown as Record<string, unknown>,
this.privateKey,
);
return { ...req, sign, sign_type: "SHA256WithRSA" };
}
private buildQueryOrderRequest(merchantOrderId: string): Record<string, unknown> {
private buildQueryOrderRequest(
merchantOrderId: string,
): Record<string, unknown> {
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<string, unknown>, this.privateKey);
return { ...req, sign, sign_type: 'SHA256WithRSA' };
const sign = signRequestObject(
req as Record<string, unknown>,
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<T>(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<string>('telebirr.baseUrl') ?? ''; }
private get webBaseUrl(): string { return this.config.get<string>('telebirr.webBaseUrl') ?? ''; }
private get fabricAppId(): string { return this.config.get<string>('telebirr.fabricAppId') ?? ''; }
private get appSecret(): string { return this.config.get<string>('telebirr.appSecret') ?? ''; }
private get merchantAppId(): string { return this.config.get<string>('telebirr.merchantAppId') ?? ''; }
private get merchantCode(): string { return this.config.get<string>('telebirr.merchantCode') ?? ''; }
private get notifyUrl(): string { return this.config.get<string>('telebirr.notifyUrl') ?? ''; }
private get timeoutExpress(): string { return this.config.get<string>('telebirr.timeoutExpress') ?? '15m'; }
private get privateKey(): string { return this.config.get<string>('telebirr.privateKey') ?? ''; }
private get publicKey(): string { return this.config.get<string>('telebirr.publicKey') ?? ''; }
private get baseUrl(): string {
return this.config.get<string>("telebirr.baseUrl") ?? "";
}
private get webBaseUrl(): string {
return this.config.get<string>("telebirr.webBaseUrl") ?? "";
}
private get fabricAppId(): string {
return this.config.get<string>("telebirr.fabricAppId") ?? "";
}
private get appSecret(): string {
return this.config.get<string>("telebirr.appSecret") ?? "";
}
private get merchantAppId(): string {
return this.config.get<string>("telebirr.merchantAppId") ?? "";
}
private get merchantCode(): string {
return this.config.get<string>("telebirr.merchantCode") ?? "";
}
private get notifyUrl(): string {
return this.config.get<string>("telebirr.notifyUrl") ?? "";
}
private get timeoutExpress(): string {
return this.config.get<string>("telebirr.timeoutExpress") ?? "15m";
}
private get privateKey(): string {
return this.config.get<string>("telebirr.privateKey") ?? "";
}
private get publicKey(): string {
return this.config.get<string>("telebirr.publicKey") ?? "";
}
}

View File

@@ -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<boolean>("waafi.insecureTls");
if (insecure) {
this.logger.warn(
"WAAFI_INSECURE_TLS=true — TLS verification disabled for Waafi calls. DEV ONLY.",
);
}
this.httpsAgent = new https.Agent({ rejectUnauthorized: !insecure });
}
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
const requestBody = this.buildInitiateRequest(input);
const response = await this.postJson<WaafiInitiateResponse>(
async initiate(
input: ProviderInitiationInput,
): Promise<ProviderInitiationResult> {
const requestBody = this.buildPurchaseRequest(input);
const response = await this.postJson<WaafiHppPurchaseResponse>(
`${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<ProviderStatus> {
const requestBody = this.buildQueryRequest(merchantOrderId);
const response = await this.postJson<WaafiQueryResponse>(
const requestBody = this.buildGetTranInfoRequest(merchantOrderId);
const response = await this.postJson<WaafiGetTranInfoResponse>(
`${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<string, unknown>,
};
}
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<string, unknown>): boolean {
// Waafi webhook signature verification
// Implementation depends on Waafi's webhook signature mechanism
const signature = payload.signature as string;
const apiKey = this.apiKey;
if (!signature || !apiKey) {
this.logger.error('Waafi webhook missing signature or API key not configured');
return false;
}
// TODO: Implement actual signature verification based on Waafi documentation
// For now, basic validation
return signature.length > 0;
}
private buildInitiateRequest(input: ProviderInitiationInput): WaafiInitiateRequest {
const amount = input.amountMinor / 100; // Convert minor units to major
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<T>(url: string, body: unknown): Promise<T> {
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<string, unknown> {
const sanitized = { ...body };
if (sanitized.serviceParams?.apiKey) {
sanitized.serviceParams.apiKey = '***REDACTED***';
}
return sanitized as unknown as Record<string, unknown>;
private sanitize(body: WaafiHppPurchaseRequest): Record<string, unknown> {
return {
...body,
serviceParams: { ...body.serviceParams, hppKey: "***REDACTED***" },
};
}
private get baseUrl(): string {
return this.config.get<string>('waafi.baseUrl') ?? 'https://api.waafipay.net';
return (
this.config.get<string>("waafi.baseUrl") ?? "https://sandbox.waafipay.net"
);
}
private get merchantUid(): string {
return this.config.get<string>('waafi.merchantUid') ?? '';
return this.config.get<string>("waafi.merchantUid") ?? "";
}
private get apiUserId(): string {
return this.config.get<string>('waafi.apiUserId') ?? '';
private get storeId(): string {
return this.config.get<string>("waafi.storeId") ?? "";
}
private get apiKey(): string {
return this.config.get<string>('waafi.apiKey') ?? '';
private get hppKey(): string {
return this.config.get<string>("waafi.hppKey") ?? "";
}
private get webhookSecret(): string {
return this.config.get<string>("waafi.webhookSecret") ?? "";
}
private get paymentMethod(): string {
return this.config.get<string>("waafi.paymentMethod") ?? "MWALLET_ACCOUNT";
}
private get currency(): string {
return this.config.get<string>("waafi.currency") ?? "";
}
private get successUrl(): string {
return this.config.get<string>("waafi.successUrl") ?? "";
}
private get failureUrl(): string {
return this.config.get<string>("waafi.failureUrl") ?? "";
}
private get respDataFormat(): number {
return this.config.get<number>("waafi.respDataFormat") ?? 1;
}
}

View File

@@ -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<TServiceParams> {
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<TParams> {
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<WaafiHppPurchaseServiceParams>;
export interface WaafiHppPurchaseParams {
hppUrl: string;
directPaymentLink?: string;
orderId: string;
referenceId: string;
}
export type WaafiHppPurchaseResponse = WaafiResponseEnvelope<WaafiHppPurchaseParams>;
// --- 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<WaafiGetTranInfoServiceParams>;
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<WaafiGetTranInfoParams>;

View File

@@ -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;

View File

@@ -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<ProviderInitiationResult>;
queryStatus(merchantOrderId: string): Promise<ProviderStatus>;
}
/* ------------------------------------------------------------------------------------------------
* 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;

109
pnpm-lock.yaml generated
View File

@@ -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':