mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: ( payment ) integrate the passenger to payment microservice
This commit is contained in:
@@ -21,7 +21,6 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@edr/payment-providers": "workspace:*",
|
|
||||||
"@edr/types": "workspace:*",
|
"@edr/types": "workspace:*",
|
||||||
"@nestjs/axios": "^4.0.1",
|
"@nestjs/axios": "^4.0.1",
|
||||||
"@nestjs/common": "^11.0.0",
|
"@nestjs/common": "^11.0.0",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,50 @@
|
|||||||
export interface GatewayResult { success: boolean; providerRef: string; clientAction?: { type: string; url?: string }; }
|
export interface GatewayResult {
|
||||||
|
success: boolean;
|
||||||
export async function telebirrAdapter(_a: number, ref: string): Promise<GatewayResult> {
|
providerRef: string;
|
||||||
await new Promise((r) => setTimeout(r, 200));
|
clientAction?: { type: string; url?: string };
|
||||||
return { success: true, providerRef: `TB-${ref}-${Date.now()}`, clientAction: { type: 'REDIRECT', url: `https://telebirr.sandbox.com/pay/${ref}` } };
|
}
|
||||||
|
|
||||||
|
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()}` }; }
|
|
||||||
|
|||||||
@@ -1,34 +1,59 @@
|
|||||||
import { Body, Controller, Get, HttpStatus, Param, Post, Query, Res, UseGuards } from '@nestjs/common';
|
import {
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiOkResponse, ApiProduces } from '@nestjs/swagger';
|
Body,
|
||||||
import { Response } from 'express';
|
Controller,
|
||||||
import { PaymentsService } from './payments.service';
|
Get,
|
||||||
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, PaymentRegionEnum, SupportedPaymentMethodDto, PaymentMethodTypeEnum, PaymentPlatformDto } from './payments.dto';
|
HttpStatus,
|
||||||
import { JwtGuard } from '../../common/jwt.guard';
|
Param,
|
||||||
import { RolesGuard } from '../../common/roles.guard';
|
Post,
|
||||||
import { Roles } from '../../common/roles.decorator';
|
Query,
|
||||||
import { UserRole } from '@prisma/client';
|
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')
|
@ApiTags("Payment")
|
||||||
@Controller('payments')
|
@Controller("payments")
|
||||||
export class PaymentsController {
|
export class PaymentsController {
|
||||||
constructor(private service: PaymentsService) {}
|
constructor(private service: PaymentsService) {}
|
||||||
|
|
||||||
@Get('all')
|
@Get("all")
|
||||||
@UseGuards(JwtGuard, RolesGuard)
|
@UseGuards(JwtGuard, RolesGuard)
|
||||||
@Roles(UserRole.ADMIN, UserRole.SUPERVISOR, UserRole.STAFF)
|
@Roles(UserRole.ADMIN, UserRole.SUPERVISOR, UserRole.STAFF)
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth("JWT-auth")
|
||||||
@ApiOperation({ summary: 'Get all payments with filters (staff/admin only)' })
|
@ApiOperation({ summary: "Get all payments with filters (staff/admin only)" })
|
||||||
@ApiQuery({ name: 'search', required: false })
|
@ApiQuery({ name: "search", required: false })
|
||||||
@ApiQuery({ name: 'status', required: false })
|
@ApiQuery({ name: "status", required: false })
|
||||||
@ApiQuery({ name: 'method', required: false })
|
@ApiQuery({ name: "method", required: false })
|
||||||
@ApiQuery({ name: 'page', required: false })
|
@ApiQuery({ name: "page", required: false })
|
||||||
@ApiQuery({ name: 'pageSize', required: false })
|
@ApiQuery({ name: "pageSize", required: false })
|
||||||
async getAll(
|
async getAll(
|
||||||
@Query('search') search?: string,
|
@Query("search") search?: string,
|
||||||
@Query('status') status?: string,
|
@Query("status") status?: string,
|
||||||
@Query('method') method?: string,
|
@Query("method") method?: string,
|
||||||
@Query('page') page?: string,
|
@Query("page") page?: string,
|
||||||
@Query('pageSize') pageSize?: string,
|
@Query("pageSize") pageSize?: string,
|
||||||
) {
|
) {
|
||||||
return this.service.getAll({
|
return this.service.getAll({
|
||||||
search,
|
search,
|
||||||
@@ -38,80 +63,121 @@ export class PaymentsController {
|
|||||||
pageSize: pageSize ? parseInt(pageSize) : 10,
|
pageSize: pageSize ? parseInt(pageSize) : 10,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('initiate')
|
@Post("initiate")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Initiate payment with nationality-based payment methods',
|
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`
|
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); }
|
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); }
|
@Get("intents/:bookingId")
|
||||||
|
@ApiOperation({ summary: "Get payment intent status for a booking" })
|
||||||
@Post('refund')
|
getIntent(@Param("bookingId") bookingId: string) {
|
||||||
|
return this.service.getIntentByBookingId(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("refund")
|
||||||
@UseGuards(JwtGuard, RolesGuard)
|
@UseGuards(JwtGuard, RolesGuard)
|
||||||
@Roles(UserRole.ADMIN, UserRole.STAFF, UserRole.AGENT)
|
@Roles(UserRole.ADMIN, UserRole.STAFF, UserRole.AGENT)
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth("JWT-auth")
|
||||||
@ApiOperation({ summary: 'Refund a confirmed booking (staff/agent only)' })
|
@ApiOperation({ summary: "Refund a confirmed booking (staff/agent only)" })
|
||||||
refund(@Body() dto: RefundDto) { return this.service.refund(dto); }
|
refund(@Body() dto: RefundDto) {
|
||||||
|
return this.service.refund(dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('methods')
|
@Post("methods")
|
||||||
@UseGuards(JwtGuard, RolesGuard)
|
@UseGuards(JwtGuard, RolesGuard)
|
||||||
@Roles(UserRole.ADMIN, UserRole.STAFF)
|
@Roles(UserRole.ADMIN, UserRole.STAFF)
|
||||||
@ApiBearerAuth('JWT-auth')
|
@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')
|
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'List payment systems supported by the platform',
|
summary: "Add a payment system to the platform catalog (admin only)",
|
||||||
description: 'Returns the global catalog of accepted payment systems. Not user-specific. Optionally filter by region to match a passenger\'s nationality.',
|
|
||||||
})
|
})
|
||||||
@ApiQuery({ name: 'region', enum: PaymentRegionEnum, required: false })
|
addMethod(@Body() dto: AddPaymentMethodDto) {
|
||||||
@ApiOkResponse({ type: [SupportedPaymentMethodDto] })
|
return this.service.addPaymentMethod(dto);
|
||||||
getMethods(@Query('region') region?: PaymentRegionEnum) { return this.service.getSupportedPaymentMethods(region); }
|
}
|
||||||
|
|
||||||
@Get('checkout')
|
@Get("methods")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Browser checkout redirect',
|
summary: "List payment systems supported by the platform",
|
||||||
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.',
|
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: "region", enum: PaymentRegionEnum, required: false })
|
||||||
@ApiQuery({ name: 'method', enum: PaymentMethodTypeEnum, required: true })
|
@ApiOkResponse({ type: [SupportedPaymentMethodDto] })
|
||||||
@ApiQuery({ name: 'platform', enum: ['web', 'mobile'], required: false })
|
getMethods(@Query("region") region?: PaymentRegionEnum) {
|
||||||
@ApiProduces('text/html')
|
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(
|
async checkout(
|
||||||
@Query('bookingId') bookingId: string,
|
@Query("bookingId") bookingId: string,
|
||||||
@Query('method') method: PaymentMethodTypeEnum,
|
@Query("method") method: PaymentMethodTypeEnum,
|
||||||
@Query('platform') platform: PaymentPlatformDto = 'web',
|
@Query("platform") platform: PaymentPlatformDto = "web",
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
) {
|
) {
|
||||||
if (!bookingId) {
|
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)) {
|
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 {
|
try {
|
||||||
const result = await this.service.initiatePayment({ bookingId, method, platform });
|
const result = await this.service.initiatePayment({
|
||||||
const url = result.clientAction?.type === 'REDIRECT' ? result.clientAction.url : undefined;
|
bookingId,
|
||||||
|
method,
|
||||||
|
platform,
|
||||||
|
});
|
||||||
|
const url =
|
||||||
|
result.clientAction?.type === "REDIRECT"
|
||||||
|
? result.clientAction.url
|
||||||
|
: undefined;
|
||||||
|
|
||||||
if (url) {
|
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) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : 'An unexpected error occurred';
|
const message =
|
||||||
return res.status(HttpStatus.OK).type('html').send(this.buildErrorHtml(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 {
|
private buildRedirectHtml(url: string): string {
|
||||||
const escaped = url.replace(/\"/g, '"');
|
const escaped = url.replace(/\"/g, """);
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
|
|||||||
@@ -1,36 +1,53 @@
|
|||||||
import { IsString, IsEnum, IsOptional, IsIn, IsBoolean, IsInt } from 'class-validator';
|
import {
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
IsString,
|
||||||
import { PaymentIntentStatus } from '@prisma/client';
|
IsEnum,
|
||||||
|
IsOptional,
|
||||||
|
IsIn,
|
||||||
|
IsBoolean,
|
||||||
|
IsInt,
|
||||||
|
} from "class-validator";
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { PaymentIntentStatus } from "@prisma/client";
|
||||||
|
|
||||||
export enum PaymentRegionEnum {
|
export enum PaymentRegionEnum {
|
||||||
ETHIOPIA = 'ETHIOPIA',
|
ETHIOPIA = "ETHIOPIA",
|
||||||
DJIBOUTI = 'DJIBOUTI',
|
DJIBOUTI = "DJIBOUTI",
|
||||||
INTERNATIONAL = 'INTERNATIONAL',
|
INTERNATIONAL = "INTERNATIONAL",
|
||||||
GLOBAL = 'GLOBAL',
|
GLOBAL = "GLOBAL",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum PaymentMethodTypeEnum {
|
export enum PaymentMethodTypeEnum {
|
||||||
TELEBIRR = 'TELEBIRR', // Ethiopia
|
TELEBIRR = "TELEBIRR", // Ethiopia
|
||||||
CBE_BIRR = 'CBE_BIRR', // Ethiopia
|
CBE_BIRR = "CBE_BIRR", // Ethiopia
|
||||||
EBIRR = 'EBIRR', // Ethiopia
|
EBIRR = "EBIRR", // Ethiopia
|
||||||
WAAFI = 'WAAFI', // Djibouti
|
WAAFI = "WAAFI", // Djibouti
|
||||||
CARD = 'CARD', // International
|
CARD = "CARD", // International
|
||||||
WALLET = 'WALLET' // Internal
|
WALLET = "WALLET", // Internal
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PaymentPlatformDto = 'web' | 'mobile';
|
export type PaymentPlatformDto = "web" | "mobile";
|
||||||
|
|
||||||
export class InitiatePaymentDto {
|
export class InitiatePaymentDto {
|
||||||
@ApiProperty({ example: 'booking-uuid' }) @IsString() bookingId: string;
|
@ApiProperty({ example: "booking-uuid" }) @IsString() bookingId: string;
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
enum: PaymentMethodTypeEnum,
|
enum: PaymentMethodTypeEnum,
|
||||||
description: 'Payment method: TELEBIRR/CBE_BIRR/EBIRR (Ethiopia), WAAFI (Djibouti), CARD (International), WALLET (Internal)',
|
description:
|
||||||
example: 'TELEBIRR'
|
"Payment method: TELEBIRR/CBE_BIRR/EBIRR (Ethiopia), WAAFI (Djibouti), CARD (International), WALLET (Internal)",
|
||||||
}) @IsEnum(PaymentMethodTypeEnum) method: PaymentMethodTypeEnum;
|
example: "TELEBIRR",
|
||||||
@ApiPropertyOptional({ description: 'Saved payment method ID (optional)' }) @IsOptional() @IsString() paymentMethodId?: string;
|
})
|
||||||
@ApiPropertyOptional({ enum: ['web', 'mobile'], default: 'web', description: 'Payment platform (web or mobile)' })
|
@IsEnum(PaymentMethodTypeEnum)
|
||||||
|
method: PaymentMethodTypeEnum;
|
||||||
|
@ApiPropertyOptional({ description: "Saved payment method ID (optional)" })
|
||||||
@IsOptional()
|
@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;
|
platform?: PaymentPlatformDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,42 +57,76 @@ export class RefundDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class AddPaymentMethodDto {
|
export class AddPaymentMethodDto {
|
||||||
@ApiProperty({ enum: PaymentMethodTypeEnum }) @IsEnum(PaymentMethodTypeEnum) type: PaymentMethodTypeEnum;
|
@ApiProperty({ enum: PaymentMethodTypeEnum })
|
||||||
|
@IsEnum(PaymentMethodTypeEnum)
|
||||||
|
type: PaymentMethodTypeEnum;
|
||||||
@ApiProperty() @IsString() displayName: string;
|
@ApiProperty() @IsString() displayName: string;
|
||||||
@ApiProperty({ enum: PaymentRegionEnum }) @IsEnum(PaymentRegionEnum) region: PaymentRegionEnum;
|
@ApiProperty({ enum: PaymentRegionEnum })
|
||||||
@ApiPropertyOptional({ example: 'ETB' }) @IsOptional() @IsString() currency?: string;
|
@IsEnum(PaymentRegionEnum)
|
||||||
|
region: PaymentRegionEnum;
|
||||||
|
@ApiPropertyOptional({ example: "ETB" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
currency?: string;
|
||||||
@ApiPropertyOptional() @IsOptional() @IsString() providerId?: string;
|
@ApiPropertyOptional() @IsOptional() @IsString() providerId?: string;
|
||||||
@ApiPropertyOptional({ default: true }) @IsOptional() @IsBoolean() enabled?: boolean;
|
@ApiPropertyOptional({ default: true })
|
||||||
@ApiPropertyOptional({ default: 0 }) @IsOptional() @IsInt() sortOrder?: number;
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
enabled?: boolean;
|
||||||
|
@ApiPropertyOptional({ default: 0 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
sortOrder?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SupportedPaymentMethodDto {
|
export class SupportedPaymentMethodDto {
|
||||||
@ApiProperty({ enum: PaymentMethodTypeEnum }) type: PaymentMethodTypeEnum;
|
@ApiProperty({ enum: PaymentMethodTypeEnum }) type: PaymentMethodTypeEnum;
|
||||||
@ApiProperty({ example: 'Telebirr' }) displayName: string;
|
@ApiProperty({ example: "Telebirr" }) displayName: string;
|
||||||
@ApiProperty({ enum: PaymentRegionEnum }) region: PaymentRegionEnum;
|
@ApiProperty({ enum: PaymentRegionEnum }) region: PaymentRegionEnum;
|
||||||
@ApiProperty({ example: 'ETB', description: 'Settlement currency for this method' }) currency: string;
|
@ApiProperty({
|
||||||
@ApiProperty({ description: 'Whether the platform currently accepts this method' }) enabled: boolean;
|
example: "ETB",
|
||||||
|
description: "Settlement currency for this method",
|
||||||
|
})
|
||||||
|
currency: string;
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Whether the platform currently accepts this method",
|
||||||
|
})
|
||||||
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ClientActionDto {
|
export class ClientActionDto {
|
||||||
@ApiProperty({ enum: ['REDIRECT', 'LAUNCH_APP'] }) type: 'REDIRECT' | 'LAUNCH_APP';
|
@ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP"] }) type:
|
||||||
@ApiPropertyOptional({ description: 'Set when type=REDIRECT (web flow)' }) url?: string;
|
| "REDIRECT"
|
||||||
@ApiPropertyOptional({ description: 'Set when type=LAUNCH_APP (mobile flow)' }) prepayId?: string;
|
| "LAUNCH_APP";
|
||||||
@ApiPropertyOptional({ description: 'Set when type=LAUNCH_APP (mobile flow)' }) receiveCode?: string;
|
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
|
||||||
@ApiPropertyOptional({ description: 'Set when type=LAUNCH_APP (mobile flow)' }) shortCode?: string;
|
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 {
|
export class InitiateResponseDto {
|
||||||
@ApiProperty() intentId: string;
|
@ApiProperty() intentId: string;
|
||||||
@ApiProperty({ enum: PaymentIntentStatus }) status: PaymentIntentStatus;
|
@ApiProperty({ enum: PaymentIntentStatus }) status: PaymentIntentStatus;
|
||||||
@ApiPropertyOptional({ type: ClientActionDto }) clientAction?: ClientActionDto;
|
@ApiPropertyOptional({ type: ClientActionDto })
|
||||||
|
clientAction?: ClientActionDto;
|
||||||
@ApiPropertyOptional() merchantOrderId?: string;
|
@ApiPropertyOptional() merchantOrderId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class IntentStatusDto {
|
export class IntentStatusDto {
|
||||||
@ApiProperty() intentId: string;
|
@ApiProperty() intentId: string;
|
||||||
@ApiProperty({ enum: PaymentIntentStatus }) status: PaymentIntentStatus;
|
@ApiProperty({ enum: PaymentIntentStatus }) status: PaymentIntentStatus;
|
||||||
@ApiPropertyOptional({ type: ClientActionDto }) clientAction?: ClientActionDto;
|
@ApiPropertyOptional({ type: ClientActionDto })
|
||||||
|
clientAction?: ClientActionDto;
|
||||||
@ApiPropertyOptional() merchantOrderId?: string;
|
@ApiPropertyOptional() merchantOrderId?: string;
|
||||||
@ApiPropertyOptional() paidAt?: string;
|
@ApiPropertyOptional() paidAt?: string;
|
||||||
@ApiPropertyOptional() failureCode?: string;
|
@ApiPropertyOptional() failureCode?: string;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
import { INestApplication, ValidationPipe } from "@nestjs/common";
|
||||||
import request from 'supertest';
|
import request from "supertest";
|
||||||
import { AppModule } from '../../app.module';
|
import { AppModule } from "../../app.module";
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from "../../common/prisma.service";
|
||||||
|
|
||||||
describe('Payments E2E', () => {
|
describe("Payments E2E", () => {
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
let prisma: PrismaService;
|
let prisma: PrismaService;
|
||||||
let authToken: string;
|
let authToken: string;
|
||||||
@@ -16,47 +16,123 @@ describe('Payments E2E', () => {
|
|||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
app = moduleFixture.createNestApplication();
|
app = moduleFixture.createNestApplication();
|
||||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({ transform: true, whitelist: true }),
|
||||||
|
);
|
||||||
await app.init();
|
await app.init();
|
||||||
|
|
||||||
prisma = app.get<PrismaService>(PrismaService);
|
prisma = app.get<PrismaService>(PrismaService);
|
||||||
|
|
||||||
const testUser = await prisma.user.create({
|
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 station1 = await prisma.station.create({
|
||||||
const station2 = await prisma.station.create({ data: { code: 'TST2', name: 'Test Station 2', city: 'Test City 2', lat: 9.5, lng: 38.5 } });
|
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({
|
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({
|
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({
|
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({
|
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;
|
bookingId = booking.id;
|
||||||
});
|
});
|
||||||
@@ -71,89 +147,60 @@ describe('Payments E2E', () => {
|
|||||||
prisma.coach.deleteMany(),
|
prisma.coach.deleteMany(),
|
||||||
prisma.trainSchedule.deleteMany(),
|
prisma.trainSchedule.deleteMany(),
|
||||||
prisma.train.deleteMany(),
|
prisma.train.deleteMany(),
|
||||||
prisma.station.deleteMany({ where: { code: { in: ['TST1', 'TST2'] } } }),
|
prisma.station.deleteMany({ where: { code: { in: ["TST1", "TST2"] } } }),
|
||||||
prisma.walletLedgerEntry.deleteMany(),
|
prisma.walletLedgerEntry.deleteMany(),
|
||||||
prisma.walletAccount.deleteMany(),
|
prisma.walletAccount.deleteMany(),
|
||||||
prisma.passenger.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();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /payments/initiate', () => {
|
describe("POST /payments/initiate", () => {
|
||||||
it('should initiate wallet payment successfully', async () => {
|
it("should initiate wallet payment successfully", async () => {
|
||||||
const response = await request(app.getHttpServer())
|
const response = await request(app.getHttpServer())
|
||||||
.post('/payments/initiate')
|
.post("/payments/initiate")
|
||||||
.set('Authorization', `Bearer ${authToken}`)
|
.set("Authorization", `Bearer ${authToken}`)
|
||||||
.send({ bookingId, method: 'WALLET' })
|
.send({ bookingId, method: "WALLET" })
|
||||||
.expect(201);
|
.expect(201);
|
||||||
expect(response.body.intentId).toBeDefined();
|
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())
|
await request(app.getHttpServer())
|
||||||
.post('/payments/initiate')
|
.post("/payments/initiate")
|
||||||
.set('Authorization', `Bearer ${authToken}`)
|
.set("Authorization", `Bearer ${authToken}`)
|
||||||
.send({ bookingId, method: 'INVALID_METHOD' })
|
.send({ bookingId, method: "INVALID_METHOD" })
|
||||||
.expect(400);
|
.expect(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 404 for non-existent booking', async () => {
|
it("should return 404 for non-existent booking", async () => {
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.post('/payments/initiate')
|
.post("/payments/initiate")
|
||||||
.set('Authorization', `Bearer ${authToken}`)
|
.set("Authorization", `Bearer ${authToken}`)
|
||||||
.send({ bookingId: 'non-existent-id', method: 'WALLET' })
|
.send({ bookingId: "non-existent-id", method: "WALLET" })
|
||||||
.expect(404);
|
.expect(404);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /payments/intents/:bookingId', () => {
|
describe("GET /payments/intents/:bookingId", () => {
|
||||||
it('should get payment intent status', async () => {
|
it("should get payment intent status", async () => {
|
||||||
const response = await request(app.getHttpServer())
|
const response = await request(app.getHttpServer())
|
||||||
.get(`/payments/intents/${bookingId}`)
|
.get(`/payments/intents/${bookingId}`)
|
||||||
.set('Authorization', `Bearer ${authToken}`)
|
.set("Authorization", `Bearer ${authToken}`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
expect(response.body.intentId).toBeDefined();
|
expect(response.body.intentId).toBeDefined();
|
||||||
expect(response.body.status).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())
|
await request(app.getHttpServer())
|
||||||
.get('/payments/intents/non-existent-booking')
|
.get("/payments/intents/non-existent-booking")
|
||||||
.set('Authorization', `Bearer ${authToken}`)
|
.set("Authorization", `Bearer ${authToken}`)
|
||||||
.expect(404);
|
.expect(404);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Webhook endpoints', () => {
|
// Provider webhooks moved to the payment microservice (apps/edr-payment-api /webhooks/*).
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,38 +1,25 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from "@nestjs/common";
|
||||||
import { HttpModule } from '@nestjs/axios';
|
import { HttpModule } from "@nestjs/axios";
|
||||||
import { PaymentsController } from './payments.controller';
|
import { PaymentsController } from "./payments.controller";
|
||||||
import { PaymentsService } from './payments.service';
|
import { PaymentsService } from "./payments.service";
|
||||||
import { SeatsModule } from '../seats/seats.module';
|
import { InternalPaymentsController } from "./internal-payments.controller";
|
||||||
import { TicketsModule } from '../tickets/tickets.module';
|
import { PaymentClientService } from "./payment-client.service";
|
||||||
import {
|
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||||
TelebirrProvider,
|
import { SeatsModule } from "../seats/seats.module";
|
||||||
CbeBirrProvider,
|
import { TicketsModule } from "../tickets/tickets.module";
|
||||||
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';
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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({
|
@Module({
|
||||||
imports: [SeatsModule, TicketsModule, HttpModule.register({ timeout: 10_000 })],
|
imports: [
|
||||||
controllers: [PaymentsController, WebhooksController],
|
SeatsModule,
|
||||||
providers: [
|
TicketsModule,
|
||||||
PaymentsService,
|
HttpModule.register({ timeout: 10_000 }),
|
||||||
TelebirrProvider,
|
|
||||||
CbeBirrProvider,
|
|
||||||
EBirrProvider,
|
|
||||||
CardProvider,
|
|
||||||
WaafiProvider,
|
|
||||||
TelebirrWebhookService,
|
|
||||||
CbeBirrWebhookService,
|
|
||||||
EBirrWebhookService,
|
|
||||||
CardWebhookService,
|
|
||||||
WaafiWebhookService,
|
|
||||||
],
|
],
|
||||||
|
controllers: [PaymentsController, InternalPaymentsController],
|
||||||
|
providers: [PaymentsService, PaymentClientService, ServiceAuthGuard],
|
||||||
})
|
})
|
||||||
export class PaymentsModule {}
|
export class PaymentsModule {}
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
import { PaymentsService } from './payments.service';
|
import { PaymentsService } from "./payments.service";
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PaymentClientService } from "./payment-client.service";
|
||||||
import { SeatsService } from '../seats/seats.service';
|
import { PrismaService } from "../../common/prisma.service";
|
||||||
import { TicketsService } from '../tickets/tickets.service';
|
import { SeatsService } from "../seats/seats.service";
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
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 {
|
import {
|
||||||
TelebirrProvider,
|
PaymentIntentSnapshot,
|
||||||
CbeBirrProvider,
|
PaymentReferenceType,
|
||||||
EBirrProvider,
|
PaymentService as PaymentServiceEnum,
|
||||||
CardProvider,
|
ProviderMethod,
|
||||||
} from '@edr/payment-providers';
|
ProviderPaymentStatus,
|
||||||
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
} from "@edr/types";
|
||||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
|
||||||
|
|
||||||
describe('PaymentsService', () => {
|
describe("PaymentsService", () => {
|
||||||
let service: PaymentsService;
|
let service: PaymentsService;
|
||||||
let prisma: PrismaService;
|
let prisma: PrismaService;
|
||||||
let seatsService: SeatsService;
|
let seatsService: SeatsService;
|
||||||
@@ -62,29 +64,25 @@ describe('PaymentsService', () => {
|
|||||||
emit: jest.fn(),
|
emit: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockTelebirrProvider = {
|
const mockPaymentClient = {
|
||||||
method: PaymentMethodType.TELEBIRR,
|
|
||||||
initiate: jest.fn(),
|
initiate: jest.fn(),
|
||||||
queryStatus: jest.fn(),
|
getIntentByReference: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockCbeBirrProvider = {
|
const requiresActionSnapshot = (
|
||||||
method: PaymentMethodType.CBE_BIRR,
|
provider: ProviderMethod,
|
||||||
initiate: jest.fn(),
|
): PaymentIntentSnapshot => ({
|
||||||
queryStatus: jest.fn(),
|
intentId: "remote-intent-1",
|
||||||
};
|
service: PaymentServiceEnum.PASSENGER,
|
||||||
|
referenceType: PaymentReferenceType.BOOKING,
|
||||||
const mockEBirrProvider = {
|
referenceId: "booking-1",
|
||||||
method: PaymentMethodType.EBIRR,
|
merchantOrderId: "PSG-MERCH-123",
|
||||||
initiate: jest.fn(),
|
provider,
|
||||||
queryStatus: jest.fn(),
|
status: ProviderPaymentStatus.REQUIRES_ACTION,
|
||||||
};
|
amountMinor: 50000,
|
||||||
|
currency: "ETB",
|
||||||
const mockCardProvider = {
|
clientAction: { type: "REDIRECT", url: "https://provider.example/pay" },
|
||||||
method: PaymentMethodType.CARD,
|
});
|
||||||
initiate: jest.fn(),
|
|
||||||
queryStatus: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
@@ -94,10 +92,7 @@ describe('PaymentsService', () => {
|
|||||||
{ provide: SeatsService, useValue: mockSeatsService },
|
{ provide: SeatsService, useValue: mockSeatsService },
|
||||||
{ provide: TicketsService, useValue: mockTicketsService },
|
{ provide: TicketsService, useValue: mockTicketsService },
|
||||||
{ provide: EventEmitter2, useValue: mockEventEmitter },
|
{ provide: EventEmitter2, useValue: mockEventEmitter },
|
||||||
{ provide: TelebirrProvider, useValue: mockTelebirrProvider },
|
{ provide: PaymentClientService, useValue: mockPaymentClient },
|
||||||
{ provide: CbeBirrProvider, useValue: mockCbeBirrProvider },
|
|
||||||
{ provide: EBirrProvider, useValue: mockEBirrProvider },
|
|
||||||
{ provide: CardProvider, useValue: mockCardProvider },
|
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
@@ -108,221 +103,276 @@ describe('PaymentsService', () => {
|
|||||||
eventEmitter = module.get<EventEmitter2>(EventEmitter2);
|
eventEmitter = module.get<EventEmitter2>(EventEmitter2);
|
||||||
|
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
mockPaymentClient.getIntentByReference.mockResolvedValue(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('initiatePayment', () => {
|
describe("initiatePayment", () => {
|
||||||
const mockBooking = {
|
const mockBooking = {
|
||||||
id: 'booking-1',
|
id: "booking-1",
|
||||||
bookingRef: 'EDR123456',
|
bookingRef: "EDR123456",
|
||||||
passengerId: 'passenger-1',
|
passengerId: "passenger-1",
|
||||||
totalMinor: 50000,
|
totalMinor: 50000,
|
||||||
currency: 'ETB',
|
currency: "ETB",
|
||||||
status: 'PENDING_PAYMENT',
|
status: "PENDING_PAYMENT",
|
||||||
seats: [{ id: 'seat-1', seatId: 'seat-id-1' }],
|
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);
|
mockPrisma.booking.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.initiatePayment({
|
service.initiatePayment({
|
||||||
bookingId: 'invalid',
|
bookingId: "invalid",
|
||||||
method: 'TELEBIRR' as any,
|
method: "TELEBIRR" as any,
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow(NotFoundException);
|
).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({
|
mockPrisma.booking.findUnique.mockResolvedValue({
|
||||||
...mockBooking,
|
...mockBooking,
|
||||||
status: 'CONFIRMED',
|
status: "CONFIRMED",
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.initiatePayment({
|
service.initiatePayment({
|
||||||
bookingId: 'booking-1',
|
bookingId: "booking-1",
|
||||||
method: 'TELEBIRR' as any,
|
method: "TELEBIRR" as any,
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow(BadRequestException);
|
).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.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
|
mockPaymentClient.initiate.mockResolvedValue(
|
||||||
mockTelebirrProvider.initiate.mockResolvedValue({
|
requiresActionSnapshot(ProviderMethod.TELEBIRR),
|
||||||
providerOrderId: 'TB-ORDER-123',
|
);
|
||||||
clientAction: { type: 'REDIRECT', url: 'https://telebirr.com/pay' },
|
|
||||||
expiresAt: new Date(),
|
|
||||||
rawInitiation: {},
|
|
||||||
});
|
|
||||||
mockPrisma.paymentIntent.upsert.mockResolvedValue({
|
mockPrisma.paymentIntent.upsert.mockResolvedValue({
|
||||||
id: 'intent-1',
|
id: "intent-1",
|
||||||
status: PaymentIntentStatus.REQUIRES_ACTION,
|
status: PaymentIntentStatus.REQUIRES_ACTION,
|
||||||
merchantOrderId: 'MERCH-123',
|
merchantOrderId: "PSG-MERCH-123",
|
||||||
clientAction: { type: 'REDIRECT', url: 'https://telebirr.com/pay' },
|
clientAction: { type: "REDIRECT", url: "https://provider.example/pay" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await service.initiatePayment({
|
const result = await service.initiatePayment({
|
||||||
bookingId: 'booking-1',
|
bookingId: "booking-1",
|
||||||
method: 'TELEBIRR' as any,
|
method: "TELEBIRR" as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.status).toBe(PaymentIntentStatus.REQUIRES_ACTION);
|
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.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
|
mockPaymentClient.initiate.mockResolvedValue({
|
||||||
mockCbeBirrProvider.initiate.mockResolvedValue({
|
...requiresActionSnapshot(ProviderMethod.WAAFI),
|
||||||
providerOrderId: 'CBE-ORDER-123',
|
status: ProviderPaymentStatus.SUCCEEDED,
|
||||||
clientAction: { type: 'REDIRECT', url: 'https://cbe.com/pay' },
|
providerTxnId: "TXN-1",
|
||||||
expiresAt: new Date(),
|
paidAt: new Date().toISOString(),
|
||||||
rawInitiation: {},
|
|
||||||
});
|
});
|
||||||
|
// Projection clamps SUCCEEDED to PROCESSING; finalizePaymentSuccess flips it.
|
||||||
mockPrisma.paymentIntent.upsert.mockResolvedValue({
|
mockPrisma.paymentIntent.upsert.mockResolvedValue({
|
||||||
id: 'intent-1',
|
id: "intent-1",
|
||||||
status: PaymentIntentStatus.REQUIRES_ACTION,
|
bookingId: "booking-1",
|
||||||
merchantOrderId: 'MERCH-123',
|
status: PaymentIntentStatus.PROCESSING,
|
||||||
clientAction: { type: 'REDIRECT', url: 'https://cbe.com/pay' },
|
|
||||||
});
|
});
|
||||||
|
mockPrisma.paymentIntent.findUnique.mockResolvedValue({
|
||||||
const result = await service.initiatePayment({
|
id: "intent-1",
|
||||||
bookingId: 'booking-1',
|
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',
|
|
||||||
status: PaymentIntentStatus.PROCESSING,
|
status: PaymentIntentStatus.PROCESSING,
|
||||||
});
|
});
|
||||||
mockPrisma.paymentIntent.findUniqueOrThrow.mockResolvedValue({
|
mockPrisma.paymentIntent.findUniqueOrThrow.mockResolvedValue({
|
||||||
id: 'intent-1',
|
id: "intent-1",
|
||||||
status: PaymentIntentStatus.SUCCEEDED,
|
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({
|
mockPrisma.loyaltyAccount.findUnique.mockResolvedValue({
|
||||||
id: 'loyalty-1',
|
id: "loyalty-1",
|
||||||
pointsBalance: 100,
|
pointsBalance: 100,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await service.initiatePayment({
|
const result = await service.initiatePayment({
|
||||||
bookingId: 'booking-1',
|
bookingId: "booking-1",
|
||||||
method: 'WALLET' as any,
|
method: "WALLET" as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.status).toBe(PaymentIntentStatus.SUCCEEDED);
|
expect(result.status).toBe(PaymentIntentStatus.SUCCEEDED);
|
||||||
expect(mockSeatsService.confirmSeats).toHaveBeenCalled();
|
expect(mockSeatsService.confirmSeats).toHaveBeenCalled();
|
||||||
expect(mockTicketsService.generate).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.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
|
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
|
||||||
mockPrisma.walletAccount.findUnique.mockResolvedValue({
|
mockPrisma.walletAccount.findUnique.mockResolvedValue({
|
||||||
id: 'wallet-1',
|
id: "wallet-1",
|
||||||
passengerId: 'passenger-1',
|
passengerId: "passenger-1",
|
||||||
balanceMinor: 10000, // Less than booking total
|
balanceMinor: 10000, // Less than booking total
|
||||||
});
|
});
|
||||||
mockPrisma.paymentIntent.upsert.mockResolvedValue({
|
mockPrisma.paymentIntent.upsert.mockResolvedValue({
|
||||||
id: 'intent-1',
|
id: "intent-1",
|
||||||
status: PaymentIntentStatus.FAILED,
|
status: PaymentIntentStatus.FAILED,
|
||||||
failureCode: 'INSUFFICIENT_BALANCE',
|
failureCode: "INSUFFICIENT_BALANCE",
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await service.initiatePayment({
|
const result = await service.initiatePayment({
|
||||||
bookingId: 'booking-1',
|
bookingId: "booking-1",
|
||||||
method: 'WALLET' as any,
|
method: "WALLET" as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.status).toBe(PaymentIntentStatus.FAILED);
|
expect(result.status).toBe(PaymentIntentStatus.FAILED);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('finalizePaymentSuccess', () => {
|
describe("finalizePaymentSuccess", () => {
|
||||||
it('should finalize payment and issue ticket', async () => {
|
it("should finalize payment and issue ticket", async () => {
|
||||||
const mockIntent = {
|
const mockIntent = {
|
||||||
id: 'intent-1',
|
id: "intent-1",
|
||||||
bookingId: 'booking-1',
|
bookingId: "booking-1",
|
||||||
status: PaymentIntentStatus.PROCESSING,
|
status: PaymentIntentStatus.PROCESSING,
|
||||||
};
|
};
|
||||||
const mockBooking = {
|
const mockBooking = {
|
||||||
id: 'booking-1',
|
id: "booking-1",
|
||||||
passengerId: 'passenger-1',
|
passengerId: "passenger-1",
|
||||||
totalMinor: 50000,
|
totalMinor: 50000,
|
||||||
seats: [{ seatId: 'seat-1' }],
|
seats: [{ seatId: "seat-1" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
mockPrisma.paymentIntent.findUnique.mockResolvedValue(mockIntent);
|
mockPrisma.paymentIntent.findUnique.mockResolvedValue(mockIntent);
|
||||||
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
|
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
mockPrisma.loyaltyAccount.findUnique.mockResolvedValue({
|
mockPrisma.loyaltyAccount.findUnique.mockResolvedValue({
|
||||||
id: 'loyalty-1',
|
id: "loyalty-1",
|
||||||
pointsBalance: 100,
|
pointsBalance: 100,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await service.finalizePaymentSuccess({
|
const result = await service.finalizePaymentSuccess({
|
||||||
intentId: 'intent-1',
|
intentId: "intent-1",
|
||||||
providerTxnId: 'TXN-123',
|
providerTxnId: "TXN-123",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.alreadyFinalized).toBe(false);
|
expect(result.alreadyFinalized).toBe(false);
|
||||||
expect(mockSeatsService.confirmSeats).toHaveBeenCalledWith(['seat-1']);
|
expect(mockSeatsService.confirmSeats).toHaveBeenCalledWith(["seat-1"]);
|
||||||
expect(mockTicketsService.generate).toHaveBeenCalledWith('booking-1');
|
expect(mockTicketsService.generate).toHaveBeenCalledWith("booking-1");
|
||||||
expect(mockEventEmitter.emit).toHaveBeenCalledWith('payment.succeeded', {
|
expect(mockEventEmitter.emit).toHaveBeenCalledWith("payment.succeeded", {
|
||||||
booking: mockBooking,
|
booking: mockBooking,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return alreadyFinalized if payment already succeeded', async () => {
|
it("should return alreadyFinalized if payment already succeeded", async () => {
|
||||||
mockPrisma.paymentIntent.findUnique.mockResolvedValue({
|
mockPrisma.paymentIntent.findUnique.mockResolvedValue({
|
||||||
id: 'intent-1',
|
id: "intent-1",
|
||||||
status: PaymentIntentStatus.SUCCEEDED,
|
status: PaymentIntentStatus.SUCCEEDED,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await service.finalizePaymentSuccess({
|
const result = await service.finalizePaymentSuccess({
|
||||||
intentId: 'intent-1',
|
intentId: "intent-1",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.alreadyFinalized).toBe(true);
|
expect(result.alreadyFinalized).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getIntentByBookingId', () => {
|
describe("getIntentByBookingId", () => {
|
||||||
it('should return intent status', async () => {
|
it("should return the cached local intent when the payment service has none", async () => {
|
||||||
const mockIntent = {
|
const mockIntent = {
|
||||||
id: 'intent-1',
|
id: "intent-1",
|
||||||
bookingId: 'booking-1',
|
bookingId: "booking-1",
|
||||||
status: PaymentIntentStatus.SUCCEEDED,
|
status: PaymentIntentStatus.SUCCEEDED,
|
||||||
method: PaymentMethodType.TELEBIRR,
|
method: PaymentMethodType.TELEBIRR,
|
||||||
paidAt: new Date(),
|
paidAt: new Date(),
|
||||||
merchantOrderId: 'MERCH-123',
|
merchantOrderId: "MERCH-123",
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
mockPrisma.paymentIntent.findUnique.mockResolvedValue(mockIntent);
|
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);
|
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);
|
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,
|
NotFoundException,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
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,
|
ClientAction,
|
||||||
PaymentProvider,
|
|
||||||
ProviderStatus,
|
|
||||||
ProviderPaymentStatus,
|
ProviderPaymentStatus,
|
||||||
TelebirrProvider,
|
} from "@edr/types";
|
||||||
CbeBirrProvider,
|
|
||||||
EBirrProvider,
|
|
||||||
CardProvider,
|
|
||||||
WaafiProvider,
|
|
||||||
createMerchantOrderId,
|
|
||||||
} from '@edr/payment-providers';
|
|
||||||
|
|
||||||
const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [
|
const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [
|
||||||
PaymentIntentStatus.REQUIRES_ACTION,
|
PaymentIntentStatus.REQUIRES_ACTION,
|
||||||
@@ -27,37 +42,30 @@ const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class PaymentsService {
|
export class PaymentsService {
|
||||||
private readonly logger = new Logger(PaymentsService.name);
|
private readonly logger = new Logger(PaymentsService.name);
|
||||||
private readonly providers: Map<PaymentMethodType, PaymentProvider>;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private seatsService: SeatsService,
|
private seatsService: SeatsService,
|
||||||
private ticketsService: TicketsService,
|
private ticketsService: TicketsService,
|
||||||
private eventEmitter: EventEmitter2,
|
private eventEmitter: EventEmitter2,
|
||||||
private telebirrProvider: TelebirrProvider,
|
private paymentClient: PaymentClientService,
|
||||||
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],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 { search, status, method, page = 1, pageSize = 10 } = filters;
|
||||||
const skip = (page - 1) * pageSize;
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
const where: any = {};
|
const where: any = {};
|
||||||
if (search) {
|
if (search) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ id: { contains: search, mode: 'insensitive' } },
|
{ id: { contains: search, mode: "insensitive" } },
|
||||||
{ booking: { bookingRef: { contains: search, mode: 'insensitive' } } },
|
{ booking: { bookingRef: { contains: search, mode: "insensitive" } } },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
if (status) {
|
if (status) {
|
||||||
@@ -73,13 +81,13 @@ export class PaymentsService {
|
|||||||
include: { booking: true },
|
include: { booking: true },
|
||||||
skip,
|
skip,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: "desc" },
|
||||||
}),
|
}),
|
||||||
this.prisma.paymentIntent.count({ where }),
|
this.prisma.paymentIntent.count({ where }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: items.map(item => ({
|
items: items.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
reference: item.id.substring(0, 8),
|
reference: item.id.substring(0, 8),
|
||||||
bookingId: item.bookingId,
|
bookingId: item.bookingId,
|
||||||
@@ -102,30 +110,87 @@ export class PaymentsService {
|
|||||||
where: { id: dto.bookingId },
|
where: { id: dto.bookingId },
|
||||||
include: { seats: true },
|
include: { seats: true },
|
||||||
});
|
});
|
||||||
if (!booking) throw new NotFoundException('Booking not found');
|
if (!booking) throw new NotFoundException("Booking not found");
|
||||||
if (booking.status !== 'PENDING_PAYMENT') {
|
if (booking.status !== "PENDING_PAYMENT") {
|
||||||
throw new BadRequestException('Booking not payable');
|
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const method = dto.method as PaymentMethodType;
|
const method = dto.method as PaymentMethodType;
|
||||||
|
|
||||||
|
// WALLET is an internal balance debit — it never leaves this app.
|
||||||
if (method === PaymentMethodType.WALLET) {
|
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);
|
return this.initiateWalletPayment(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider = this.providers.get(method);
|
// Provider methods go through the payment microservice (docs/payment-service §7.1):
|
||||||
if (provider) {
|
// it owns the intent, the provider session, and the single webhook per provider.
|
||||||
return this.initiateProviderPayment(booking, provider, dto.platform);
|
// 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(
|
private async initiateWalletPayment(
|
||||||
@@ -146,7 +211,7 @@ export class PaymentsService {
|
|||||||
await tx.walletLedgerEntry.create({
|
await tx.walletLedgerEntry.create({
|
||||||
data: {
|
data: {
|
||||||
walletId: wallet.id,
|
walletId: wallet.id,
|
||||||
type: 'DEBIT',
|
type: "DEBIT",
|
||||||
amountMinor: booking.totalMinor,
|
amountMinor: booking.totalMinor,
|
||||||
balanceAfterMinor: newBalance,
|
balanceAfterMinor: newBalance,
|
||||||
description: `Train Ticket - ${booking.bookingRef}`,
|
description: `Train Ticket - ${booking.bookingRef}`,
|
||||||
@@ -161,14 +226,14 @@ export class PaymentsService {
|
|||||||
where: { bookingId: booking.id },
|
where: { bookingId: booking.id },
|
||||||
update: {
|
update: {
|
||||||
status: PaymentIntentStatus.FAILED,
|
status: PaymentIntentStatus.FAILED,
|
||||||
failureCode: 'INSUFFICIENT_BALANCE',
|
failureCode: "INSUFFICIENT_BALANCE",
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
bookingId: booking.id,
|
bookingId: booking.id,
|
||||||
amountMinor: booking.totalMinor,
|
amountMinor: booking.totalMinor,
|
||||||
method: PaymentMethodType.WALLET,
|
method: PaymentMethodType.WALLET,
|
||||||
status: PaymentIntentStatus.FAILED,
|
status: PaymentIntentStatus.FAILED,
|
||||||
failureCode: 'INSUFFICIENT_BALANCE',
|
failureCode: "INSUFFICIENT_BALANCE",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return this.formatIntentResponse(failed);
|
return this.formatIntentResponse(failed);
|
||||||
@@ -192,55 +257,11 @@ export class PaymentsService {
|
|||||||
return this.formatIntentResponse(refreshed);
|
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(
|
private formatIntentResponse(
|
||||||
intent: Prisma.PaymentIntentGetPayload<Record<string, never>>,
|
intent: Prisma.PaymentIntentGetPayload<Record<string, never>>,
|
||||||
): InitiateResponseDto {
|
): InitiateResponseDto {
|
||||||
const clientAction =
|
const clientAction =
|
||||||
intent.clientAction && typeof intent.clientAction === 'object'
|
intent.clientAction && typeof intent.clientAction === "object"
|
||||||
? (intent.clientAction as unknown as ClientAction)
|
? (intent.clientAction as unknown as ClientAction)
|
||||||
: undefined;
|
: undefined;
|
||||||
return {
|
return {
|
||||||
@@ -252,65 +273,52 @@ export class PaymentsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getIntentByBookingId(bookingId: string): Promise<IntentStatusDto> {
|
async getIntentByBookingId(bookingId: string): Promise<IntentStatusDto> {
|
||||||
const intent = await this.prisma.paymentIntent.findUnique({
|
const local = await this.prisma.paymentIntent.findUnique({
|
||||||
where: { bookingId },
|
where: { bookingId },
|
||||||
});
|
});
|
||||||
if (!intent) throw new NotFoundException('PaymentIntent not found');
|
|
||||||
|
|
||||||
const refreshable =
|
// WALLET payments never leave this app — no remote intent exists for them.
|
||||||
intent.status === PaymentIntentStatus.REQUIRES_ACTION ||
|
if (local?.method === PaymentMethodType.WALLET) {
|
||||||
intent.status === PaymentIntentStatus.PROCESSING;
|
return this.formatIntentStatus(local);
|
||||||
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`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
if (!snapshot) {
|
||||||
intentId: string,
|
// Pre-cutover/local-only intent (or service briefly unreachable): serve the cached
|
||||||
status: ProviderStatus,
|
// status. The payment service owns provider refresh for everything initiated after
|
||||||
): Promise<void> {
|
// the cutover; webhooks/mark-paid converge the rest.
|
||||||
const bizContent = (status.rawResponse as { biz_content?: { order_status?: string } })
|
if (!local) throw new NotFoundException("PaymentIntent not found");
|
||||||
?.biz_content;
|
return this.formatIntentStatus(local);
|
||||||
if (bizContent?.order_status === 'PAY_SUCCESS') {
|
}
|
||||||
|
|
||||||
|
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({
|
await this.finalizePaymentSuccess({
|
||||||
intentId,
|
intentId: intent.id,
|
||||||
providerTxnId: status.providerTxnId,
|
providerTxnId: snapshot.providerTxnId,
|
||||||
|
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
|
||||||
});
|
});
|
||||||
return;
|
intent = await this.prisma.paymentIntent.findUniqueOrThrow({
|
||||||
}
|
where: { id: intent.id },
|
||||||
if (status.status === ProviderPaymentStatus.FAILED) {
|
|
||||||
await this.markPaymentFailed({
|
|
||||||
intentId,
|
|
||||||
failureCode: status.failureCode,
|
|
||||||
failureMessage: status.failureMessage,
|
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
await this.prisma.paymentIntent.update({
|
return this.formatIntentStatus(intent);
|
||||||
where: { id: intentId },
|
|
||||||
data: {
|
|
||||||
status: status.status as unknown as PaymentIntentStatus,
|
|
||||||
providerTxnId: status.providerTxnId ?? undefined,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private formatIntentStatus(
|
private formatIntentStatus(
|
||||||
@@ -326,13 +334,25 @@ export class PaymentsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async refund(dto: RefundDto) {
|
async refund(dto: RefundDto) {
|
||||||
const intent = await this.prisma.paymentIntent.findUnique({ where: { bookingId: dto.bookingId } });
|
const intent = await this.prisma.paymentIntent.findUnique({
|
||||||
if (!intent || intent.status !== 'SUCCEEDED') throw new BadRequestException('No successful payment to refund');
|
where: { bookingId: dto.bookingId },
|
||||||
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 (!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) {
|
if (booking) {
|
||||||
await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId));
|
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 };
|
return { refunded: true, bookingRef: booking?.bookingRef };
|
||||||
}
|
}
|
||||||
@@ -342,7 +362,7 @@ export class PaymentsService {
|
|||||||
type: dto.type as unknown as PaymentMethodType,
|
type: dto.type as unknown as PaymentMethodType,
|
||||||
displayName: dto.displayName,
|
displayName: dto.displayName,
|
||||||
region: dto.region as unknown as PaymentRegion,
|
region: dto.region as unknown as PaymentRegion,
|
||||||
currency: dto.currency ?? 'ETB',
|
currency: dto.currency ?? "ETB",
|
||||||
providerId: dto.providerId,
|
providerId: dto.providerId,
|
||||||
enabled: dto.enabled ?? true,
|
enabled: dto.enabled ?? true,
|
||||||
sortOrder: dto.sortOrder ?? 0,
|
sortOrder: dto.sortOrder ?? 0,
|
||||||
@@ -359,10 +379,17 @@ export class PaymentsService {
|
|||||||
where: {
|
where: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
...(region
|
...(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({
|
const intent = await this.prisma.paymentIntent.findUnique({
|
||||||
where: { id: input.intentId },
|
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) {
|
if (intent.status === PaymentIntentStatus.SUCCEEDED) {
|
||||||
return { alreadyFinalized: true };
|
return { alreadyFinalized: true };
|
||||||
}
|
}
|
||||||
if (intent.status === PaymentIntentStatus.CANCELLED) {
|
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({
|
const booking = await this.prisma.booking.findUnique({
|
||||||
where: { id: intent.bookingId },
|
where: { id: intent.bookingId },
|
||||||
include: { seats: true },
|
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();
|
const paidAt = input.paidAt ?? new Date();
|
||||||
await this.prisma.$transaction(async (tx) => {
|
await this.prisma.$transaction(async (tx) => {
|
||||||
@@ -394,45 +423,134 @@ export class PaymentsService {
|
|||||||
where: { id: intent.id },
|
where: { id: intent.id },
|
||||||
data: {
|
data: {
|
||||||
status: PaymentIntentStatus.SUCCEEDED,
|
status: PaymentIntentStatus.SUCCEEDED,
|
||||||
providerTxnId: input.providerTxnId ?? intent.providerTxnId ?? undefined,
|
providerTxnId:
|
||||||
|
input.providerTxnId ?? intent.providerTxnId ?? undefined,
|
||||||
paidAt,
|
paidAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await tx.booking.update({
|
await tx.booking.update({
|
||||||
where: { id: booking.id },
|
where: { id: booking.id },
|
||||||
data: { status: 'CONFIRMED' },
|
data: { status: "CONFIRMED" },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId));
|
await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId));
|
||||||
} catch (err) {
|
} 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 {
|
try {
|
||||||
await this.createJourneySegments(booking);
|
await this.createJourneySegments(booking);
|
||||||
} catch (err) {
|
} 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 {
|
try {
|
||||||
await this.ticketsService.generate(booking.id);
|
await this.ticketsService.generate(booking.id);
|
||||||
} catch (err) {
|
} 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;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.awardLoyaltyPoints(booking.passengerId, booking.totalMinor, booking.id);
|
await this.awardLoyaltyPoints(
|
||||||
|
booking.passengerId,
|
||||||
|
booking.totalMinor,
|
||||||
|
booking.id,
|
||||||
|
);
|
||||||
} catch (err) {
|
} 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 };
|
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: {
|
async markPaymentFailed(input: {
|
||||||
intentId: string;
|
intentId: string;
|
||||||
failureCode?: string;
|
failureCode?: string;
|
||||||
@@ -441,7 +559,7 @@ export class PaymentsService {
|
|||||||
const intent = await this.prisma.paymentIntent.findUnique({
|
const intent = await this.prisma.paymentIntent.findUnique({
|
||||||
where: { id: input.intentId },
|
where: { id: input.intentId },
|
||||||
});
|
});
|
||||||
if (!intent) throw new NotFoundException('PaymentIntent not found');
|
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
||||||
if (
|
if (
|
||||||
intent.status === PaymentIntentStatus.SUCCEEDED ||
|
intent.status === PaymentIntentStatus.SUCCEEDED ||
|
||||||
intent.status === PaymentIntentStatus.CANCELLED
|
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 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;
|
if (!account) return;
|
||||||
const newBalance = account.pointsBalance + points;
|
const newBalance = account.pointsBalance + points;
|
||||||
const tier = newBalance >= 10000 ? 'PLATINUM' : newBalance >= 5000 ? 'GOLD' : newBalance >= 2000 ? 'SILVER' : 'BRONZE';
|
const tier =
|
||||||
await this.prisma.loyaltyAccount.update({ where: { passengerId }, data: { pointsBalance: { increment: points }, tier: tier as any } });
|
newBalance >= 10000
|
||||||
await this.prisma.loyaltyLedgerEntry.create({ data: { accountId: account.id, delta: points, reason: 'TRIP_COMPLETED', bookingId, balanceAfter: newBalance } });
|
? "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({
|
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||||
where: { id: booking.scheduleId },
|
where: { id: booking.scheduleId },
|
||||||
include: { stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
|
include: {
|
||||||
|
stopTimes: { include: { station: true }, orderBy: { sequence: "asc" } },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (!schedule) return;
|
if (!schedule) return;
|
||||||
|
|
||||||
const stopTimes = schedule.stopTimes;
|
const stopTimes = schedule.stopTimes;
|
||||||
if (stopTimes.length < 2) return;
|
if (stopTimes.length < 2) return;
|
||||||
|
|
||||||
const originSequence = stopTimes.findIndex(st => st.stationId === schedule.originStationId);
|
const originSequence = stopTimes.findIndex(
|
||||||
const destSequence = stopTimes.findIndex(st => st.stationId === schedule.destinationStationId);
|
(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({
|
const journey = await this.prisma.journey.create({
|
||||||
data: {
|
data: {
|
||||||
passengerId: booking.passengerId,
|
passengerId: booking.passengerId,
|
||||||
status: 'CONFIRMED',
|
status: "CONFIRMED",
|
||||||
totalMinor: booking.totalMinor,
|
totalMinor: booking.totalMinor,
|
||||||
currency: booking.currency,
|
currency: booking.currency,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// The payment provider contract now lives in @edr/types (consumed via @edr/payment-providers).
|
// The payment provider contract lives in @edr/types; the gateways themselves now run only
|
||||||
// This file remains as a thin re-export so existing local imports keep working.
|
// inside apps/edr-payment-api. This file remains as a thin re-export so existing local
|
||||||
|
// imports keep working.
|
||||||
export type {
|
export type {
|
||||||
PaymentProvider,
|
PaymentProvider,
|
||||||
ProviderInitiationInput,
|
ProviderInitiationInput,
|
||||||
@@ -7,5 +8,5 @@ export type {
|
|||||||
ProviderStatus,
|
ProviderStatus,
|
||||||
ClientAction,
|
ClientAction,
|
||||||
PaymentPlatform,
|
PaymentPlatform,
|
||||||
} from '@edr/types';
|
} from "@edr/types";
|
||||||
export { ProviderPaymentStatus, ProviderMethod } from '@edr/types';
|
export { ProviderPaymentStatus, ProviderMethod } from "@edr/types";
|
||||||
|
|||||||
@@ -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 },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
|
||||||
import {
|
|
||||||
WaafiProvider,
|
|
||||||
WaafiWebhookPayload,
|
|
||||||
WaafiWebhookHeaders,
|
|
||||||
WaafiWebhookTransactionPayload,
|
|
||||||
ProviderPaymentStatus,
|
|
||||||
} from '@edr/payment-providers';
|
|
||||||
import { PrismaService } from '../../../common/prisma.service';
|
|
||||||
import { PaymentsService } from '../payments.service';
|
|
||||||
|
|
||||||
/** Reject webhooks whose timestamp is older than this (replay protection). */
|
|
||||||
const WAAFI_REPLAY_WINDOW_SECONDS = 300;
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class WaafiWebhookService {
|
|
||||||
private readonly logger = new Logger(WaafiWebhookService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly provider: WaafiProvider,
|
|
||||||
private readonly payments: PaymentsService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async handleWebhook(
|
|
||||||
payload: WaafiWebhookPayload,
|
|
||||||
rawBody: string,
|
|
||||||
headers: WaafiWebhookHeaders,
|
|
||||||
): Promise<{ received: boolean }> {
|
|
||||||
|
|
||||||
console.log("Waafi Webhook Service");
|
|
||||||
// Unsigned validation ping sent on registration — acknowledge without verifying or persisting.
|
|
||||||
if (payload.event === 'webhook.test') {
|
|
||||||
this.logger.log('Waafi webhook.test ping received');
|
|
||||||
return { received: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
const { payment } = payload;
|
|
||||||
const merchantOrderId = payment.reference_id;
|
|
||||||
const providerTxnId = payment.transaction_id;
|
|
||||||
const eventId = headers['x-webhook-event-id'];
|
|
||||||
const timestamp = headers['x-webhook-timestamp'];
|
|
||||||
const signature = headers['x-webhook-signature'];
|
|
||||||
|
|
||||||
const signatureValid =
|
|
||||||
this.isFresh(timestamp) &&
|
|
||||||
this.provider.verifyWebhookSignature(rawBody, signature, timestamp, eventId);
|
|
||||||
|
|
||||||
// X-Webhook-Event-Id is unique per event; fall back to a derived id if absent.
|
|
||||||
const externalEventId = eventId ?? `${providerTxnId}_${payment.status}`;
|
|
||||||
|
|
||||||
const eventRow = await this.persistEvent({
|
|
||||||
externalEventId,
|
|
||||||
merchantOrderId,
|
|
||||||
providerTxnId,
|
|
||||||
signatureValid,
|
|
||||||
status: payment.status,
|
|
||||||
payload,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!eventRow) {
|
|
||||||
this.logger.log(`Waafi webhook duplicate: ${externalEventId} — short-circuit OK`);
|
|
||||||
return { received: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!signatureValid) {
|
|
||||||
this.logger.warn(`Waafi webhook signature invalid/stale for ref=${merchantOrderId}`);
|
|
||||||
await this.markProcessed(eventRow.id, 'signature-invalid');
|
|
||||||
return { received: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
const intent = await this.prisma.paymentIntent.findUnique({
|
|
||||||
where: { merchantOrderId },
|
|
||||||
});
|
|
||||||
if (!intent) {
|
|
||||||
this.logger.warn(`Waafi webhook: no PaymentIntent for ref=${merchantOrderId}`);
|
|
||||||
await this.markProcessed(eventRow.id, 'intent-not-found');
|
|
||||||
return { received: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapped = this.provider.mapWebhookStatus(payment.status);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (payload.event === 'refund') {
|
|
||||||
// Refund state is owned by PaymentsService.refund; just record the notification.
|
|
||||||
this.logger.log(
|
|
||||||
`Waafi refund webhook for ref=${merchantOrderId} status=${payment.status}`,
|
|
||||||
);
|
|
||||||
} else if (mapped === ProviderPaymentStatus.SUCCEEDED) {
|
|
||||||
await this.payments.finalizePaymentSuccess({
|
|
||||||
intentId: intent.id,
|
|
||||||
providerTxnId,
|
|
||||||
paidAt: this.parseDate(payment.date),
|
|
||||||
});
|
|
||||||
} else if (
|
|
||||||
mapped === ProviderPaymentStatus.FAILED ||
|
|
||||||
mapped === ProviderPaymentStatus.CANCELLED
|
|
||||||
) {
|
|
||||||
await this.payments.markPaymentFailed({
|
|
||||||
intentId: intent.id,
|
|
||||||
failureCode: payment.status,
|
|
||||||
failureMessage: payment.description,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await this.prisma.paymentIntent.update({
|
|
||||||
where: { id: intent.id },
|
|
||||||
data: {
|
|
||||||
status: mapped as unknown as PaymentIntentStatus,
|
|
||||||
providerTxnId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await this.markProcessed(eventRow.id);
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
this.logger.error(
|
|
||||||
`Waafi webhook processing failed for ${merchantOrderId}: ${message}`,
|
|
||||||
);
|
|
||||||
await this.markProcessed(eventRow.id, `processing-error: ${message}`);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { received: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
private async persistEvent(input: {
|
|
||||||
externalEventId: string;
|
|
||||||
merchantOrderId: string;
|
|
||||||
providerTxnId?: string;
|
|
||||||
signatureValid: boolean;
|
|
||||||
status: string;
|
|
||||||
payload: WaafiWebhookTransactionPayload;
|
|
||||||
}): Promise<{ id: string } | null> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.paymentWebhookEvent.create({
|
|
||||||
data: {
|
|
||||||
provider: PaymentMethodType.WAAFI,
|
|
||||||
externalEventId: input.externalEventId,
|
|
||||||
merchantOrderId: input.merchantOrderId,
|
|
||||||
providerTxnId: input.providerTxnId,
|
|
||||||
signatureValid: input.signatureValid,
|
|
||||||
status: input.status,
|
|
||||||
payload: input.payload as unknown as Prisma.InputJsonValue,
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
if (
|
|
||||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
|
||||||
err.code === 'P2002'
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async markProcessed(eventId: string, processingError?: string): Promise<void> {
|
|
||||||
await this.prisma.paymentWebhookEvent.update({
|
|
||||||
where: { id: eventId },
|
|
||||||
data: { processedAt: new Date(), processingError },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when the webhook timestamp (unix seconds) is within the replay window. */
|
|
||||||
private isFresh(timestamp: string | undefined): boolean {
|
|
||||||
if (!timestamp) return false;
|
|
||||||
const ts = parseInt(timestamp, 10);
|
|
||||||
if (Number.isNaN(ts)) return false;
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
return Math.abs(now - ts) <= WAAFI_REPLAY_WINDOW_SECONDS;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Parse Waafi's "YYYY-MM-DD HH:mm:ss" payment date; undefined when unparseable. */
|
|
||||||
private parseDate(raw: string | undefined): Date | undefined {
|
|
||||||
if (!raw) return undefined;
|
|
||||||
const d = new Date(raw);
|
|
||||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
import {All, Body, Controller, Headers, HttpCode, HttpStatus, Logger, Post, Req} from '@nestjs/common';
|
|
||||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
|
||||||
import {
|
|
||||||
TelebirrWebhookPayload,
|
|
||||||
CbeBirrWebhookPayload,
|
|
||||||
EBirrWebhookPayload,
|
|
||||||
CardWebhookPayload,
|
|
||||||
WaafiWebhookPayload,
|
|
||||||
WaafiWebhookHeaders,
|
|
||||||
} from '@edr/payment-providers';
|
|
||||||
import { TelebirrWebhookService } from './telebirr-webhook.service';
|
|
||||||
import { CbeBirrWebhookService } from './cbe-birr-webhook.service';
|
|
||||||
import { EBirrWebhookService } from './ebirr-webhook.service';
|
|
||||||
import { CardWebhookService } from './card-webhook.service';
|
|
||||||
import { WaafiWebhookService } from './waafi-webhook.service';
|
|
||||||
|
|
||||||
@ApiTags('Payment Webhooks')
|
|
||||||
@Controller('payments/webhooks')
|
|
||||||
export class WebhooksController {
|
|
||||||
private readonly logger = new Logger(WebhooksController.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly telebirr: TelebirrWebhookService,
|
|
||||||
private readonly cbeBirr: CbeBirrWebhookService,
|
|
||||||
private readonly eBirr: EBirrWebhookService,
|
|
||||||
private readonly card: CardWebhookService,
|
|
||||||
private readonly waafi: WaafiWebhookService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@All('telebirr')
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Telebirr payment notification callback (Ethiopia)',
|
|
||||||
description: 'Webhook endpoint for Telebirr payment status updates. Used by Ethiopian passengers.'
|
|
||||||
})
|
|
||||||
async receiveTelebirr(@Body() payload: TelebirrWebhookPayload) {
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Telebirr webhook Called`,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.telebirr.handle(payload);
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
this.logger.error(`Telebirr webhook handler threw: ${message}`);
|
|
||||||
}
|
|
||||||
return { code: '0', message: 'OK' };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('cbe-birr')
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'CBE Birr payment notification callback (Ethiopia)',
|
|
||||||
description: 'Webhook endpoint for Commercial Bank of Ethiopia payment status updates.'
|
|
||||||
})
|
|
||||||
async receiveCbeBirr(@Body() payload: CbeBirrWebhookPayload) {
|
|
||||||
try {
|
|
||||||
await this.cbeBirr.handle(payload);
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
this.logger.error(`CBE Birr webhook handler threw: ${message}`);
|
|
||||||
}
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('ebirr')
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'eBirr payment notification callback (Ethiopia)',
|
|
||||||
description: 'Webhook endpoint for eBirr electronic payment gateway status updates.'
|
|
||||||
})
|
|
||||||
async receiveEBirr(@Body() payload: EBirrWebhookPayload) {
|
|
||||||
try {
|
|
||||||
await this.eBirr.handle(payload);
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
this.logger.error(`eBirr webhook handler threw: ${message}`);
|
|
||||||
}
|
|
||||||
return { code: '0000', message: 'success' };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('card')
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Card payment notification callback (International)',
|
|
||||||
description: 'Webhook endpoint for international card payments (Visa, Mastercard) via Stripe.'
|
|
||||||
})
|
|
||||||
async receiveCard(
|
|
||||||
@Body() payload: CardWebhookPayload,
|
|
||||||
@Headers('stripe-signature') signature: string,
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
await this.card.handle(payload, signature);
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
this.logger.error(`Card webhook handler threw: ${message}`);
|
|
||||||
}
|
|
||||||
return { received: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('waafi')
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Waafi payment notification callback (Djibouti)',
|
|
||||||
description: 'Webhook endpoint for Waafi mobile money payment status updates. Used by Djiboutian passengers.'
|
|
||||||
})
|
|
||||||
async receiveWaafi(
|
|
||||||
@Body() payload: WaafiWebhookPayload,
|
|
||||||
@Headers() headers: WaafiWebhookHeaders,
|
|
||||||
@Req() req: { rawBody?: Buffer },
|
|
||||||
) {
|
|
||||||
this.logger.log(
|
|
||||||
`Waafi webhook hit: event=${payload?.event ?? 'unknown'} eventId=${headers['x-webhook-event-id'] ?? 'n/a'}`,
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
// HMAC verification must sign over the exact raw bytes Waafi sent, not re-serialized JSON.
|
|
||||||
const rawBody = req.rawBody?.toString('utf8') ?? '';
|
|
||||||
await this.waafi.handleWebhook(payload, rawBody, headers);
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
this.logger.error(`Waafi webhook handler threw: ${message}`);
|
|
||||||
}
|
|
||||||
return { responseCode: '2001', responseMsg: 'Success' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -75,6 +75,25 @@ export class InitiatePaymentRequestDto implements InitiatePaymentRequest {
|
|||||||
@MaxLength(32)
|
@MaxLength(32)
|
||||||
payerAccount?: string;
|
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({
|
@ApiPropertyOptional({
|
||||||
description: "Caller key to dedupe retried initiations",
|
description: "Caller key to dedupe retried initiations",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { DataSource, QueryFailedError } from "typeorm";
|
|||||||
import { createMerchantOrderId } from "@edr/payment-providers";
|
import { createMerchantOrderId } from "@edr/payment-providers";
|
||||||
import {
|
import {
|
||||||
InitiatePaymentRequest,
|
InitiatePaymentRequest,
|
||||||
MERCHANT_ORDER_PREFIX,
|
|
||||||
PaymentIntentSnapshot,
|
PaymentIntentSnapshot,
|
||||||
PaymentReferenceType,
|
PaymentReferenceType,
|
||||||
PaymentService,
|
PaymentService,
|
||||||
@@ -60,6 +59,7 @@ export class IntentsService {
|
|||||||
async initiate(
|
async initiate(
|
||||||
request: InitiatePaymentRequest,
|
request: InitiatePaymentRequest,
|
||||||
): Promise<PaymentIntentSnapshot> {
|
): Promise<PaymentIntentSnapshot> {
|
||||||
|
|
||||||
if (request.idempotencyKey) {
|
if (request.idempotencyKey) {
|
||||||
const byKey = await this.intentsRepository.findByIdempotencyKey(
|
const byKey = await this.intentsRepository.findByIdempotencyKey(
|
||||||
request.service,
|
request.service,
|
||||||
@@ -85,7 +85,7 @@ export class IntentsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const merchantOrderId = `${MERCHANT_ORDER_PREFIX[request.service]}${createMerchantOrderId()}`;
|
const merchantOrderId = createMerchantOrderId();
|
||||||
const result = await provider.initiate({
|
const result = await provider.initiate({
|
||||||
merchantOrderId,
|
merchantOrderId,
|
||||||
orderRef: request.orderRef ?? request.referenceId,
|
orderRef: request.orderRef ?? request.referenceId,
|
||||||
@@ -93,6 +93,9 @@ export class IntentsService {
|
|||||||
currency: request.currency,
|
currency: request.currency,
|
||||||
platform: request.platform,
|
platform: request.platform,
|
||||||
payerAccount: request.payerAccount,
|
payerAccount: request.payerAccount,
|
||||||
|
returnUrl: request.returnUrl,
|
||||||
|
redirectUrl: request.returnUrl,
|
||||||
|
failureUrl: request.failureUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -116,8 +119,6 @@ export class IntentsService {
|
|||||||
);
|
);
|
||||||
return this.toSnapshot(intent);
|
return this.toSnapshot(intent);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Concurrent initiate for the same reference lost the partial-unique race — return the
|
|
||||||
// winner's intent. The provider session we just opened is simply abandoned.
|
|
||||||
if (
|
if (
|
||||||
err instanceof QueryFailedError &&
|
err instanceof QueryFailedError &&
|
||||||
(err.driverError as { code?: string })?.code === PG_UNIQUE_VIOLATION
|
(err.driverError as { code?: string })?.code === PG_UNIQUE_VIOLATION
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import {
|
import { ProviderMethod } from "@edr/types";
|
||||||
MERCHANT_ORDER_PREFIX,
|
|
||||||
PaymentService,
|
|
||||||
ProviderMethod,
|
|
||||||
} from "@edr/types";
|
|
||||||
import { IntentsRepository } from "../intents/intents.repository";
|
import { IntentsRepository } from "../intents/intents.repository";
|
||||||
import {
|
import {
|
||||||
IntentsService,
|
IntentsService,
|
||||||
@@ -81,20 +77,6 @@ export class WebhookProcessorService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Integrity guard (§10): the stateless prefix and the stored discriminator must agree.
|
|
||||||
const expectedPrefix =
|
|
||||||
MERCHANT_ORDER_PREFIX[intent.service as PaymentService];
|
|
||||||
if (expectedPrefix && !merchantOrderId.startsWith(expectedPrefix)) {
|
|
||||||
this.logger.error(
|
|
||||||
`${provider} webhook: merchantOrderId ${merchantOrderId} prefix does not match stored service ${intent.service} — refusing to process`,
|
|
||||||
);
|
|
||||||
await this.webhookEvents.markProcessed(
|
|
||||||
eventRow.id,
|
|
||||||
"service-prefix-mismatch",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.intentsService.applyProviderResult(intent.id, webhook.result);
|
await this.intentsService.applyProviderResult(intent.id, webhook.result);
|
||||||
await this.webhookEvents.markProcessed(eventRow.id);
|
await this.webhookEvents.markProcessed(eventRow.id);
|
||||||
|
|||||||
@@ -112,6 +112,8 @@ export class WebhooksController {
|
|||||||
@Headers() headers: WaafiWebhookHeaders,
|
@Headers() headers: WaafiWebhookHeaders,
|
||||||
@Req() req: { rawBody?: Buffer },
|
@Req() req: { rawBody?: Buffer },
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
this.logger.log("\n\n\n\nWaafi payment notification callback (Djibouti)\n\n\n\n");
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Waafi webhook hit: event=${payload?.event ?? "unknown"} eventId=${headers["x-webhook-event-id"] ?? "n/a"}`,
|
`Waafi webhook hit: event=${payload?.event ?? "unknown"} eventId=${headers["x-webhook-event-id"] ?? "n/a"}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { HttpService } from '@nestjs/axios';
|
import { HttpService } from "@nestjs/axios";
|
||||||
import {
|
import {
|
||||||
PaymentProvider,
|
PaymentProvider,
|
||||||
ProviderInitiationInput,
|
ProviderInitiationInput,
|
||||||
@@ -8,10 +8,10 @@ import {
|
|||||||
ProviderStatus,
|
ProviderStatus,
|
||||||
ProviderPaymentStatus,
|
ProviderPaymentStatus,
|
||||||
ProviderMethod,
|
ProviderMethod,
|
||||||
} from '@edr/types';
|
} from "@edr/types";
|
||||||
import { AxiosError, AxiosRequestConfig } from 'axios';
|
import { AxiosError, AxiosRequestConfig } from "axios";
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from "rxjs";
|
||||||
import * as crypto from 'node:crypto';
|
import * as crypto from "node:crypto";
|
||||||
|
|
||||||
interface CardInitiateRequest {
|
interface CardInitiateRequest {
|
||||||
amount: number;
|
amount: number;
|
||||||
@@ -54,7 +54,9 @@ export class CardProvider implements PaymentProvider {
|
|||||||
private readonly http: HttpService,
|
private readonly http: HttpService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
|
async initiate(
|
||||||
|
input: ProviderInitiationInput,
|
||||||
|
): Promise<ProviderInitiationResult> {
|
||||||
const amount = input.amountMinor / 100;
|
const amount = input.amountMinor / 100;
|
||||||
|
|
||||||
const requestBody: CardInitiateRequest = {
|
const requestBody: CardInitiateRequest = {
|
||||||
@@ -65,7 +67,8 @@ export class CardProvider implements PaymentProvider {
|
|||||||
merchantOrderId: input.merchantOrderId,
|
merchantOrderId: input.merchantOrderId,
|
||||||
orderRef: input.orderRef,
|
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,
|
webhook_url: this.webhookUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -75,14 +78,16 @@ export class CardProvider implements PaymentProvider {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!response.id) {
|
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);
|
const expiresAt = new Date(response.expires_at * 1000);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
providerOrderId: response.id,
|
providerOrderId: response.id,
|
||||||
clientAction: { type: 'REDIRECT', url: response.checkout_url },
|
clientAction: { type: "REDIRECT", url: response.checkout_url },
|
||||||
expiresAt,
|
expiresAt,
|
||||||
rawInitiation: {
|
rawInitiation: {
|
||||||
request: requestBody,
|
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 payloadString = JSON.stringify(payload);
|
||||||
const expectedSignature = crypto
|
const expectedSignature = crypto
|
||||||
.createHmac('sha256', this.webhookSecret)
|
.createHmac("sha256", this.webhookSecret)
|
||||||
.update(payloadString)
|
.update(payloadString)
|
||||||
.digest('hex');
|
.digest("hex");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return crypto.timingSafeEqual(
|
return crypto.timingSafeEqual(
|
||||||
@@ -132,18 +140,18 @@ export class CardProvider implements PaymentProvider {
|
|||||||
|
|
||||||
private mapStatus(status: string): ProviderPaymentStatus {
|
private mapStatus(status: string): ProviderPaymentStatus {
|
||||||
switch (status?.toLowerCase()) {
|
switch (status?.toLowerCase()) {
|
||||||
case 'succeeded':
|
case "succeeded":
|
||||||
case 'paid':
|
case "paid":
|
||||||
return ProviderPaymentStatus.SUCCEEDED;
|
return ProviderPaymentStatus.SUCCEEDED;
|
||||||
case 'failed':
|
case "failed":
|
||||||
case 'canceled':
|
case "canceled":
|
||||||
case 'expired':
|
case "expired":
|
||||||
return ProviderPaymentStatus.FAILED;
|
return ProviderPaymentStatus.FAILED;
|
||||||
case 'requires_payment_method':
|
case "requires_payment_method":
|
||||||
case 'requires_confirmation':
|
case "requires_confirmation":
|
||||||
case 'requires_action':
|
case "requires_action":
|
||||||
return ProviderPaymentStatus.REQUIRES_ACTION;
|
return ProviderPaymentStatus.REQUIRES_ACTION;
|
||||||
case 'processing':
|
case "processing":
|
||||||
return ProviderPaymentStatus.PROCESSING;
|
return ProviderPaymentStatus.PROCESSING;
|
||||||
default:
|
default:
|
||||||
return ProviderPaymentStatus.PROCESSING;
|
return ProviderPaymentStatus.PROCESSING;
|
||||||
@@ -153,8 +161,8 @@ export class CardProvider implements PaymentProvider {
|
|||||||
private async postJson<T>(url: string, body: unknown): Promise<T> {
|
private async postJson<T>(url: string, body: unknown): Promise<T> {
|
||||||
const config: AxiosRequestConfig = {
|
const config: AxiosRequestConfig = {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
'Authorization': `Bearer ${this.apiKey}`,
|
Authorization: `Bearer ${this.apiKey}`,
|
||||||
},
|
},
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
};
|
};
|
||||||
@@ -162,7 +170,9 @@ export class CardProvider implements PaymentProvider {
|
|||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
try {
|
try {
|
||||||
const res = await firstValueFrom(this.http.post<T>(url, body, config));
|
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;
|
return res.data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof AxiosError) {
|
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)}`,
|
`Card Gateway POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
|
||||||
);
|
);
|
||||||
} else {
|
} 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;
|
throw err;
|
||||||
}
|
}
|
||||||
@@ -179,7 +191,7 @@ export class CardProvider implements PaymentProvider {
|
|||||||
private async getJson<T>(url: string): Promise<T> {
|
private async getJson<T>(url: string): Promise<T> {
|
||||||
const config: AxiosRequestConfig = {
|
const config: AxiosRequestConfig = {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${this.apiKey}`,
|
Authorization: `Bearer ${this.apiKey}`,
|
||||||
},
|
},
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
};
|
};
|
||||||
@@ -187,7 +199,9 @@ export class CardProvider implements PaymentProvider {
|
|||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
try {
|
try {
|
||||||
const res = await firstValueFrom(this.http.get<T>(url, config));
|
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;
|
return res.data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof AxiosError) {
|
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)}`,
|
`Card Gateway GET ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
|
||||||
);
|
);
|
||||||
} else {
|
} 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;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private get baseUrl(): string {
|
private get baseUrl(): string {
|
||||||
return this.config.get<string>('card.baseUrl') ?? '';
|
return this.config.get<string>("card.baseUrl") ?? "";
|
||||||
}
|
}
|
||||||
private get apiKey(): string {
|
private get apiKey(): string {
|
||||||
return this.config.get<string>('card.apiKey') ?? '';
|
return this.config.get<string>("card.apiKey") ?? "";
|
||||||
}
|
}
|
||||||
private get webhookSecret(): string {
|
private get webhookSecret(): string {
|
||||||
return this.config.get<string>('card.webhookSecret') ?? '';
|
return this.config.get<string>("card.webhookSecret") ?? "";
|
||||||
}
|
}
|
||||||
private get webhookUrl(): string {
|
private get webhookUrl(): string {
|
||||||
return this.config.get<string>('card.webhookUrl') ?? '';
|
return this.config.get<string>("card.webhookUrl") ?? "";
|
||||||
}
|
}
|
||||||
private get returnUrl(): string {
|
private get returnUrl(): string {
|
||||||
return this.config.get<string>('card.returnUrl') ?? '';
|
return this.config.get<string>("card.returnUrl") ?? "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { HttpService } from '@nestjs/axios';
|
import { HttpService } from "@nestjs/axios";
|
||||||
import {
|
import {
|
||||||
PaymentProvider,
|
PaymentProvider,
|
||||||
ProviderInitiationInput,
|
ProviderInitiationInput,
|
||||||
@@ -8,10 +8,10 @@ import {
|
|||||||
ProviderStatus,
|
ProviderStatus,
|
||||||
ProviderPaymentStatus,
|
ProviderPaymentStatus,
|
||||||
ProviderMethod,
|
ProviderMethod,
|
||||||
} from '@edr/types';
|
} from "@edr/types";
|
||||||
import { AxiosError, AxiosRequestConfig } from 'axios';
|
import { AxiosError, AxiosRequestConfig } from "axios";
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from "rxjs";
|
||||||
import * as crypto from 'node:crypto';
|
import * as crypto from "node:crypto";
|
||||||
|
|
||||||
interface CbeBirrInitiateRequest {
|
interface CbeBirrInitiateRequest {
|
||||||
merchantId: string;
|
merchantId: string;
|
||||||
@@ -51,7 +51,9 @@ export class CbeBirrProvider implements PaymentProvider {
|
|||||||
private readonly http: HttpService,
|
private readonly http: HttpService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
|
async initiate(
|
||||||
|
input: ProviderInitiationInput,
|
||||||
|
): Promise<ProviderInitiationResult> {
|
||||||
const amount = (input.amountMinor / 100).toFixed(2);
|
const amount = (input.amountMinor / 100).toFixed(2);
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
|
|
||||||
@@ -61,7 +63,8 @@ export class CbeBirrProvider implements PaymentProvider {
|
|||||||
amount,
|
amount,
|
||||||
currency: input.currency,
|
currency: input.currency,
|
||||||
description: `EDR ${input.orderRef}`,
|
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,
|
notifyUrl: this.notifyUrl,
|
||||||
timestamp,
|
timestamp,
|
||||||
signature: this.signRequest({
|
signature: this.signRequest({
|
||||||
@@ -85,7 +88,7 @@ export class CbeBirrProvider implements PaymentProvider {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
providerOrderId: response.orderId,
|
providerOrderId: response.orderId,
|
||||||
clientAction: { type: 'REDIRECT', url: response.paymentUrl },
|
clientAction: { type: "REDIRECT", url: response.paymentUrl },
|
||||||
expiresAt,
|
expiresAt,
|
||||||
rawInitiation: {
|
rawInitiation: {
|
||||||
request: this.sanitize(requestBody),
|
request: this.sanitize(requestBody),
|
||||||
@@ -117,14 +120,15 @@ export class CbeBirrProvider implements PaymentProvider {
|
|||||||
return {
|
return {
|
||||||
status: mapped,
|
status: mapped,
|
||||||
providerTxnId: response.transactionId,
|
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>,
|
rawResponse: response as unknown as Record<string, unknown>,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
|
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
|
||||||
const { signature, ...data } = payload;
|
const { signature, ...data } = payload;
|
||||||
if (!signature || typeof signature !== 'string') return false;
|
if (!signature || typeof signature !== "string") return false;
|
||||||
|
|
||||||
const expectedSignature = this.signRequest(data);
|
const expectedSignature = this.signRequest(data);
|
||||||
return crypto.timingSafeEqual(
|
return crypto.timingSafeEqual(
|
||||||
@@ -139,16 +143,16 @@ export class CbeBirrProvider implements PaymentProvider {
|
|||||||
|
|
||||||
private mapStatus(status: string): ProviderPaymentStatus {
|
private mapStatus(status: string): ProviderPaymentStatus {
|
||||||
switch (status?.toUpperCase()) {
|
switch (status?.toUpperCase()) {
|
||||||
case 'SUCCESS':
|
case "SUCCESS":
|
||||||
case 'COMPLETED':
|
case "COMPLETED":
|
||||||
return ProviderPaymentStatus.SUCCEEDED;
|
return ProviderPaymentStatus.SUCCEEDED;
|
||||||
case 'FAILED':
|
case "FAILED":
|
||||||
case 'REJECTED':
|
case "REJECTED":
|
||||||
case 'EXPIRED':
|
case "EXPIRED":
|
||||||
return ProviderPaymentStatus.FAILED;
|
return ProviderPaymentStatus.FAILED;
|
||||||
case 'PENDING':
|
case "PENDING":
|
||||||
return ProviderPaymentStatus.REQUIRES_ACTION;
|
return ProviderPaymentStatus.REQUIRES_ACTION;
|
||||||
case 'PROCESSING':
|
case "PROCESSING":
|
||||||
return ProviderPaymentStatus.PROCESSING;
|
return ProviderPaymentStatus.PROCESSING;
|
||||||
default:
|
default:
|
||||||
return ProviderPaymentStatus.PROCESSING;
|
return ProviderPaymentStatus.PROCESSING;
|
||||||
@@ -157,21 +161,19 @@ export class CbeBirrProvider implements PaymentProvider {
|
|||||||
|
|
||||||
private signRequest(data: Record<string, unknown>): string {
|
private signRequest(data: Record<string, unknown>): string {
|
||||||
const sortedKeys = Object.keys(data).sort();
|
const sortedKeys = Object.keys(data).sort();
|
||||||
const signString = sortedKeys
|
const signString = sortedKeys.map((key) => `${key}=${data[key]}`).join("&");
|
||||||
.map((key) => `${key}=${data[key]}`)
|
|
||||||
.join('&');
|
|
||||||
|
|
||||||
return crypto
|
return crypto
|
||||||
.createHmac('sha256', this.secretKey)
|
.createHmac("sha256", this.secretKey)
|
||||||
.update(signString)
|
.update(signString)
|
||||||
.digest('hex');
|
.digest("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async postJson<T>(url: string, body: unknown): Promise<T> {
|
private async postJson<T>(url: string, body: unknown): Promise<T> {
|
||||||
const config: AxiosRequestConfig = {
|
const config: AxiosRequestConfig = {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
'X-Merchant-Id': this.merchantId,
|
"X-Merchant-Id": this.merchantId,
|
||||||
},
|
},
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
};
|
};
|
||||||
@@ -179,7 +181,9 @@ export class CbeBirrProvider implements PaymentProvider {
|
|||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
try {
|
try {
|
||||||
const res = await firstValueFrom(this.http.post<T>(url, body, config));
|
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;
|
return res.data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof AxiosError) {
|
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)}`,
|
`CBE Birr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
|
||||||
);
|
);
|
||||||
} else {
|
} 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;
|
throw err;
|
||||||
}
|
}
|
||||||
@@ -199,18 +205,18 @@ export class CbeBirrProvider implements PaymentProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private get baseUrl(): string {
|
private get baseUrl(): string {
|
||||||
return this.config.get<string>('cbe.baseUrl') ?? '';
|
return this.config.get<string>("cbe.baseUrl") ?? "";
|
||||||
}
|
}
|
||||||
private get merchantId(): string {
|
private get merchantId(): string {
|
||||||
return this.config.get<string>('cbe.merchantId') ?? '';
|
return this.config.get<string>("cbe.merchantId") ?? "";
|
||||||
}
|
}
|
||||||
private get secretKey(): string {
|
private get secretKey(): string {
|
||||||
return this.config.get<string>('cbe.secretKey') ?? '';
|
return this.config.get<string>("cbe.secretKey") ?? "";
|
||||||
}
|
}
|
||||||
private get notifyUrl(): string {
|
private get notifyUrl(): string {
|
||||||
return this.config.get<string>('cbe.notifyUrl') ?? '';
|
return this.config.get<string>("cbe.notifyUrl") ?? "";
|
||||||
}
|
}
|
||||||
private get returnUrl(): string {
|
private get returnUrl(): string {
|
||||||
return this.config.get<string>('cbe.returnUrl') ?? '';
|
return this.config.get<string>("cbe.returnUrl") ?? "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export class DMoneyProvider implements PaymentProvider {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly http: HttpService,
|
private readonly http: HttpService,
|
||||||
) { }
|
) {}
|
||||||
|
|
||||||
async initiate(
|
async initiate(
|
||||||
input: ProviderInitiationInput,
|
input: ProviderInitiationInput,
|
||||||
@@ -99,9 +99,9 @@ export class DMoneyProvider implements PaymentProvider {
|
|||||||
clientAction: response.checkoutUrl
|
clientAction: response.checkoutUrl
|
||||||
? { type: "REDIRECT", url: response.checkoutUrl }
|
? { type: "REDIRECT", url: response.checkoutUrl }
|
||||||
: {
|
: {
|
||||||
type: "REDIRECT",
|
type: "REDIRECT",
|
||||||
url: `${this.baseUrl}/checkout/${response.orderId}`,
|
url: `${this.baseUrl}/checkout/${response.orderId}`,
|
||||||
},
|
},
|
||||||
expiresAt,
|
expiresAt,
|
||||||
rawInitiation: {
|
rawInitiation: {
|
||||||
request: this.sanitize(requestBody),
|
request: this.sanitize(requestBody),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { HttpService } from '@nestjs/axios';
|
import { HttpService } from "@nestjs/axios";
|
||||||
import {
|
import {
|
||||||
PaymentProvider,
|
PaymentProvider,
|
||||||
ProviderInitiationInput,
|
ProviderInitiationInput,
|
||||||
@@ -8,10 +8,10 @@ import {
|
|||||||
ProviderStatus,
|
ProviderStatus,
|
||||||
ProviderPaymentStatus,
|
ProviderPaymentStatus,
|
||||||
ProviderMethod,
|
ProviderMethod,
|
||||||
} from '@edr/types';
|
} from "@edr/types";
|
||||||
import { AxiosError, AxiosRequestConfig } from 'axios';
|
import { AxiosError, AxiosRequestConfig } from "axios";
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from "rxjs";
|
||||||
import * as crypto from 'node:crypto';
|
import * as crypto from "node:crypto";
|
||||||
|
|
||||||
interface EBirrInitiateRequest {
|
interface EBirrInitiateRequest {
|
||||||
merchantCode: string;
|
merchantCode: string;
|
||||||
@@ -58,7 +58,9 @@ export class EBirrProvider implements PaymentProvider {
|
|||||||
private readonly http: HttpService,
|
private readonly http: HttpService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
|
async initiate(
|
||||||
|
input: ProviderInitiationInput,
|
||||||
|
): Promise<ProviderInitiationResult> {
|
||||||
const amount = input.amountMinor / 100;
|
const amount = input.amountMinor / 100;
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
|
|
||||||
@@ -70,7 +72,8 @@ export class EBirrProvider implements PaymentProvider {
|
|||||||
subject: `EDR Ticket`,
|
subject: `EDR Ticket`,
|
||||||
body: `Order ${input.orderRef}`,
|
body: `Order ${input.orderRef}`,
|
||||||
notifyUrl: this.notifyUrl,
|
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,
|
timestamp,
|
||||||
sign: this.signRequest({
|
sign: this.signRequest({
|
||||||
merchantCode: this.merchantCode,
|
merchantCode: this.merchantCode,
|
||||||
@@ -85,7 +88,7 @@ export class EBirrProvider implements PaymentProvider {
|
|||||||
requestBody,
|
requestBody,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.code !== '0000' || !response.data?.orderNo) {
|
if (response.code !== "0000" || !response.data?.orderNo) {
|
||||||
throw new Error(`eBirr initiate failed: ${response.message}`);
|
throw new Error(`eBirr initiate failed: ${response.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +96,7 @@ export class EBirrProvider implements PaymentProvider {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
providerOrderId: response.data.orderNo,
|
providerOrderId: response.data.orderNo,
|
||||||
clientAction: { type: 'REDIRECT', url: response.data.payUrl },
|
clientAction: { type: "REDIRECT", url: response.data.payUrl },
|
||||||
expiresAt,
|
expiresAt,
|
||||||
rawInitiation: {
|
rawInitiation: {
|
||||||
request: this.sanitize(requestBody),
|
request: this.sanitize(requestBody),
|
||||||
@@ -120,7 +123,7 @@ export class EBirrProvider implements PaymentProvider {
|
|||||||
requestBody,
|
requestBody,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.code !== '0000' || !response.data) {
|
if (response.code !== "0000" || !response.data) {
|
||||||
throw new Error(`eBirr query failed: ${response.message}`);
|
throw new Error(`eBirr query failed: ${response.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,20 +132,20 @@ export class EBirrProvider implements PaymentProvider {
|
|||||||
return {
|
return {
|
||||||
status: mapped,
|
status: mapped,
|
||||||
providerTxnId: response.data.tradeNo,
|
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>,
|
rawResponse: response as unknown as Record<string, unknown>,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
|
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
|
||||||
const { sign, ...data } = payload;
|
const { sign, ...data } = payload;
|
||||||
if (!sign || typeof sign !== 'string') return false;
|
if (!sign || typeof sign !== "string") return false;
|
||||||
|
|
||||||
const expectedSign = this.signRequest(data);
|
const expectedSign = this.signRequest(data);
|
||||||
return crypto.timingSafeEqual(
|
return crypto.timingSafeEqual(Buffer.from(sign), Buffer.from(expectedSign));
|
||||||
Buffer.from(sign),
|
|
||||||
Buffer.from(expectedSign),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
mapWebhookStatus(tradeStatus: string): ProviderPaymentStatus {
|
mapWebhookStatus(tradeStatus: string): ProviderPaymentStatus {
|
||||||
@@ -151,17 +154,17 @@ export class EBirrProvider implements PaymentProvider {
|
|||||||
|
|
||||||
private mapStatus(tradeStatus: string): ProviderPaymentStatus {
|
private mapStatus(tradeStatus: string): ProviderPaymentStatus {
|
||||||
switch (tradeStatus?.toUpperCase()) {
|
switch (tradeStatus?.toUpperCase()) {
|
||||||
case 'TRADE_SUCCESS':
|
case "TRADE_SUCCESS":
|
||||||
case 'SUCCESS':
|
case "SUCCESS":
|
||||||
return ProviderPaymentStatus.SUCCEEDED;
|
return ProviderPaymentStatus.SUCCEEDED;
|
||||||
case 'TRADE_CLOSED':
|
case "TRADE_CLOSED":
|
||||||
case 'TRADE_FAILED':
|
case "TRADE_FAILED":
|
||||||
case 'FAILED':
|
case "FAILED":
|
||||||
return ProviderPaymentStatus.FAILED;
|
return ProviderPaymentStatus.FAILED;
|
||||||
case 'WAIT_BUYER_PAY':
|
case "WAIT_BUYER_PAY":
|
||||||
case 'PENDING':
|
case "PENDING":
|
||||||
return ProviderPaymentStatus.REQUIRES_ACTION;
|
return ProviderPaymentStatus.REQUIRES_ACTION;
|
||||||
case 'PROCESSING':
|
case "PROCESSING":
|
||||||
return ProviderPaymentStatus.PROCESSING;
|
return ProviderPaymentStatus.PROCESSING;
|
||||||
default:
|
default:
|
||||||
return ProviderPaymentStatus.PROCESSING;
|
return ProviderPaymentStatus.PROCESSING;
|
||||||
@@ -170,21 +173,21 @@ export class EBirrProvider implements PaymentProvider {
|
|||||||
|
|
||||||
private signRequest(data: Record<string, unknown>): string {
|
private signRequest(data: Record<string, unknown>): string {
|
||||||
const sortedKeys = Object.keys(data).sort();
|
const sortedKeys = Object.keys(data).sort();
|
||||||
const signString = sortedKeys
|
const signString =
|
||||||
.map((key) => `${key}=${data[key]}`)
|
sortedKeys.map((key) => `${key}=${data[key]}`).join("&") +
|
||||||
.join('&') + `&key=${this.secretKey}`;
|
`&key=${this.secretKey}`;
|
||||||
|
|
||||||
return crypto
|
return crypto
|
||||||
.createHash('md5')
|
.createHash("md5")
|
||||||
.update(signString)
|
.update(signString)
|
||||||
.digest('hex')
|
.digest("hex")
|
||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async postJson<T>(url: string, body: unknown): Promise<T> {
|
private async postJson<T>(url: string, body: unknown): Promise<T> {
|
||||||
const config: AxiosRequestConfig = {
|
const config: AxiosRequestConfig = {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
};
|
};
|
||||||
@@ -192,7 +195,9 @@ export class EBirrProvider implements PaymentProvider {
|
|||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
try {
|
try {
|
||||||
const res = await firstValueFrom(this.http.post<T>(url, body, config));
|
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;
|
return res.data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof AxiosError) {
|
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)}`,
|
`eBirr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
|
||||||
);
|
);
|
||||||
} else {
|
} 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;
|
throw err;
|
||||||
}
|
}
|
||||||
@@ -212,18 +219,18 @@ export class EBirrProvider implements PaymentProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private get baseUrl(): string {
|
private get baseUrl(): string {
|
||||||
return this.config.get<string>('ebirr.baseUrl') ?? '';
|
return this.config.get<string>("ebirr.baseUrl") ?? "";
|
||||||
}
|
}
|
||||||
private get merchantCode(): string {
|
private get merchantCode(): string {
|
||||||
return this.config.get<string>('ebirr.merchantCode') ?? '';
|
return this.config.get<string>("ebirr.merchantCode") ?? "";
|
||||||
}
|
}
|
||||||
private get secretKey(): string {
|
private get secretKey(): string {
|
||||||
return this.config.get<string>('ebirr.secretKey') ?? '';
|
return this.config.get<string>("ebirr.secretKey") ?? "";
|
||||||
}
|
}
|
||||||
private get notifyUrl(): string {
|
private get notifyUrl(): string {
|
||||||
return this.config.get<string>('ebirr.notifyUrl') ?? '';
|
return this.config.get<string>("ebirr.notifyUrl") ?? "";
|
||||||
}
|
}
|
||||||
private get returnUrl(): string {
|
private get returnUrl(): string {
|
||||||
return this.config.get<string>('ebirr.returnUrl') ?? '';
|
return this.config.get<string>("ebirr.returnUrl") ?? "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { HttpService } from '@nestjs/axios';
|
import { HttpService } from "@nestjs/axios";
|
||||||
import {
|
import {
|
||||||
PaymentProvider,
|
PaymentProvider,
|
||||||
ProviderInitiationInput,
|
ProviderInitiationInput,
|
||||||
@@ -8,22 +8,22 @@ import {
|
|||||||
ProviderStatus,
|
ProviderStatus,
|
||||||
ProviderPaymentStatus,
|
ProviderPaymentStatus,
|
||||||
ProviderMethod,
|
ProviderMethod,
|
||||||
} from '@edr/types';
|
} from "@edr/types";
|
||||||
import { AxiosError, AxiosRequestConfig } from 'axios';
|
import { AxiosError, AxiosRequestConfig } from "axios";
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from "rxjs";
|
||||||
import * as https from 'node:https';
|
import * as https from "node:https";
|
||||||
import {
|
import {
|
||||||
createNonceStr,
|
createNonceStr,
|
||||||
createTimestamp,
|
createTimestamp,
|
||||||
signRequestObject,
|
signRequestObject,
|
||||||
verifyRequestObject,
|
verifyRequestObject,
|
||||||
} from './telebirr.crypto';
|
} from "./telebirr.crypto";
|
||||||
import {
|
import {
|
||||||
CreateOrderRequest,
|
CreateOrderRequest,
|
||||||
CreateOrderResponse,
|
CreateOrderResponse,
|
||||||
FabricTokenResponse,
|
FabricTokenResponse,
|
||||||
QueryOrderResponse,
|
QueryOrderResponse,
|
||||||
} from './telebirr.types';
|
} from "./telebirr.types";
|
||||||
|
|
||||||
const TELEBIRR_HTTP_TIMEOUT_MS = 10_000;
|
const TELEBIRR_HTTP_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
@@ -37,17 +37,21 @@ export class TelebirrProvider implements PaymentProvider {
|
|||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly http: HttpService,
|
private readonly http: HttpService,
|
||||||
) {
|
) {
|
||||||
const insecure = this.config.get<boolean>('telebirr.insecureTls');
|
const insecure = this.config.get<boolean>("telebirr.insecureTls");
|
||||||
if (insecure) {
|
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({
|
this.httpsAgent = new https.Agent({
|
||||||
rejectUnauthorized: !insecure,
|
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 fabricToken = await this.applyFabricToken();
|
||||||
const requestBody = this.buildCreateOrderRequest(input);
|
const requestBody = this.buildCreateOrderRequest(input);
|
||||||
const response = await this.requestCreateOrder(fabricToken, requestBody);
|
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 expiresAt = this.computeExpiresAt(
|
||||||
const platform = input.platform ?? 'web';
|
requestBody.biz_content.timeout_express,
|
||||||
|
);
|
||||||
|
const platform = input.platform ?? "web";
|
||||||
const clientAction =
|
const clientAction =
|
||||||
platform === 'mobile'
|
platform === "mobile"
|
||||||
? {
|
? {
|
||||||
type: 'LAUNCH_APP' as const,
|
type: "LAUNCH_APP" as const,
|
||||||
appId: this.merchantAppId,
|
appId: this.merchantAppId,
|
||||||
receiveCode: response.biz_content?.receiveCode,
|
receiveCode: response.biz_content?.receiveCode,
|
||||||
shortCode: this.merchantCode,
|
shortCode: this.merchantCode,
|
||||||
}
|
}
|
||||||
: { type: 'REDIRECT' as const, url: this.buildCheckoutUrl(prepayId) };
|
: { type: "REDIRECT" as const, url: this.buildCheckoutUrl(prepayId) };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
providerOrderId: prepayId,
|
providerOrderId: prepayId,
|
||||||
@@ -89,8 +95,8 @@ export class TelebirrProvider implements PaymentProvider {
|
|||||||
`${this.baseUrl}/payment/v1/merchant/queryOrder`,
|
`${this.baseUrl}/payment/v1/merchant/queryOrder`,
|
||||||
requestBody,
|
requestBody,
|
||||||
{
|
{
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
'X-APP-Key': this.fabricAppId,
|
"X-APP-Key": this.fabricAppId,
|
||||||
Authorization: fabricToken,
|
Authorization: fabricToken,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -104,36 +110,40 @@ export class TelebirrProvider implements PaymentProvider {
|
|||||||
status: mapped,
|
status: mapped,
|
||||||
providerTxnId,
|
providerTxnId,
|
||||||
failureCode:
|
failureCode:
|
||||||
mapped === ProviderPaymentStatus.FAILED && tradeStatus ? tradeStatus : undefined,
|
mapped === ProviderPaymentStatus.FAILED && tradeStatus
|
||||||
|
? tradeStatus
|
||||||
|
: undefined,
|
||||||
rawResponse: response as Record<string, unknown>,
|
rawResponse: response as Record<string, unknown>,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
mapTradeStatus(tradeStatus: string | undefined): ProviderPaymentStatus {
|
mapTradeStatus(tradeStatus: string | undefined): ProviderPaymentStatus {
|
||||||
switch (tradeStatus) {
|
switch (tradeStatus) {
|
||||||
case 'PAY_SUCCESS':
|
case "PAY_SUCCESS":
|
||||||
return ProviderPaymentStatus.SUCCEEDED;
|
return ProviderPaymentStatus.SUCCEEDED;
|
||||||
case 'PAY_FAILED':
|
case "PAY_FAILED":
|
||||||
case 'ORDER_CLOSED':
|
case "ORDER_CLOSED":
|
||||||
return ProviderPaymentStatus.FAILED;
|
return ProviderPaymentStatus.FAILED;
|
||||||
case 'WAIT_PAY':
|
case "WAIT_PAY":
|
||||||
return ProviderPaymentStatus.REQUIRES_ACTION;
|
return ProviderPaymentStatus.REQUIRES_ACTION;
|
||||||
case 'PAYING':
|
case "PAYING":
|
||||||
return ProviderPaymentStatus.PROCESSING;
|
return ProviderPaymentStatus.PROCESSING;
|
||||||
default:
|
default:
|
||||||
return ProviderPaymentStatus.PROCESSING;
|
return ProviderPaymentStatus.PROCESSING;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mapWebhookTradeStatus(tradeStatus: string | undefined): ProviderPaymentStatus {
|
mapWebhookTradeStatus(
|
||||||
|
tradeStatus: string | undefined,
|
||||||
|
): ProviderPaymentStatus {
|
||||||
switch (tradeStatus) {
|
switch (tradeStatus) {
|
||||||
case 'Completed':
|
case "Completed":
|
||||||
return ProviderPaymentStatus.SUCCEEDED;
|
return ProviderPaymentStatus.SUCCEEDED;
|
||||||
case 'Failure':
|
case "Failure":
|
||||||
case 'Expired':
|
case "Expired":
|
||||||
return ProviderPaymentStatus.FAILED;
|
return ProviderPaymentStatus.FAILED;
|
||||||
case 'Paying':
|
case "Paying":
|
||||||
case 'Pending':
|
case "Pending":
|
||||||
return ProviderPaymentStatus.PROCESSING;
|
return ProviderPaymentStatus.PROCESSING;
|
||||||
default:
|
default:
|
||||||
return ProviderPaymentStatus.PROCESSING;
|
return ProviderPaymentStatus.PROCESSING;
|
||||||
@@ -142,7 +152,9 @@ export class TelebirrProvider implements PaymentProvider {
|
|||||||
|
|
||||||
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
|
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
|
||||||
if (!this.publicKey) {
|
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 false;
|
||||||
}
|
}
|
||||||
return verifyRequestObject(payload, this.publicKey);
|
return verifyRequestObject(payload, this.publicKey);
|
||||||
@@ -153,12 +165,14 @@ export class TelebirrProvider implements PaymentProvider {
|
|||||||
`${this.baseUrl}/payment/v1/token`,
|
`${this.baseUrl}/payment/v1/token`,
|
||||||
{ appSecret: this.appSecret },
|
{ appSecret: this.appSecret },
|
||||||
{
|
{
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
'X-APP-Key': this.fabricAppId,
|
"X-APP-Key": this.fabricAppId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!response?.token) {
|
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;
|
return response.token;
|
||||||
}
|
}
|
||||||
@@ -171,51 +185,61 @@ export class TelebirrProvider implements PaymentProvider {
|
|||||||
`${this.baseUrl}/payment/v1/inapp/createOrder`,
|
`${this.baseUrl}/payment/v1/inapp/createOrder`,
|
||||||
body,
|
body,
|
||||||
{
|
{
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
'X-APP-Key': this.fabricAppId,
|
"X-APP-Key": this.fabricAppId,
|
||||||
Authorization: fabricToken,
|
Authorization: fabricToken,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildCreateOrderRequest(input: ProviderInitiationInput): CreateOrderRequest {
|
private buildCreateOrderRequest(
|
||||||
|
input: ProviderInitiationInput,
|
||||||
|
): CreateOrderRequest {
|
||||||
const totalAmount = String(input.amountMinor / 100);
|
const totalAmount = String(input.amountMinor / 100);
|
||||||
const req = {
|
const req = {
|
||||||
timestamp: createTimestamp(),
|
timestamp: createTimestamp(),
|
||||||
nonce_str: createNonceStr(),
|
nonce_str: createNonceStr(),
|
||||||
method: 'payment.preorder' as const,
|
method: "payment.preorder" as const,
|
||||||
version: '1.0' as const,
|
version: "1.0" as const,
|
||||||
biz_content: {
|
biz_content: {
|
||||||
notify_url: this.notifyUrl,
|
notify_url: this.notifyUrl,
|
||||||
appid: this.merchantAppId,
|
appid: this.merchantAppId,
|
||||||
merch_code: this.merchantCode,
|
merch_code: this.merchantCode,
|
||||||
merch_order_id: input.merchantOrderId,
|
merch_order_id: input.merchantOrderId,
|
||||||
trade_type: 'Checkout' as const,
|
trade_type: "Checkout" as const,
|
||||||
title: `EDR ${input.orderRef}`,
|
title: `EDR ${input.orderRef}`,
|
||||||
total_amount: totalAmount,
|
total_amount: totalAmount,
|
||||||
trans_currency: input.currency,
|
trans_currency: input.currency,
|
||||||
timeout_express: this.timeoutExpress,
|
timeout_express: this.timeoutExpress,
|
||||||
redirect_url: input.redirectUrl
|
redirect_url: input.redirectUrl,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const sign = signRequestObject(req as unknown as Record<string, unknown>, this.privateKey);
|
const sign = signRequestObject(
|
||||||
return { ...req, sign, sign_type: 'SHA256WithRSA' };
|
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 = {
|
const req = {
|
||||||
timestamp: createTimestamp(),
|
timestamp: createTimestamp(),
|
||||||
nonce_str: createNonceStr(),
|
nonce_str: createNonceStr(),
|
||||||
method: 'payment.queryorder',
|
method: "payment.queryorder",
|
||||||
version: '1.0',
|
version: "1.0",
|
||||||
biz_content: {
|
biz_content: {
|
||||||
appid: this.merchantAppId,
|
appid: this.merchantAppId,
|
||||||
merch_code: this.merchantCode,
|
merch_code: this.merchantCode,
|
||||||
merch_order_id: merchantOrderId,
|
merch_order_id: merchantOrderId,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const sign = signRequestObject(req as Record<string, unknown>, this.privateKey);
|
const sign = signRequestObject(
|
||||||
return { ...req, sign, sign_type: 'SHA256WithRSA' };
|
req as Record<string, unknown>,
|
||||||
|
this.privateKey,
|
||||||
|
);
|
||||||
|
return { ...req, sign, sign_type: "SHA256WithRSA" };
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildCheckoutUrl(prepayId: string): string {
|
private buildCheckoutUrl(prepayId: string): string {
|
||||||
@@ -233,27 +257,34 @@ export class TelebirrProvider implements PaymentProvider {
|
|||||||
`nonce_str=${map.nonce_str}`,
|
`nonce_str=${map.nonce_str}`,
|
||||||
`prepay_id=${map.prepay_id}`,
|
`prepay_id=${map.prepay_id}`,
|
||||||
`timestamp=${map.timestamp}`,
|
`timestamp=${map.timestamp}`,
|
||||||
'sign_type=SHA256WithRSA',
|
"sign_type=SHA256WithRSA",
|
||||||
`sign=${sign}`,
|
`sign=${sign}`,
|
||||||
'version=1.0',
|
"version=1.0",
|
||||||
'trade_type=Checkout',
|
"trade_type=Checkout",
|
||||||
].join('&');
|
].join("&");
|
||||||
return `${this.webBaseUrl}${rawRequest}`;
|
return `${this.webBaseUrl}${rawRequest}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private computeExpiresAt(timeoutExpress: string): Date {
|
private computeExpiresAt(timeoutExpress: string): Date {
|
||||||
const match = /^(\d+)([smhd])$/.exec(timeoutExpress);
|
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);
|
return new Date(Date.now() + minutes * 60_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
private toMinutes(n: number, unit: string): number {
|
private toMinutes(n: number, unit: string): number {
|
||||||
switch (unit) {
|
switch (unit) {
|
||||||
case 's': return Math.max(1, Math.round(n / 60));
|
case "s":
|
||||||
case 'm': return n;
|
return Math.max(1, Math.round(n / 60));
|
||||||
case 'h': return n * 60;
|
case "m":
|
||||||
case 'd': return n * 60 * 24;
|
return n;
|
||||||
default: return 15;
|
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();
|
const started = Date.now();
|
||||||
try {
|
try {
|
||||||
const res = await firstValueFrom(this.http.post<T>(url, body, config));
|
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;
|
return res.data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof AxiosError) {
|
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}`,
|
`Telebirr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)} code=${err.code} message=${err.message}`,
|
||||||
);
|
);
|
||||||
} else {
|
} 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;
|
throw err;
|
||||||
}
|
}
|
||||||
@@ -289,14 +324,34 @@ export class TelebirrProvider implements PaymentProvider {
|
|||||||
return rest;
|
return rest;
|
||||||
}
|
}
|
||||||
|
|
||||||
private get baseUrl(): string { return this.config.get<string>('telebirr.baseUrl') ?? ''; }
|
private get baseUrl(): string {
|
||||||
private get webBaseUrl(): string { return this.config.get<string>('telebirr.webBaseUrl') ?? ''; }
|
return this.config.get<string>("telebirr.baseUrl") ?? "";
|
||||||
private get fabricAppId(): string { return this.config.get<string>('telebirr.fabricAppId') ?? ''; }
|
}
|
||||||
private get appSecret(): string { return this.config.get<string>('telebirr.appSecret') ?? ''; }
|
private get webBaseUrl(): string {
|
||||||
private get merchantAppId(): string { return this.config.get<string>('telebirr.merchantAppId') ?? ''; }
|
return this.config.get<string>("telebirr.webBaseUrl") ?? "";
|
||||||
private get merchantCode(): string { return this.config.get<string>('telebirr.merchantCode') ?? ''; }
|
}
|
||||||
private get notifyUrl(): string { return this.config.get<string>('telebirr.notifyUrl') ?? ''; }
|
private get fabricAppId(): string {
|
||||||
private get timeoutExpress(): string { return this.config.get<string>('telebirr.timeoutExpress') ?? '15m'; }
|
return this.config.get<string>("telebirr.fabricAppId") ?? "";
|
||||||
private get privateKey(): string { return this.config.get<string>('telebirr.privateKey') ?? ''; }
|
}
|
||||||
private get publicKey(): string { return this.config.get<string>('telebirr.publicKey') ?? ''; }
|
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") ?? "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { HttpService } from '@nestjs/axios';
|
import { HttpService } from "@nestjs/axios";
|
||||||
import {
|
import {
|
||||||
PaymentProvider,
|
PaymentProvider,
|
||||||
ProviderInitiationInput,
|
ProviderInitiationInput,
|
||||||
@@ -8,20 +8,20 @@ import {
|
|||||||
ProviderStatus,
|
ProviderStatus,
|
||||||
ProviderPaymentStatus,
|
ProviderPaymentStatus,
|
||||||
ProviderMethod,
|
ProviderMethod,
|
||||||
} from '@edr/types';
|
} from "@edr/types";
|
||||||
import { AxiosError, AxiosRequestConfig } from 'axios';
|
import { AxiosError, AxiosRequestConfig } from "axios";
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from "rxjs";
|
||||||
import * as crypto from 'node:crypto';
|
import * as crypto from "node:crypto";
|
||||||
import * as https from 'node:https';
|
import * as https from "node:https";
|
||||||
import {
|
import {
|
||||||
WaafiGetTranInfoRequest,
|
WaafiGetTranInfoRequest,
|
||||||
WaafiGetTranInfoResponse,
|
WaafiGetTranInfoResponse,
|
||||||
WaafiHppPurchaseRequest,
|
WaafiHppPurchaseRequest,
|
||||||
WaafiHppPurchaseResponse,
|
WaafiHppPurchaseResponse,
|
||||||
} from './waafi.types';
|
} from "./waafi.types";
|
||||||
|
|
||||||
const WAAFI_HTTP_TIMEOUT_MS = 10_000;
|
const WAAFI_HTTP_TIMEOUT_MS = 10_000;
|
||||||
const WAAFI_SUCCESS_CODE = '2001';
|
const WAAFI_SUCCESS_CODE = "2001";
|
||||||
/** Waafi cancels an unprocessed HPP session after ~5 minutes (RCS_HPP_USERACTION_TIMEOUT). */
|
/** Waafi cancels an unprocessed HPP session after ~5 minutes (RCS_HPP_USERACTION_TIMEOUT). */
|
||||||
const WAAFI_HPP_SESSION_MS = 5 * 60_000;
|
const WAAFI_HPP_SESSION_MS = 5 * 60_000;
|
||||||
|
|
||||||
@@ -35,16 +35,18 @@ export class WaafiProvider implements PaymentProvider {
|
|||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly http: HttpService,
|
private readonly http: HttpService,
|
||||||
) {
|
) {
|
||||||
const insecure = this.config.get<boolean>('waafi.insecureTls');
|
const insecure = this.config.get<boolean>("waafi.insecureTls");
|
||||||
if (insecure) {
|
if (insecure) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
'WAAFI_INSECURE_TLS=true — TLS verification disabled for Waafi calls. DEV ONLY.',
|
"WAAFI_INSECURE_TLS=true — TLS verification disabled for Waafi calls. DEV ONLY.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.httpsAgent = new https.Agent({ rejectUnauthorized: !insecure });
|
this.httpsAgent = new https.Agent({ rejectUnauthorized: !insecure });
|
||||||
}
|
}
|
||||||
|
|
||||||
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
|
async initiate(
|
||||||
|
input: ProviderInitiationInput,
|
||||||
|
): Promise<ProviderInitiationResult> {
|
||||||
const requestBody = this.buildPurchaseRequest(input);
|
const requestBody = this.buildPurchaseRequest(input);
|
||||||
const response = await this.postJson<WaafiHppPurchaseResponse>(
|
const response = await this.postJson<WaafiHppPurchaseResponse>(
|
||||||
`${this.baseUrl}/asm`,
|
`${this.baseUrl}/asm`,
|
||||||
@@ -57,7 +59,8 @@ export class WaafiProvider implements PaymentProvider {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkoutUrl = response.params?.hppUrl ?? response.params?.directPaymentLink;
|
const checkoutUrl =
|
||||||
|
response.params?.hppUrl ?? response.params?.directPaymentLink;
|
||||||
const orderId = response.params?.orderId;
|
const orderId = response.params?.orderId;
|
||||||
if (!checkoutUrl || !orderId) {
|
if (!checkoutUrl || !orderId) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -67,7 +70,7 @@ export class WaafiProvider implements PaymentProvider {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
providerOrderId: orderId,
|
providerOrderId: orderId,
|
||||||
clientAction: { type: 'REDIRECT', url: checkoutUrl },
|
clientAction: { type: "REDIRECT", url: checkoutUrl },
|
||||||
expiresAt: new Date(Date.now() + WAAFI_HPP_SESSION_MS),
|
expiresAt: new Date(Date.now() + WAAFI_HPP_SESSION_MS),
|
||||||
rawInitiation: {
|
rawInitiation: {
|
||||||
request: this.sanitize(requestBody),
|
request: this.sanitize(requestBody),
|
||||||
@@ -91,7 +94,9 @@ export class WaafiProvider implements PaymentProvider {
|
|||||||
status: mapped,
|
status: mapped,
|
||||||
providerTxnId: transactionId,
|
providerTxnId: transactionId,
|
||||||
failureCode:
|
failureCode:
|
||||||
mapped === ProviderPaymentStatus.FAILED && rawState ? rawState : undefined,
|
mapped === ProviderPaymentStatus.FAILED && rawState
|
||||||
|
? rawState
|
||||||
|
: undefined,
|
||||||
rawResponse: response as unknown as Record<string, unknown>,
|
rawResponse: response as unknown as Record<string, unknown>,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -115,61 +120,70 @@ export class WaafiProvider implements PaymentProvider {
|
|||||||
eventId: string | undefined,
|
eventId: string | undefined,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!this.webhookSecret) {
|
if (!this.webhookSecret) {
|
||||||
this.logger.error('WAAFI_WEBHOOK_SECRET not configured; rejecting all webhooks');
|
this.logger.error(
|
||||||
|
"WAAFI_WEBHOOK_SECRET not configured; rejecting all webhooks",
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!signature || !timestamp || !eventId) {
|
if (!signature || !timestamp || !eventId) {
|
||||||
this.logger.warn('Waafi webhook missing signature/timestamp/event-id headers');
|
this.logger.warn(
|
||||||
|
"Waafi webhook missing signature/timestamp/event-id headers",
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const signingString = `${timestamp}.${eventId}.${rawBody}`;
|
const signingString = `${timestamp}.${eventId}.${rawBody}`;
|
||||||
const expected = crypto
|
const expected = crypto
|
||||||
.createHmac('sha256', this.webhookSecret)
|
.createHmac("sha256", this.webhookSecret)
|
||||||
.update(signingString)
|
.update(signingString)
|
||||||
.digest('hex');
|
.digest("hex");
|
||||||
|
|
||||||
const provided = Buffer.from(signature, 'utf8');
|
const provided = Buffer.from(signature, "utf8");
|
||||||
const computed = Buffer.from(expected, 'utf8');
|
const computed = Buffer.from(expected, "utf8");
|
||||||
if (provided.length !== computed.length) return false;
|
if (provided.length !== computed.length) return false;
|
||||||
return crypto.timingSafeEqual(provided, computed);
|
return crypto.timingSafeEqual(provided, computed);
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapStatus(raw: string | undefined): ProviderPaymentStatus {
|
private mapStatus(raw: string | undefined): ProviderPaymentStatus {
|
||||||
switch (raw?.toUpperCase()) {
|
switch (raw?.toUpperCase()) {
|
||||||
case 'APPROVED':
|
case "APPROVED":
|
||||||
case 'SUCCESS':
|
case "SUCCESS":
|
||||||
return ProviderPaymentStatus.SUCCEEDED;
|
return ProviderPaymentStatus.SUCCEEDED;
|
||||||
case 'CANCELED':
|
case "CANCELED":
|
||||||
case 'CANCELLED':
|
case "CANCELLED":
|
||||||
return ProviderPaymentStatus.CANCELLED;
|
return ProviderPaymentStatus.CANCELLED;
|
||||||
case 'DECLINED':
|
case "DECLINED":
|
||||||
case 'FAILED':
|
case "FAILED":
|
||||||
case 'EXPIRED':
|
case "EXPIRED":
|
||||||
case 'TIMEOUT':
|
case "TIMEOUT":
|
||||||
return ProviderPaymentStatus.FAILED;
|
return ProviderPaymentStatus.FAILED;
|
||||||
case 'PENDING':
|
case "PENDING":
|
||||||
case 'INITIATED':
|
case "INITIATED":
|
||||||
return ProviderPaymentStatus.REQUIRES_ACTION;
|
return ProviderPaymentStatus.REQUIRES_ACTION;
|
||||||
default:
|
default:
|
||||||
return ProviderPaymentStatus.PROCESSING;
|
return ProviderPaymentStatus.PROCESSING;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildPurchaseRequest(input: ProviderInitiationInput): WaafiHppPurchaseRequest {
|
private buildPurchaseRequest(
|
||||||
|
input: ProviderInitiationInput,
|
||||||
|
): WaafiHppPurchaseRequest {
|
||||||
return {
|
return {
|
||||||
schemaVersion: '1.0',
|
schemaVersion: "1.0",
|
||||||
requestId: crypto.randomUUID(),
|
requestId: crypto.randomUUID(),
|
||||||
timestamp: this.timestamp(),
|
timestamp: this.timestamp(),
|
||||||
channelName: 'WEB',
|
channelName: "WEB",
|
||||||
serviceName: 'HPP_PURCHASE',
|
serviceName: "HPP_PURCHASE",
|
||||||
serviceParams: {
|
serviceParams: {
|
||||||
merchantUid: this.merchantUid,
|
merchantUid: this.merchantUid,
|
||||||
storeId: this.storeId,
|
storeId: this.storeId,
|
||||||
hppKey: this.hppKey,
|
hppKey: this.hppKey,
|
||||||
paymentMethod: this.paymentMethod,
|
paymentMethod: this.paymentMethod,
|
||||||
hppSuccessCallbackUrl: this.successUrl,
|
// Browser bounce-back is per-transaction (each calling app has its own UI), so the
|
||||||
hppFailureCallbackUrl: this.failureUrl,
|
// 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,
|
hppRespDataFormat: this.respDataFormat,
|
||||||
// MWALLET_ACCOUNT requires the payer phone up front; omit if the caller did not supply it
|
// 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.
|
// and let the hosted page collect it. See docs/waffi open question on payer-phone sourcing.
|
||||||
@@ -187,13 +201,15 @@ export class WaafiProvider implements PaymentProvider {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildGetTranInfoRequest(merchantOrderId: string): WaafiGetTranInfoRequest {
|
private buildGetTranInfoRequest(
|
||||||
|
merchantOrderId: string,
|
||||||
|
): WaafiGetTranInfoRequest {
|
||||||
return {
|
return {
|
||||||
schemaVersion: '1.0',
|
schemaVersion: "1.0",
|
||||||
requestId: crypto.randomUUID(),
|
requestId: crypto.randomUUID(),
|
||||||
timestamp: this.timestamp(),
|
timestamp: this.timestamp(),
|
||||||
channelName: 'WEB',
|
channelName: "WEB",
|
||||||
serviceName: 'HPP_GETTRANINFO',
|
serviceName: "HPP_GETTRANINFO",
|
||||||
serviceParams: {
|
serviceParams: {
|
||||||
merchantUid: this.merchantUid,
|
merchantUid: this.merchantUid,
|
||||||
storeId: this.storeId,
|
storeId: this.storeId,
|
||||||
@@ -214,7 +230,7 @@ export class WaafiProvider implements PaymentProvider {
|
|||||||
|
|
||||||
private async postJson<T>(url: string, body: unknown): Promise<T> {
|
private async postJson<T>(url: string, body: unknown): Promise<T> {
|
||||||
const config: AxiosRequestConfig = {
|
const config: AxiosRequestConfig = {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
timeout: WAAFI_HTTP_TIMEOUT_MS,
|
timeout: WAAFI_HTTP_TIMEOUT_MS,
|
||||||
httpsAgent: this.httpsAgent,
|
httpsAgent: this.httpsAgent,
|
||||||
};
|
};
|
||||||
@@ -243,38 +259,40 @@ export class WaafiProvider implements PaymentProvider {
|
|||||||
private sanitize(body: WaafiHppPurchaseRequest): Record<string, unknown> {
|
private sanitize(body: WaafiHppPurchaseRequest): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
...body,
|
...body,
|
||||||
serviceParams: { ...body.serviceParams, hppKey: '***REDACTED***' },
|
serviceParams: { ...body.serviceParams, hppKey: "***REDACTED***" },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private get baseUrl(): string {
|
private get baseUrl(): string {
|
||||||
return this.config.get<string>('waafi.baseUrl') ?? 'https://sandbox.waafipay.net';
|
return (
|
||||||
|
this.config.get<string>("waafi.baseUrl") ?? "https://sandbox.waafipay.net"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
private get merchantUid(): string {
|
private get merchantUid(): string {
|
||||||
return this.config.get<string>('waafi.merchantUid') ?? '';
|
return this.config.get<string>("waafi.merchantUid") ?? "";
|
||||||
}
|
}
|
||||||
private get storeId(): string {
|
private get storeId(): string {
|
||||||
return this.config.get<string>('waafi.storeId') ?? '';
|
return this.config.get<string>("waafi.storeId") ?? "";
|
||||||
}
|
}
|
||||||
private get hppKey(): string {
|
private get hppKey(): string {
|
||||||
return this.config.get<string>('waafi.hppKey') ?? '';
|
return this.config.get<string>("waafi.hppKey") ?? "";
|
||||||
}
|
}
|
||||||
private get webhookSecret(): string {
|
private get webhookSecret(): string {
|
||||||
return this.config.get<string>('waafi.webhookSecret') ?? '';
|
return this.config.get<string>("waafi.webhookSecret") ?? "";
|
||||||
}
|
}
|
||||||
private get paymentMethod(): string {
|
private get paymentMethod(): string {
|
||||||
return this.config.get<string>('waafi.paymentMethod') ?? 'MWALLET_ACCOUNT';
|
return this.config.get<string>("waafi.paymentMethod") ?? "MWALLET_ACCOUNT";
|
||||||
}
|
}
|
||||||
private get currency(): string {
|
private get currency(): string {
|
||||||
return this.config.get<string>('waafi.currency') ?? '';
|
return this.config.get<string>("waafi.currency") ?? "";
|
||||||
}
|
}
|
||||||
private get successUrl(): string {
|
private get successUrl(): string {
|
||||||
return this.config.get<string>('waafi.successUrl') ?? '';
|
return this.config.get<string>("waafi.successUrl") ?? "";
|
||||||
}
|
}
|
||||||
private get failureUrl(): string {
|
private get failureUrl(): string {
|
||||||
return this.config.get<string>('waafi.failureUrl') ?? '';
|
return this.config.get<string>("waafi.failureUrl") ?? "";
|
||||||
}
|
}
|
||||||
private get respDataFormat(): number {
|
private get respDataFormat(): number {
|
||||||
return this.config.get<number>('waafi.respDataFormat') ?? 1;
|
return this.config.get<number>("waafi.respDataFormat") ?? 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ export interface ProviderInitiationInput {
|
|||||||
/** Optional caller-supplied redirect targets for redirect/HPP-style providers. */
|
/** Optional caller-supplied redirect targets for redirect/HPP-style providers. */
|
||||||
returnUrl?: string;
|
returnUrl?: string;
|
||||||
redirectUrl?: string;
|
redirectUrl?: string;
|
||||||
|
/** Where the browser lands when the hosted page fails/cancels (UX only — never trusted). */
|
||||||
|
failureUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProviderInitiationResult {
|
export interface ProviderInitiationResult {
|
||||||
@@ -94,11 +96,6 @@ export enum PaymentReferenceType {
|
|||||||
SHIPMENT = "SHIPMENT",
|
SHIPMENT = "SHIPMENT",
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `merchant_order_id` prefix per owning service — lets a webhook be routed before a DB lookup. */
|
|
||||||
export const MERCHANT_ORDER_PREFIX: Record<PaymentService, string> = {
|
|
||||||
[PaymentService.PASSENGER]: "PSG-",
|
|
||||||
[PaymentService.FREIGHT]: "FRT-",
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Body of `POST /payments/initiate` on the payment service (internal, service-authenticated). */
|
/** Body of `POST /payments/initiate` on the payment service (internal, service-authenticated). */
|
||||||
export interface InitiatePaymentRequest {
|
export interface InitiatePaymentRequest {
|
||||||
@@ -114,6 +111,16 @@ export interface InitiatePaymentRequest {
|
|||||||
provider: ProviderMethod;
|
provider: ProviderMethod;
|
||||||
platform?: PaymentPlatform;
|
platform?: PaymentPlatform;
|
||||||
payerAccount?: string;
|
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. */
|
/** Optional caller key to dedupe retried initiations beyond the per-reference upsert. */
|
||||||
idempotencyKey?: string;
|
idempotencyKey?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -392,9 +392,6 @@ importers:
|
|||||||
|
|
||||||
apps/edr-passenger-api:
|
apps/edr-passenger-api:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@edr/payment-providers':
|
|
||||||
specifier: workspace:*
|
|
||||||
version: link:../../packages/payment-providers
|
|
||||||
'@edr/types':
|
'@edr/types':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/types
|
version: link:../../packages/types
|
||||||
|
|||||||
Reference in New Issue
Block a user