mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 02:30:55 +00:00
feat: ( payment ) create payment microservice
This commit is contained in:
@@ -5,3 +5,17 @@ DB_PORT=5433
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=
|
||||
DB_NAME=edr_freight
|
||||
|
||||
# Telebirr payment gateway (freight merchant credentials)
|
||||
TELEBIRR_BASE_URL=
|
||||
TELEBIRR_WEB_BASE_URL=
|
||||
TELEBIRR_FABRIC_APP_ID=
|
||||
TELEBIRR_APP_SECRET=
|
||||
TELEBIRR_MERCHANT_APP_ID=
|
||||
TELEBIRR_MERCHANT_CODE=
|
||||
TELEBIRR_NOTIFY_URL=https://freight-api.edr.et/payments/webhooks/telebirr
|
||||
TELEBIRR_RETURN_URL=
|
||||
TELEBIRR_TIMEOUT_EXPRESS=15m
|
||||
TELEBIRR_PRIVATE_KEY=
|
||||
TELEBIRR_PUBLIC_KEY=
|
||||
TELEBIRR_INSECURE_TLS=false
|
||||
|
||||
@@ -16,14 +16,18 @@
|
||||
"@tria-plc/api-common": "^0.1.0",
|
||||
"@tria-plc/iamapi-common": "^0.1.0",
|
||||
"@edr/api-common": "workspace:*",
|
||||
"@edr/payment-providers": "workspace:*",
|
||||
"@edr/types": "workspace:*",
|
||||
"@nestjs/axios": "^4.0.0",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/config": "^4.0.0",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
"@nestjs/event-emitter": "^2.0.4",
|
||||
"@nestjs/microservices": "^11.0.0",
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
"@nestjs/swagger": "^11.4.2",
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"axios": "^1.7.7",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"pg": "^8.13.0",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { EventEmitterModule } from "@nestjs/event-emitter";
|
||||
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
|
||||
|
||||
import appConfig from "./config/app.config";
|
||||
import databaseConfig from "./config/database.config";
|
||||
import telebirrConfig from "./config/telebirr.config";
|
||||
|
||||
import { BookingsModule } from "./modules/bookings/bookings.module";
|
||||
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
|
||||
@@ -12,13 +14,15 @@ import { CustomersModule } from "./modules/customers/customers.module";
|
||||
import { TrackingModule } from "./modules/tracking/tracking.module";
|
||||
import { BillingModule } from "./modules/billing/billing.module";
|
||||
import { NotificationsModule } from "./modules/notifications/notifications.module";
|
||||
import { PaymentsModule } from "./modules/payments/payments.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [appConfig, databaseConfig],
|
||||
load: [appConfig, databaseConfig, telebirrConfig],
|
||||
}),
|
||||
EventEmitterModule.forRoot(),
|
||||
TypeOrmModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
|
||||
@@ -31,6 +35,7 @@ import { NotificationsModule } from "./modules/notifications/notifications.modul
|
||||
TrackingModule,
|
||||
BillingModule,
|
||||
NotificationsModule,
|
||||
PaymentsModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
16
apps/edr-freight-api/src/config/telebirr.config.ts
Normal file
16
apps/edr-freight-api/src/config/telebirr.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { registerAs } from "@nestjs/config";
|
||||
|
||||
export default registerAs("telebirr", () => ({
|
||||
baseUrl: process.env.TELEBIRR_BASE_URL ?? "",
|
||||
webBaseUrl: process.env.TELEBIRR_WEB_BASE_URL ?? "",
|
||||
fabricAppId: process.env.TELEBIRR_FABRIC_APP_ID ?? "",
|
||||
appSecret: process.env.TELEBIRR_APP_SECRET ?? "",
|
||||
merchantAppId: process.env.TELEBIRR_MERCHANT_APP_ID ?? "",
|
||||
merchantCode: process.env.TELEBIRR_MERCHANT_CODE ?? "",
|
||||
notifyUrl: process.env.TELEBIRR_NOTIFY_URL ?? "",
|
||||
returnUrl: process.env.TELEBIRR_RETURN_URL ?? "",
|
||||
timeoutExpress: process.env.TELEBIRR_TIMEOUT_EXPRESS ?? "15m",
|
||||
privateKey: process.env.TELEBIRR_PRIVATE_KEY ?? "",
|
||||
publicKey: process.env.TELEBIRR_PUBLIC_KEY ?? "",
|
||||
insecureTls: process.env.TELEBIRR_INSECURE_TLS === "true",
|
||||
}));
|
||||
@@ -0,0 +1,65 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { ProviderMethod, ProviderPaymentStatus } from "@edr/types";
|
||||
import { Column, Entity, JoinColumn, ManyToOne } from "typeorm";
|
||||
|
||||
import { Booking } from "../../bookings/entities/booking.entity";
|
||||
|
||||
@Entity({ name: "payment_intents" })
|
||||
export class PaymentIntent extends BaseEntity {
|
||||
@Column({ name: "booking_id", type: "uuid", unique: true })
|
||||
bookingId!: string;
|
||||
|
||||
@ManyToOne(() => Booking)
|
||||
@JoinColumn({ name: "booking_id" })
|
||||
booking?: Booking;
|
||||
|
||||
@Column({ name: "amount_minor", type: "int" })
|
||||
amountMinor!: number;
|
||||
|
||||
@Column({ name: "currency", type: "varchar", length: 8, default: "ETB" })
|
||||
currency!: string;
|
||||
|
||||
@Column({ name: "method", type: "enum", enum: ProviderMethod })
|
||||
method!: ProviderMethod;
|
||||
|
||||
@Column({
|
||||
name: "status",
|
||||
type: "enum",
|
||||
enum: ProviderPaymentStatus,
|
||||
default: ProviderPaymentStatus.REQUIRES_ACTION,
|
||||
})
|
||||
status!: ProviderPaymentStatus;
|
||||
|
||||
@Column({
|
||||
name: "merchant_order_id",
|
||||
type: "varchar",
|
||||
length: 64,
|
||||
unique: true,
|
||||
nullable: true,
|
||||
})
|
||||
merchantOrderId?: string | null;
|
||||
|
||||
@Column({ name: "provider_order_id", type: "varchar", length: 128, nullable: true })
|
||||
providerOrderId?: string | null;
|
||||
|
||||
@Column({ name: "provider_txn_id", type: "varchar", length: 128, nullable: true })
|
||||
providerTxnId?: string | null;
|
||||
|
||||
@Column({ name: "client_action", type: "jsonb", nullable: true })
|
||||
clientAction?: Record<string, unknown> | null;
|
||||
|
||||
@Column({ name: "raw_initiation", type: "jsonb", nullable: true })
|
||||
rawInitiation?: Record<string, unknown> | null;
|
||||
|
||||
@Column({ name: "expires_at", type: "timestamptz", nullable: true })
|
||||
expiresAt?: Date | null;
|
||||
|
||||
@Column({ name: "paid_at", type: "timestamptz", nullable: true })
|
||||
paidAt?: Date | null;
|
||||
|
||||
@Column({ name: "failure_code", type: "varchar", length: 128, nullable: true })
|
||||
failureCode?: string | null;
|
||||
|
||||
@Column({ name: "failure_message", type: "text", nullable: true })
|
||||
failureMessage?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { ProviderMethod } from "@edr/types";
|
||||
import { Column, Entity } from "typeorm";
|
||||
|
||||
@Entity({ name: "payment_webhook_events" })
|
||||
export class PaymentWebhookEvent extends BaseEntity {
|
||||
@Column({ name: "provider", type: "enum", enum: ProviderMethod })
|
||||
provider!: ProviderMethod;
|
||||
|
||||
@Column({ name: "external_event_id", type: "varchar", length: 256, unique: true })
|
||||
externalEventId!: string;
|
||||
|
||||
@Column({ name: "merchant_order_id", type: "varchar", length: 64 })
|
||||
merchantOrderId!: string;
|
||||
|
||||
@Column({ name: "provider_txn_id", type: "varchar", length: 128, nullable: true })
|
||||
providerTxnId?: string | null;
|
||||
|
||||
@Column({ name: "signature_valid", type: "boolean" })
|
||||
signatureValid!: boolean;
|
||||
|
||||
@Column({ name: "status", type: "varchar", length: 64 })
|
||||
status!: string;
|
||||
|
||||
@Column({ name: "payload", type: "jsonb" })
|
||||
payload!: Record<string, unknown>;
|
||||
|
||||
@Column({ name: "processed_at", type: "timestamptz", nullable: true })
|
||||
processedAt?: Date | null;
|
||||
|
||||
@Column({ name: "processing_error", type: "text", nullable: true })
|
||||
processingError?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Body, Controller, Get, Param, Post } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import {
|
||||
InitiatePaymentDto,
|
||||
InitiateResponseDto,
|
||||
IntentStatusDto,
|
||||
} from "./payments.dto";
|
||||
import { PaymentsService } from "./payments.service";
|
||||
|
||||
@ApiTags("Payments")
|
||||
@Controller("payments")
|
||||
export class PaymentsController {
|
||||
constructor(private readonly payments: PaymentsService) {}
|
||||
|
||||
@Post("initiate")
|
||||
@ApiOperation({ summary: "Initiate a payment for a freight booking" })
|
||||
initiate(@Body() dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
|
||||
return this.payments.initiatePayment(dto);
|
||||
}
|
||||
|
||||
@Get(":bookingId")
|
||||
@ApiOperation({ summary: "Get the payment intent status for a booking" })
|
||||
getStatus(@Param("bookingId") bookingId: string): Promise<IntentStatusDto> {
|
||||
return this.payments.getIntentByBookingId(bookingId);
|
||||
}
|
||||
}
|
||||
62
apps/edr-freight-api/src/modules/payments/payments.dto.ts
Normal file
62
apps/edr-freight-api/src/modules/payments/payments.dto.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { ProviderPaymentStatus } from "@edr/types";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsIn, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export type PaymentPlatformDto = "web" | "mobile";
|
||||
|
||||
export class InitiatePaymentDto {
|
||||
@ApiProperty({ example: "booking-uuid" })
|
||||
@IsString()
|
||||
bookingId!: string;
|
||||
|
||||
@ApiProperty({ enum: ["TELEBIRR"], example: "TELEBIRR" })
|
||||
@IsIn(["TELEBIRR"])
|
||||
method!: "TELEBIRR";
|
||||
|
||||
@ApiPropertyOptional({ enum: ["web", "mobile"], default: "web" })
|
||||
@IsOptional()
|
||||
@IsIn(["web", "mobile"])
|
||||
platform?: PaymentPlatformDto;
|
||||
}
|
||||
|
||||
export class ClientActionDto {
|
||||
@ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP"] })
|
||||
type!: "REDIRECT" | "LAUNCH_APP";
|
||||
|
||||
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
|
||||
url?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" })
|
||||
appId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" })
|
||||
receiveCode?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" })
|
||||
shortCode?: string;
|
||||
}
|
||||
|
||||
export class InitiateResponseDto {
|
||||
@ApiProperty()
|
||||
intentId!: string;
|
||||
|
||||
@ApiProperty({ enum: ProviderPaymentStatus })
|
||||
status!: ProviderPaymentStatus;
|
||||
|
||||
@ApiPropertyOptional({ type: ClientActionDto })
|
||||
clientAction?: ClientActionDto;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
merchantOrderId?: string;
|
||||
}
|
||||
|
||||
export class IntentStatusDto extends InitiateResponseDto {
|
||||
@ApiPropertyOptional()
|
||||
paidAt?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
failureCode?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
failureMessage?: string;
|
||||
}
|
||||
33
apps/edr-freight-api/src/modules/payments/payments.module.ts
Normal file
33
apps/edr-freight-api/src/modules/payments/payments.module.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { TelebirrProvider } from "@edr/payment-providers";
|
||||
|
||||
import { BookingsModule } from "../bookings/bookings.module";
|
||||
import { PaymentIntent } from "./entities/payment-intent.entity";
|
||||
import { PaymentWebhookEvent } from "./entities/payment-webhook-event.entity";
|
||||
import { PaymentsController } from "./payments.controller";
|
||||
import {
|
||||
PaymentIntentRepository,
|
||||
PaymentWebhookEventRepository,
|
||||
} from "./payments.repository";
|
||||
import { PaymentsService } from "./payments.service";
|
||||
import { TelebirrWebhookService } from "./webhooks/telebirr-webhook.service";
|
||||
import { WebhooksController } from "./webhooks/webhooks.controller";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
BookingsModule,
|
||||
TypeOrmModule.forFeature([PaymentIntent, PaymentWebhookEvent]),
|
||||
HttpModule.register({ timeout: 10_000 }),
|
||||
],
|
||||
controllers: [PaymentsController, WebhooksController],
|
||||
providers: [
|
||||
PaymentsService,
|
||||
PaymentIntentRepository,
|
||||
PaymentWebhookEventRepository,
|
||||
TelebirrProvider,
|
||||
TelebirrWebhookService,
|
||||
],
|
||||
})
|
||||
export class PaymentsModule {}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { DeepPartial, QueryFailedError, Repository } from "typeorm";
|
||||
|
||||
import { PaymentIntent } from "./entities/payment-intent.entity";
|
||||
import { PaymentWebhookEvent } from "./entities/payment-webhook-event.entity";
|
||||
|
||||
const PG_UNIQUE_VIOLATION = "23505";
|
||||
|
||||
function isUniqueViolation(err: unknown): boolean {
|
||||
return (
|
||||
err instanceof QueryFailedError &&
|
||||
(err as QueryFailedError & { driverError?: { code?: string } }).driverError
|
||||
?.code === PG_UNIQUE_VIOLATION
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PaymentIntentRepository extends BaseRepository<PaymentIntent> {
|
||||
constructor(
|
||||
@InjectRepository(PaymentIntent)
|
||||
repository: Repository<PaymentIntent>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
findByBookingId(bookingId: string): Promise<PaymentIntent | null> {
|
||||
return this.repository.findOne({ where: { bookingId } });
|
||||
}
|
||||
|
||||
findByMerchantOrderId(merchantOrderId: string): Promise<PaymentIntent | null> {
|
||||
return this.repository.findOne({ where: { merchantOrderId } });
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PaymentWebhookEventRepository extends BaseRepository<PaymentWebhookEvent> {
|
||||
constructor(
|
||||
@InjectRepository(PaymentWebhookEvent)
|
||||
repository: Repository<PaymentWebhookEvent>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a webhook event, returning null if an event with the same
|
||||
* externalEventId already exists (deduplication via unique constraint).
|
||||
*/
|
||||
async createIfNew(
|
||||
data: DeepPartial<PaymentWebhookEvent>,
|
||||
): Promise<PaymentWebhookEvent | null> {
|
||||
try {
|
||||
return await this.repository.save(this.repository.create(data));
|
||||
} catch (err) {
|
||||
if (isUniqueViolation(err)) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async markProcessed(id: string, processingError?: string): Promise<void> {
|
||||
await this.repository.update(id, {
|
||||
processedAt: new Date(),
|
||||
processingError: processingError ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
212
apps/edr-freight-api/src/modules/payments/payments.service.ts
Normal file
212
apps/edr-freight-api/src/modules/payments/payments.service.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||
import {
|
||||
ClientAction,
|
||||
createMerchantOrderId,
|
||||
PaymentProvider,
|
||||
ProviderMethod,
|
||||
ProviderPaymentStatus,
|
||||
TelebirrProvider,
|
||||
} from "@edr/payment-providers";
|
||||
import { Freight } from "@edr/types";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import { BookingsService } from "../bookings/bookings.service";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { PaymentIntent } from "./entities/payment-intent.entity";
|
||||
import { PaymentIntentRepository } from "./payments.repository";
|
||||
import {
|
||||
InitiatePaymentDto,
|
||||
InitiateResponseDto,
|
||||
IntentStatusDto,
|
||||
PaymentPlatformDto,
|
||||
} from "./payments.dto";
|
||||
|
||||
const NON_TERMINAL_STATUSES: ProviderPaymentStatus[] = [
|
||||
ProviderPaymentStatus.REQUIRES_ACTION,
|
||||
ProviderPaymentStatus.PROCESSING,
|
||||
ProviderPaymentStatus.SUCCEEDED,
|
||||
];
|
||||
|
||||
const DEFAULT_CURRENCY = "ETB";
|
||||
|
||||
@Injectable()
|
||||
export class PaymentsService {
|
||||
private readonly logger = new Logger(PaymentsService.name);
|
||||
private readonly providers: Map<ProviderMethod, PaymentProvider>;
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly intents: PaymentIntentRepository,
|
||||
private readonly bookings: BookingsService,
|
||||
private readonly telebirrProvider: TelebirrProvider,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
) {
|
||||
this.providers = new Map<ProviderMethod, PaymentProvider>([
|
||||
[ProviderMethod.TELEBIRR, this.telebirrProvider],
|
||||
// add ProviderMethod.CBE_BIRR etc. here when enabled for freight
|
||||
]);
|
||||
}
|
||||
|
||||
async initiatePayment(dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
|
||||
const booking = await this.bookings.findById(dto.bookingId);
|
||||
if (booking.paymentStatus !== Freight.PaymentStatus.Pending) {
|
||||
throw new BadRequestException("Booking is not payable");
|
||||
}
|
||||
|
||||
const existing = await this.intents.findByBookingId(dto.bookingId);
|
||||
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
|
||||
return this.formatIntentResponse(existing);
|
||||
}
|
||||
|
||||
const method = dto.method as unknown as ProviderMethod;
|
||||
const provider = this.providers.get(method);
|
||||
if (!provider) {
|
||||
throw new BadRequestException(`Unsupported payment method: ${method}`);
|
||||
}
|
||||
|
||||
return this.initiateProviderPayment(booking, provider, dto.platform);
|
||||
}
|
||||
|
||||
private async initiateProviderPayment(
|
||||
booking: Booking,
|
||||
provider: PaymentProvider,
|
||||
platform: PaymentPlatformDto | undefined,
|
||||
): Promise<InitiateResponseDto> {
|
||||
const merchantOrderId = createMerchantOrderId();
|
||||
const amountMinor = Math.round(Number(booking.totalAmount) * 100);
|
||||
|
||||
const result = await provider.initiate({
|
||||
merchantOrderId,
|
||||
orderRef: booking.reference,
|
||||
amountMinor,
|
||||
currency: DEFAULT_CURRENCY,
|
||||
platform,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Initiated ${provider.method} payment for booking ${booking.reference} (merchantOrderId=${merchantOrderId})`,
|
||||
);
|
||||
|
||||
const data = {
|
||||
bookingId: booking.id,
|
||||
amountMinor,
|
||||
currency: DEFAULT_CURRENCY,
|
||||
method: provider.method,
|
||||
status: ProviderPaymentStatus.REQUIRES_ACTION,
|
||||
merchantOrderId,
|
||||
providerOrderId: result.providerOrderId,
|
||||
clientAction: result.clientAction as unknown as Record<string, unknown>,
|
||||
rawInitiation: result.rawInitiation,
|
||||
expiresAt: result.expiresAt,
|
||||
failureCode: null,
|
||||
failureMessage: null,
|
||||
};
|
||||
|
||||
const existing = await this.intents.findByBookingId(booking.id);
|
||||
const intent = existing
|
||||
? await this.intents.update(existing.id, data)
|
||||
: await this.intents.create(data);
|
||||
|
||||
return this.formatIntentResponse(intent!);
|
||||
}
|
||||
|
||||
async getIntentByBookingId(bookingId: string): Promise<IntentStatusDto> {
|
||||
const intent = await this.intents.findByBookingId(bookingId);
|
||||
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
||||
return this.formatIntentStatus(intent);
|
||||
}
|
||||
|
||||
async finalizePaymentSuccess(input: {
|
||||
intentId: string;
|
||||
providerTxnId?: string;
|
||||
paidAt?: Date;
|
||||
}): Promise<{ alreadyFinalized: boolean }> {
|
||||
const intent = await this.intents.findById(input.intentId);
|
||||
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
||||
if (intent.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
return { alreadyFinalized: true };
|
||||
}
|
||||
if (intent.status === ProviderPaymentStatus.CANCELLED) {
|
||||
throw new BadRequestException("PaymentIntent is cancelled; cannot finalize");
|
||||
}
|
||||
|
||||
const booking = await this.bookings.findById(intent.bookingId);
|
||||
const paidAt = input.paidAt ?? new Date();
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.update(
|
||||
PaymentIntent,
|
||||
{ id: intent.id },
|
||||
{
|
||||
status: ProviderPaymentStatus.SUCCEEDED,
|
||||
providerTxnId: input.providerTxnId ?? intent.providerTxnId ?? null,
|
||||
paidAt,
|
||||
},
|
||||
);
|
||||
// FREIGHT-SPECIFIC: mark the booking paid and confirm it.
|
||||
await manager.update(
|
||||
Booking,
|
||||
{ id: booking.id },
|
||||
{
|
||||
paymentStatus: Freight.PaymentStatus.Paid,
|
||||
status: Freight.BookingStatus.Confirmed,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
this.eventEmitter.emit("payment.succeeded", {
|
||||
bookingId: booking.id,
|
||||
reference: booking.reference,
|
||||
});
|
||||
|
||||
return { alreadyFinalized: false };
|
||||
}
|
||||
|
||||
async markPaymentFailed(input: {
|
||||
intentId: string;
|
||||
failureCode?: string;
|
||||
failureMessage?: string;
|
||||
}): Promise<void> {
|
||||
const intent = await this.intents.findById(input.intentId);
|
||||
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
||||
if (
|
||||
intent.status === ProviderPaymentStatus.SUCCEEDED ||
|
||||
intent.status === ProviderPaymentStatus.CANCELLED
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.intents.update(intent.id, {
|
||||
status: ProviderPaymentStatus.FAILED,
|
||||
failureCode: input.failureCode ?? null,
|
||||
failureMessage: input.failureMessage ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
private formatIntentResponse(intent: PaymentIntent): InitiateResponseDto {
|
||||
const clientAction =
|
||||
intent.clientAction && typeof intent.clientAction === "object"
|
||||
? (intent.clientAction as unknown as ClientAction)
|
||||
: undefined;
|
||||
return {
|
||||
intentId: intent.id,
|
||||
status: intent.status,
|
||||
clientAction,
|
||||
merchantOrderId: intent.merchantOrderId ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private formatIntentStatus(intent: PaymentIntent): IntentStatusDto {
|
||||
return {
|
||||
...this.formatIntentResponse(intent),
|
||||
paidAt: intent.paidAt?.toISOString(),
|
||||
failureCode: intent.failureCode ?? undefined,
|
||||
failureMessage: intent.failureMessage ?? undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import {
|
||||
ProviderMethod,
|
||||
ProviderPaymentStatus,
|
||||
TelebirrProvider,
|
||||
TelebirrWebhookPayload,
|
||||
} from "@edr/payment-providers";
|
||||
|
||||
import { PaymentsService } from "../payments.service";
|
||||
import {
|
||||
PaymentIntentRepository,
|
||||
PaymentWebhookEventRepository,
|
||||
} from "../payments.repository";
|
||||
|
||||
@Injectable()
|
||||
export class TelebirrWebhookService {
|
||||
private readonly logger = new Logger(TelebirrWebhookService.name);
|
||||
|
||||
constructor(
|
||||
private readonly events: PaymentWebhookEventRepository,
|
||||
private readonly intents: PaymentIntentRepository,
|
||||
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
|
||||
// (matches the passenger implementation; see TelebirrProvider.verifyWebhookSignature)
|
||||
const signatureValid = true;
|
||||
|
||||
const eventRow = await this.events.createIfNew({
|
||||
provider: ProviderMethod.TELEBIRR,
|
||||
externalEventId,
|
||||
merchantOrderId,
|
||||
providerTxnId: payload.trans_id ?? payload.payment_order_id,
|
||||
signatureValid,
|
||||
status: payload.trade_status,
|
||||
payload: payload as unknown as Record<string, unknown>,
|
||||
});
|
||||
|
||||
if (!eventRow) {
|
||||
this.logger.log(
|
||||
`Telebirr webhook duplicate: ${externalEventId} — short-circuit OK`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const intent = await this.intents.findByMerchantOrderId(merchantOrderId);
|
||||
if (!intent) {
|
||||
this.logger.warn(
|
||||
`Telebirr webhook: no PaymentIntent for merch_order_id=${merchantOrderId}`,
|
||||
);
|
||||
await this.events.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.intents.update(intent.id, {
|
||||
status: mapped,
|
||||
providerTxnId: payload.trans_id ?? undefined,
|
||||
});
|
||||
}
|
||||
await this.events.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.events.markProcessed(eventRow.id, `processing-error: ${message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private buildExternalEventId(payload: TelebirrWebhookPayload): string {
|
||||
return `${payload.payment_order_id}_${payload.trade_status}`;
|
||||
}
|
||||
|
||||
private parseEpochSeconds(raw: string | undefined): Date | undefined {
|
||||
if (!raw) return undefined;
|
||||
const n = parseInt(raw, 10);
|
||||
if (Number.isNaN(n)) return undefined;
|
||||
return new Date(n * 1000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
All,
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { TelebirrWebhookPayload } from "@edr/payment-providers";
|
||||
|
||||
import { TelebirrWebhookService } from "./telebirr-webhook.service";
|
||||
|
||||
@ApiTags("Payment Webhooks")
|
||||
@Controller("payments/webhooks")
|
||||
export class WebhooksController {
|
||||
private readonly logger = new Logger(WebhooksController.name);
|
||||
|
||||
constructor(private readonly telebirr: TelebirrWebhookService) {}
|
||||
|
||||
@All("telebirr")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: "Telebirr payment notification callback",
|
||||
description: "Webhook endpoint for Telebirr freight payment status updates.",
|
||||
})
|
||||
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" };
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@
|
||||
"seed": "ts-node prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edr/payment-providers": "workspace:*",
|
||||
"@edr/types": "workspace:*",
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
|
||||
@@ -4,11 +4,13 @@ import { PaymentsController } from './payments.controller';
|
||||
import { PaymentsService } from './payments.service';
|
||||
import { SeatsModule } from '../seats/seats.module';
|
||||
import { TicketsModule } from '../tickets/tickets.module';
|
||||
import { TelebirrProvider } from './providers/telebirr.provider';
|
||||
import { CbeBirrProvider } from './providers/cbe-birr.provider';
|
||||
import { EBirrProvider } from './providers/ebirr.provider';
|
||||
import { CardProvider } from './providers/card.provider';
|
||||
import { WaafiProvider } from './providers/waafi.provider';
|
||||
import {
|
||||
TelebirrProvider,
|
||||
CbeBirrProvider,
|
||||
EBirrProvider,
|
||||
CardProvider,
|
||||
WaafiProvider,
|
||||
} from '@edr/payment-providers';
|
||||
import { WebhooksController } from './webhooks/webhooks.controller';
|
||||
import { TelebirrWebhookService } from './webhooks/telebirr-webhook.service';
|
||||
import { CbeBirrWebhookService } from './webhooks/cbe-birr-webhook.service';
|
||||
|
||||
@@ -4,10 +4,12 @@ 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 { TelebirrProvider } from './providers/telebirr.provider';
|
||||
import { CbeBirrProvider } from './providers/cbe-birr.provider';
|
||||
import { EBirrProvider } from './providers/ebirr.provider';
|
||||
import { CardProvider } from './providers/card.provider';
|
||||
import {
|
||||
TelebirrProvider,
|
||||
CbeBirrProvider,
|
||||
EBirrProvider,
|
||||
CardProvider,
|
||||
} from '@edr/payment-providers';
|
||||
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
|
||||
|
||||
@@ -5,13 +5,18 @@ 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 { ClientAction, PaymentProvider, ProviderStatus } from './payments.types';
|
||||
import { TelebirrProvider } from './providers/telebirr.provider';
|
||||
import { CbeBirrProvider } from './providers/cbe-birr.provider';
|
||||
import { EBirrProvider } from './providers/ebirr.provider';
|
||||
import { CardProvider } from './providers/card.provider';
|
||||
import { WaafiProvider } from './providers/waafi.provider';
|
||||
import { createMerchantOrderId } from './providers/telebirr.crypto';
|
||||
import {
|
||||
ClientAction,
|
||||
PaymentProvider,
|
||||
ProviderStatus,
|
||||
ProviderPaymentStatus,
|
||||
TelebirrProvider,
|
||||
CbeBirrProvider,
|
||||
EBirrProvider,
|
||||
CardProvider,
|
||||
WaafiProvider,
|
||||
createMerchantOrderId,
|
||||
} from '@edr/payment-providers';
|
||||
|
||||
const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [
|
||||
PaymentIntentStatus.REQUIRES_ACTION,
|
||||
@@ -147,17 +152,18 @@ export class PaymentsService {
|
||||
const merchantOrderId = createMerchantOrderId();
|
||||
const result = await provider.initiate({
|
||||
merchantOrderId,
|
||||
bookingRef: booking.bookingRef,
|
||||
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: provider.method,
|
||||
method: providerMethod,
|
||||
merchantOrderId,
|
||||
providerOrderId: result.providerOrderId,
|
||||
clientAction: result.clientAction as unknown as Prisma.InputJsonValue,
|
||||
@@ -170,7 +176,7 @@ export class PaymentsService {
|
||||
bookingId: booking.id,
|
||||
amountMinor: booking.totalMinor,
|
||||
currency: booking.currency,
|
||||
method: provider.method,
|
||||
method: providerMethod,
|
||||
status: PaymentIntentStatus.REQUIRES_ACTION,
|
||||
merchantOrderId,
|
||||
providerOrderId: result.providerOrderId,
|
||||
@@ -235,14 +241,16 @@ export class PaymentsService {
|
||||
intentId: string,
|
||||
status: ProviderStatus,
|
||||
): Promise<void> {
|
||||
if (status.rawResponse.biz_content.order_status === "PAY_SUCCESS") {
|
||||
const bizContent = (status.rawResponse as { biz_content?: { order_status?: string } })
|
||||
?.biz_content;
|
||||
if (bizContent?.order_status === 'PAY_SUCCESS') {
|
||||
await this.finalizePaymentSuccess({
|
||||
intentId,
|
||||
providerTxnId: status.providerTxnId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (status.status === PaymentIntentStatus.FAILED) {
|
||||
if (status.status === ProviderPaymentStatus.FAILED) {
|
||||
await this.markPaymentFailed({
|
||||
intentId,
|
||||
failureCode: status.failureCode,
|
||||
@@ -253,7 +261,7 @@ export class PaymentsService {
|
||||
await this.prisma.paymentIntent.update({
|
||||
where: { id: intentId },
|
||||
data: {
|
||||
status: status.status,
|
||||
status: status.status as unknown as PaymentIntentStatus,
|
||||
providerTxnId: status.providerTxnId ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,36 +1,11 @@
|
||||
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
||||
|
||||
export type PaymentPlatform = 'web' | 'mobile';
|
||||
|
||||
export type ClientAction =
|
||||
| { type: 'REDIRECT'; url: string }
|
||||
| { type: 'LAUNCH_APP'; appId: string; receiveCode?: string; shortCode: string };
|
||||
|
||||
export interface ProviderInitiationInput {
|
||||
merchantOrderId: string;
|
||||
bookingRef: string;
|
||||
amountMinor: number;
|
||||
currency: string;
|
||||
platform?: PaymentPlatform;
|
||||
}
|
||||
|
||||
export interface ProviderInitiationResult {
|
||||
providerOrderId: string;
|
||||
clientAction: ClientAction;
|
||||
expiresAt: Date;
|
||||
rawInitiation: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ProviderStatus {
|
||||
status: PaymentIntentStatus;
|
||||
providerTxnId?: string;
|
||||
failureCode?: string;
|
||||
failureMessage?: string;
|
||||
rawResponse: any;
|
||||
}
|
||||
|
||||
export interface PaymentProvider {
|
||||
readonly method: PaymentMethodType;
|
||||
initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult>;
|
||||
queryStatus(merchantOrderId: string): Promise<ProviderStatus>;
|
||||
}
|
||||
// The payment provider contract now lives in @edr/types (consumed via @edr/payment-providers).
|
||||
// This file remains as a thin re-export so existing local imports keep working.
|
||||
export type {
|
||||
PaymentProvider,
|
||||
ProviderInitiationInput,
|
||||
ProviderInitiationResult,
|
||||
ProviderStatus,
|
||||
ClientAction,
|
||||
PaymentPlatform,
|
||||
} from '@edr/types';
|
||||
export { ProviderPaymentStatus, ProviderMethod } from '@edr/types';
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
||||
import { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import * as crypto from 'node:crypto';
|
||||
import {
|
||||
PaymentProvider,
|
||||
ProviderInitiationInput,
|
||||
ProviderInitiationResult,
|
||||
ProviderStatus,
|
||||
} from '../payments.types';
|
||||
|
||||
interface CardInitiateRequest {
|
||||
amount: number;
|
||||
currency: string;
|
||||
description: string;
|
||||
metadata: {
|
||||
merchantOrderId: string;
|
||||
bookingRef: string;
|
||||
};
|
||||
return_url: string;
|
||||
webhook_url: string;
|
||||
}
|
||||
|
||||
interface CardInitiateResponse {
|
||||
id: string;
|
||||
status: string;
|
||||
client_secret: string;
|
||||
checkout_url: string;
|
||||
expires_at: number;
|
||||
}
|
||||
|
||||
interface CardQueryResponse {
|
||||
id: string;
|
||||
status: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
transaction_id?: string;
|
||||
paid_at?: number;
|
||||
failure_code?: string;
|
||||
failure_message?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CardProvider implements PaymentProvider {
|
||||
readonly method = PaymentMethodType.CARD;
|
||||
private readonly logger = new Logger(CardProvider.name);
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly http: HttpService,
|
||||
) {}
|
||||
|
||||
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
|
||||
const amount = input.amountMinor / 100;
|
||||
|
||||
const requestBody: CardInitiateRequest = {
|
||||
amount,
|
||||
currency: input.currency,
|
||||
description: `EDR Train Booking ${input.bookingRef}`,
|
||||
metadata: {
|
||||
merchantOrderId: input.merchantOrderId,
|
||||
bookingRef: input.bookingRef,
|
||||
},
|
||||
return_url: this.returnUrl,
|
||||
webhook_url: this.webhookUrl,
|
||||
};
|
||||
|
||||
const response = await this.postJson<CardInitiateResponse>(
|
||||
`${this.baseUrl}/v1/payment_intents`,
|
||||
requestBody,
|
||||
);
|
||||
|
||||
if (!response.id) {
|
||||
throw new Error(`Card gateway initiate failed: ${JSON.stringify(response)}`);
|
||||
}
|
||||
|
||||
const expiresAt = new Date(response.expires_at * 1000);
|
||||
|
||||
return {
|
||||
providerOrderId: response.id,
|
||||
clientAction: { type: 'REDIRECT', url: response.checkout_url },
|
||||
expiresAt,
|
||||
rawInitiation: {
|
||||
request: requestBody,
|
||||
response,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
|
||||
// For card payments, we need to find the payment intent by metadata
|
||||
// In a real implementation, we'd store the provider order ID and use it directly
|
||||
const response = await this.getJson<CardQueryResponse>(
|
||||
`${this.baseUrl}/v1/payment_intents/search?metadata[merchantOrderId]=${merchantOrderId}`,
|
||||
);
|
||||
|
||||
const mapped = this.mapStatus(response.status);
|
||||
|
||||
return {
|
||||
status: mapped,
|
||||
providerTxnId: response.transaction_id,
|
||||
failureCode: response.failure_code,
|
||||
failureMessage: response.failure_message,
|
||||
rawResponse: response as unknown as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
verifyWebhookSignature(payload: Record<string, unknown>, signature: string): boolean {
|
||||
const payloadString = JSON.stringify(payload);
|
||||
const expectedSignature = crypto
|
||||
.createHmac('sha256', this.webhookSecret)
|
||||
.update(payloadString)
|
||||
.digest('hex');
|
||||
|
||||
try {
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(signature),
|
||||
Buffer.from(expectedSignature),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
mapWebhookStatus(status: string): PaymentIntentStatus {
|
||||
return this.mapStatus(status);
|
||||
}
|
||||
|
||||
private mapStatus(status: string): PaymentIntentStatus {
|
||||
switch (status?.toLowerCase()) {
|
||||
case 'succeeded':
|
||||
case 'paid':
|
||||
return PaymentIntentStatus.SUCCEEDED;
|
||||
case 'failed':
|
||||
case 'canceled':
|
||||
case 'expired':
|
||||
return PaymentIntentStatus.FAILED;
|
||||
case 'requires_payment_method':
|
||||
case 'requires_confirmation':
|
||||
case 'requires_action':
|
||||
return PaymentIntentStatus.REQUIRES_ACTION;
|
||||
case 'processing':
|
||||
return PaymentIntentStatus.PROCESSING;
|
||||
default:
|
||||
return PaymentIntentStatus.PROCESSING;
|
||||
}
|
||||
}
|
||||
|
||||
private async postJson<T>(url: string, body: unknown): Promise<T> {
|
||||
const config: AxiosRequestConfig = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
},
|
||||
timeout: 10_000,
|
||||
};
|
||||
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await firstValueFrom(this.http.post<T>(url, body, config));
|
||||
this.logger.debug(`Card Gateway POST ${url} status=${res.status} latency=${Date.now() - started}ms`);
|
||||
return res.data;
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError) {
|
||||
this.logger.error(
|
||||
`Card Gateway POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.error(`Card Gateway POST ${url} threw: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async getJson<T>(url: string): Promise<T> {
|
||||
const config: AxiosRequestConfig = {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
},
|
||||
timeout: 10_000,
|
||||
};
|
||||
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await firstValueFrom(this.http.get<T>(url, config));
|
||||
this.logger.debug(`Card Gateway GET ${url} status=${res.status} latency=${Date.now() - started}ms`);
|
||||
return res.data;
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError) {
|
||||
this.logger.error(
|
||||
`Card Gateway GET ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.error(`Card Gateway GET ${url} threw: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private get baseUrl(): string {
|
||||
return this.config.get<string>('card.baseUrl') ?? '';
|
||||
}
|
||||
private get apiKey(): string {
|
||||
return this.config.get<string>('card.apiKey') ?? '';
|
||||
}
|
||||
private get webhookSecret(): string {
|
||||
return this.config.get<string>('card.webhookSecret') ?? '';
|
||||
}
|
||||
private get webhookUrl(): string {
|
||||
return this.config.get<string>('card.webhookUrl') ?? '';
|
||||
}
|
||||
private get returnUrl(): string {
|
||||
return this.config.get<string>('card.returnUrl') ?? '';
|
||||
}
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
||||
import { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import * as crypto from 'node:crypto';
|
||||
import {
|
||||
PaymentProvider,
|
||||
ProviderInitiationInput,
|
||||
ProviderInitiationResult,
|
||||
ProviderStatus,
|
||||
} from '../payments.types';
|
||||
|
||||
interface CbeBirrInitiateRequest {
|
||||
merchantId: string;
|
||||
merchantOrderId: string;
|
||||
amount: string;
|
||||
currency: string;
|
||||
description: string;
|
||||
returnUrl: string;
|
||||
notifyUrl: string;
|
||||
timestamp: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
interface CbeBirrInitiateResponse {
|
||||
success: boolean;
|
||||
orderId: string;
|
||||
paymentUrl: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
interface CbeBirrQueryResponse {
|
||||
success: boolean;
|
||||
orderId: string;
|
||||
status: string;
|
||||
transactionId?: string;
|
||||
amount?: string;
|
||||
paidAt?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CbeBirrProvider implements PaymentProvider {
|
||||
readonly method = PaymentMethodType.CBE_BIRR;
|
||||
private readonly logger = new Logger(CbeBirrProvider.name);
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly http: HttpService,
|
||||
) {}
|
||||
|
||||
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
|
||||
const amount = (input.amountMinor / 100).toFixed(2);
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
const requestBody: CbeBirrInitiateRequest = {
|
||||
merchantId: this.merchantId,
|
||||
merchantOrderId: input.merchantOrderId,
|
||||
amount,
|
||||
currency: input.currency,
|
||||
description: `EDR Booking ${input.bookingRef}`,
|
||||
returnUrl: this.returnUrl,
|
||||
notifyUrl: this.notifyUrl,
|
||||
timestamp,
|
||||
signature: this.signRequest({
|
||||
merchantId: this.merchantId,
|
||||
merchantOrderId: input.merchantOrderId,
|
||||
amount,
|
||||
timestamp,
|
||||
}),
|
||||
};
|
||||
|
||||
const response = await this.postJson<CbeBirrInitiateResponse>(
|
||||
`${this.baseUrl}/api/v1/payment/initiate`,
|
||||
requestBody,
|
||||
);
|
||||
|
||||
if (!response.success || !response.orderId) {
|
||||
throw new Error(`CBE Birr initiate failed: ${JSON.stringify(response)}`);
|
||||
}
|
||||
|
||||
const expiresAt = new Date(Date.now() + response.expiresIn * 1000);
|
||||
|
||||
return {
|
||||
providerOrderId: response.orderId,
|
||||
clientAction: { type: 'REDIRECT', url: response.paymentUrl },
|
||||
expiresAt,
|
||||
rawInitiation: {
|
||||
request: this.sanitize(requestBody),
|
||||
response,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const signature = this.signRequest({
|
||||
merchantId: this.merchantId,
|
||||
merchantOrderId,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
const response = await this.postJson<CbeBirrQueryResponse>(
|
||||
`${this.baseUrl}/api/v1/payment/query`,
|
||||
{
|
||||
merchantId: this.merchantId,
|
||||
merchantOrderId,
|
||||
timestamp,
|
||||
signature,
|
||||
},
|
||||
);
|
||||
|
||||
const mapped = this.mapStatus(response.status);
|
||||
|
||||
return {
|
||||
status: mapped,
|
||||
providerTxnId: response.transactionId,
|
||||
failureCode: mapped === PaymentIntentStatus.FAILED ? response.status : undefined,
|
||||
rawResponse: response as unknown as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
|
||||
const { signature, ...data } = payload;
|
||||
if (!signature || typeof signature !== 'string') return false;
|
||||
|
||||
const expectedSignature = this.signRequest(data);
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(signature),
|
||||
Buffer.from(expectedSignature),
|
||||
);
|
||||
}
|
||||
|
||||
mapWebhookStatus(status: string): PaymentIntentStatus {
|
||||
return this.mapStatus(status);
|
||||
}
|
||||
|
||||
private mapStatus(status: string): PaymentIntentStatus {
|
||||
switch (status?.toUpperCase()) {
|
||||
case 'SUCCESS':
|
||||
case 'COMPLETED':
|
||||
return PaymentIntentStatus.SUCCEEDED;
|
||||
case 'FAILED':
|
||||
case 'REJECTED':
|
||||
case 'EXPIRED':
|
||||
return PaymentIntentStatus.FAILED;
|
||||
case 'PENDING':
|
||||
return PaymentIntentStatus.REQUIRES_ACTION;
|
||||
case 'PROCESSING':
|
||||
return PaymentIntentStatus.PROCESSING;
|
||||
default:
|
||||
return PaymentIntentStatus.PROCESSING;
|
||||
}
|
||||
}
|
||||
|
||||
private signRequest(data: Record<string, unknown>): string {
|
||||
const sortedKeys = Object.keys(data).sort();
|
||||
const signString = sortedKeys
|
||||
.map((key) => `${key}=${data[key]}`)
|
||||
.join('&');
|
||||
|
||||
return crypto
|
||||
.createHmac('sha256', this.secretKey)
|
||||
.update(signString)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
private async postJson<T>(url: string, body: unknown): Promise<T> {
|
||||
const config: AxiosRequestConfig = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Merchant-Id': this.merchantId,
|
||||
},
|
||||
timeout: 10_000,
|
||||
};
|
||||
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await firstValueFrom(this.http.post<T>(url, body, config));
|
||||
this.logger.debug(`CBE Birr POST ${url} status=${res.status} latency=${Date.now() - started}ms`);
|
||||
return res.data;
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError) {
|
||||
this.logger.error(
|
||||
`CBE Birr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.error(`CBE Birr POST ${url} threw: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private sanitize(body: CbeBirrInitiateRequest): Record<string, unknown> {
|
||||
const { signature: _signature, ...rest } = body;
|
||||
return rest;
|
||||
}
|
||||
|
||||
private get baseUrl(): string {
|
||||
return this.config.get<string>('cbe.baseUrl') ?? '';
|
||||
}
|
||||
private get merchantId(): string {
|
||||
return this.config.get<string>('cbe.merchantId') ?? '';
|
||||
}
|
||||
private get secretKey(): string {
|
||||
return this.config.get<string>('cbe.secretKey') ?? '';
|
||||
}
|
||||
private get notifyUrl(): string {
|
||||
return this.config.get<string>('cbe.notifyUrl') ?? '';
|
||||
}
|
||||
private get returnUrl(): string {
|
||||
return this.config.get<string>('cbe.returnUrl') ?? '';
|
||||
}
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
||||
import { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import * as crypto from 'node:crypto';
|
||||
import {
|
||||
PaymentProvider,
|
||||
ProviderInitiationInput,
|
||||
ProviderInitiationResult,
|
||||
ProviderStatus,
|
||||
} from '../payments.types';
|
||||
|
||||
interface EBirrInitiateRequest {
|
||||
merchantCode: string;
|
||||
orderNo: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
notifyUrl: string;
|
||||
returnUrl: string;
|
||||
timestamp: number;
|
||||
sign: string;
|
||||
}
|
||||
|
||||
interface EBirrInitiateResponse {
|
||||
code: string;
|
||||
message: string;
|
||||
data?: {
|
||||
orderNo: string;
|
||||
payUrl: string;
|
||||
expireTime: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface EBirrQueryResponse {
|
||||
code: string;
|
||||
message: string;
|
||||
data?: {
|
||||
orderNo: string;
|
||||
tradeStatus: string;
|
||||
tradeNo?: string;
|
||||
totalAmount?: number;
|
||||
payTime?: number;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EBirrProvider implements PaymentProvider {
|
||||
readonly method = PaymentMethodType.EBIRR;
|
||||
private readonly logger = new Logger(EBirrProvider.name);
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly http: HttpService,
|
||||
) {}
|
||||
|
||||
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
|
||||
const amount = input.amountMinor / 100;
|
||||
const timestamp = Date.now();
|
||||
|
||||
const requestBody: EBirrInitiateRequest = {
|
||||
merchantCode: this.merchantCode,
|
||||
orderNo: input.merchantOrderId,
|
||||
amount,
|
||||
currency: input.currency,
|
||||
subject: `EDR Ticket`,
|
||||
body: `Train booking ${input.bookingRef}`,
|
||||
notifyUrl: this.notifyUrl,
|
||||
returnUrl: this.returnUrl,
|
||||
timestamp,
|
||||
sign: this.signRequest({
|
||||
merchantCode: this.merchantCode,
|
||||
orderNo: input.merchantOrderId,
|
||||
amount,
|
||||
timestamp,
|
||||
}),
|
||||
};
|
||||
|
||||
const response = await this.postJson<EBirrInitiateResponse>(
|
||||
`${this.baseUrl}/gateway/api/pay/create`,
|
||||
requestBody,
|
||||
);
|
||||
|
||||
if (response.code !== '0000' || !response.data?.orderNo) {
|
||||
throw new Error(`eBirr initiate failed: ${response.message}`);
|
||||
}
|
||||
|
||||
const expiresAt = new Date(response.data.expireTime);
|
||||
|
||||
return {
|
||||
providerOrderId: response.data.orderNo,
|
||||
clientAction: { type: 'REDIRECT', url: response.data.payUrl },
|
||||
expiresAt,
|
||||
rawInitiation: {
|
||||
request: this.sanitize(requestBody),
|
||||
response,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
|
||||
const timestamp = Date.now();
|
||||
const requestBody = {
|
||||
merchantCode: this.merchantCode,
|
||||
orderNo: merchantOrderId,
|
||||
timestamp,
|
||||
sign: this.signRequest({
|
||||
merchantCode: this.merchantCode,
|
||||
orderNo: merchantOrderId,
|
||||
timestamp,
|
||||
}),
|
||||
};
|
||||
|
||||
const response = await this.postJson<EBirrQueryResponse>(
|
||||
`${this.baseUrl}/gateway/api/pay/query`,
|
||||
requestBody,
|
||||
);
|
||||
|
||||
if (response.code !== '0000' || !response.data) {
|
||||
throw new Error(`eBirr query failed: ${response.message}`);
|
||||
}
|
||||
|
||||
const mapped = this.mapStatus(response.data.tradeStatus);
|
||||
|
||||
return {
|
||||
status: mapped,
|
||||
providerTxnId: response.data.tradeNo,
|
||||
failureCode: mapped === PaymentIntentStatus.FAILED ? response.data.tradeStatus : undefined,
|
||||
rawResponse: response as unknown as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
|
||||
const { sign, ...data } = payload;
|
||||
if (!sign || typeof sign !== 'string') return false;
|
||||
|
||||
const expectedSign = this.signRequest(data);
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(sign),
|
||||
Buffer.from(expectedSign),
|
||||
);
|
||||
}
|
||||
|
||||
mapWebhookStatus(tradeStatus: string): PaymentIntentStatus {
|
||||
return this.mapStatus(tradeStatus);
|
||||
}
|
||||
|
||||
private mapStatus(tradeStatus: string): PaymentIntentStatus {
|
||||
switch (tradeStatus?.toUpperCase()) {
|
||||
case 'TRADE_SUCCESS':
|
||||
case 'SUCCESS':
|
||||
return PaymentIntentStatus.SUCCEEDED;
|
||||
case 'TRADE_CLOSED':
|
||||
case 'TRADE_FAILED':
|
||||
case 'FAILED':
|
||||
return PaymentIntentStatus.FAILED;
|
||||
case 'WAIT_BUYER_PAY':
|
||||
case 'PENDING':
|
||||
return PaymentIntentStatus.REQUIRES_ACTION;
|
||||
case 'PROCESSING':
|
||||
return PaymentIntentStatus.PROCESSING;
|
||||
default:
|
||||
return PaymentIntentStatus.PROCESSING;
|
||||
}
|
||||
}
|
||||
|
||||
private signRequest(data: Record<string, unknown>): string {
|
||||
const sortedKeys = Object.keys(data).sort();
|
||||
const signString = sortedKeys
|
||||
.map((key) => `${key}=${data[key]}`)
|
||||
.join('&') + `&key=${this.secretKey}`;
|
||||
|
||||
return crypto
|
||||
.createHash('md5')
|
||||
.update(signString)
|
||||
.digest('hex')
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
private async postJson<T>(url: string, body: unknown): Promise<T> {
|
||||
const config: AxiosRequestConfig = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 10_000,
|
||||
};
|
||||
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await firstValueFrom(this.http.post<T>(url, body, config));
|
||||
this.logger.debug(`eBirr POST ${url} status=${res.status} latency=${Date.now() - started}ms`);
|
||||
return res.data;
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError) {
|
||||
this.logger.error(
|
||||
`eBirr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.error(`eBirr POST ${url} threw: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private sanitize(body: EBirrInitiateRequest): Record<string, unknown> {
|
||||
const { sign: _sign, ...rest } = body;
|
||||
return rest;
|
||||
}
|
||||
|
||||
private get baseUrl(): string {
|
||||
return this.config.get<string>('ebirr.baseUrl') ?? '';
|
||||
}
|
||||
private get merchantCode(): string {
|
||||
return this.config.get<string>('ebirr.merchantCode') ?? '';
|
||||
}
|
||||
private get secretKey(): string {
|
||||
return this.config.get<string>('ebirr.secretKey') ?? '';
|
||||
}
|
||||
private get notifyUrl(): string {
|
||||
return this.config.get<string>('ebirr.notifyUrl') ?? '';
|
||||
}
|
||||
private get returnUrl(): string {
|
||||
return this.config.get<string>('ebirr.returnUrl') ?? '';
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export type {
|
||||
PaymentProvider,
|
||||
ProviderInitiationInput,
|
||||
ProviderInitiationResult,
|
||||
ProviderStatus,
|
||||
ClientAction,
|
||||
} from '../payments.types';
|
||||
|
||||
export const PAYMENT_PROVIDERS = Symbol('PAYMENT_PROVIDERS');
|
||||
@@ -1,98 +0,0 @@
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
const EXCLUDE_FIELDS = new Set([
|
||||
'sign',
|
||||
'sign_type',
|
||||
'header',
|
||||
'refund_info',
|
||||
'openType',
|
||||
'raw_request',
|
||||
'biz_content',
|
||||
]);
|
||||
|
||||
const NONCE_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
|
||||
export function buildCanonicalString(requestObject: Record<string, unknown>): string {
|
||||
const fieldMap: Record<string, unknown> = {};
|
||||
|
||||
for (const key of Object.keys(requestObject)) {
|
||||
if (EXCLUDE_FIELDS.has(key)) continue;
|
||||
fieldMap[key] = requestObject[key];
|
||||
}
|
||||
|
||||
const biz = requestObject['biz_content'];
|
||||
if (biz && typeof biz === 'object') {
|
||||
for (const key of Object.keys(biz as Record<string, unknown>)) {
|
||||
if (EXCLUDE_FIELDS.has(key)) continue;
|
||||
fieldMap[key] = (biz as Record<string, unknown>)[key];
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(fieldMap)
|
||||
.sort()
|
||||
.map((k) => `${k}=${fieldMap[k]}`)
|
||||
.join('&');
|
||||
}
|
||||
|
||||
export function signRequestObject(
|
||||
requestObject: Record<string, unknown>,
|
||||
privateKey: string,
|
||||
): string {
|
||||
return signString(buildCanonicalString(requestObject), privateKey);
|
||||
}
|
||||
|
||||
export function verifyRequestObject(
|
||||
requestObject: Record<string, unknown>,
|
||||
publicKey: string,
|
||||
): boolean {
|
||||
const signature = requestObject['sign'];
|
||||
if (typeof signature !== 'string' || signature.length === 0) return false;
|
||||
return verifySignature(buildCanonicalString(requestObject), signature, publicKey);
|
||||
}
|
||||
|
||||
export function signString(text: string, privateKey: string): string {
|
||||
const signature = crypto.sign('sha256', Buffer.from(text), {
|
||||
key: privateKey,
|
||||
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
|
||||
saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST,
|
||||
});
|
||||
return signature.toString('base64');
|
||||
}
|
||||
|
||||
export function verifySignature(
|
||||
text: string,
|
||||
signatureBase64: string,
|
||||
publicKey: string,
|
||||
): boolean {
|
||||
try {
|
||||
return crypto.verify(
|
||||
'sha256',
|
||||
Buffer.from(text),
|
||||
{
|
||||
key: publicKey,
|
||||
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
|
||||
saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST,
|
||||
},
|
||||
Buffer.from(signatureBase64, 'base64'),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function createTimestamp(): string {
|
||||
return Math.round(Date.now() / 1000).toString();
|
||||
}
|
||||
|
||||
export function createNonceStr(length = 32): string {
|
||||
const bytes = crypto.randomBytes(length);
|
||||
let out = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
out += NONCE_CHARS[bytes[i] % NONCE_CHARS.length];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function createMerchantOrderId(): string {
|
||||
return `${Date.now()}${crypto.randomBytes(4).toString('hex')}`;
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
||||
import { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import * as https from 'node:https';
|
||||
import {
|
||||
PaymentProvider,
|
||||
ProviderInitiationInput,
|
||||
ProviderInitiationResult,
|
||||
ProviderStatus,
|
||||
} from '../payments.types';
|
||||
import {
|
||||
createNonceStr,
|
||||
createTimestamp,
|
||||
signRequestObject,
|
||||
verifyRequestObject,
|
||||
} from './telebirr.crypto';
|
||||
import {
|
||||
CreateOrderRequest,
|
||||
CreateOrderResponse,
|
||||
FabricTokenResponse,
|
||||
QueryOrderResponse,
|
||||
} from './telebirr.types';
|
||||
|
||||
const TELEBIRR_HTTP_TIMEOUT_MS = 10_000;
|
||||
|
||||
@Injectable()
|
||||
export class TelebirrProvider implements PaymentProvider {
|
||||
readonly method = PaymentMethodType.TELEBIRR;
|
||||
private readonly logger = new Logger(TelebirrProvider.name);
|
||||
private readonly httpsAgent: https.Agent;
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly http: HttpService,
|
||||
) {
|
||||
const insecure = this.config.get<boolean>('telebirr.insecureTls');
|
||||
if (insecure) {
|
||||
this.logger.warn('TELEBIRR_INSECURE_TLS=true — TLS verification disabled for Telebirr calls. DEV ONLY.');
|
||||
}
|
||||
this.httpsAgent = new https.Agent({
|
||||
rejectUnauthorized: !insecure,
|
||||
secureProtocol: 'TLSv1_2_method',
|
||||
});
|
||||
}
|
||||
|
||||
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
|
||||
const fabricToken = await this.applyFabricToken();
|
||||
const requestBody = this.buildCreateOrderRequest(input);
|
||||
const response = await this.requestCreateOrder(fabricToken, requestBody);
|
||||
|
||||
const prepayId = response.biz_content?.prepay_id;
|
||||
if (!prepayId) {
|
||||
throw new Error(
|
||||
`Telebirr createOrder returned no prepay_id: ${JSON.stringify(response)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const expiresAt = this.computeExpiresAt(requestBody.biz_content.timeout_express);
|
||||
const platform = input.platform ?? 'web';
|
||||
const clientAction =
|
||||
platform === 'mobile'
|
||||
? {
|
||||
type: 'LAUNCH_APP' as const,
|
||||
appId:this.merchantAppId,
|
||||
receiveCode: response.biz_content?.receiveCode,
|
||||
shortCode: this.merchantCode,
|
||||
}
|
||||
: { type: 'REDIRECT' as const, url: this.buildCheckoutUrl(prepayId) };
|
||||
|
||||
return {
|
||||
providerOrderId: prepayId,
|
||||
clientAction,
|
||||
expiresAt,
|
||||
rawInitiation: {
|
||||
request: this.sanitize(requestBody),
|
||||
response,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
|
||||
const fabricToken = await this.applyFabricToken();
|
||||
const requestBody = this.buildQueryOrderRequest(merchantOrderId);
|
||||
const response = await this.postJson<QueryOrderResponse>(
|
||||
`${this.baseUrl}/payment/v1/merchant/queryOrder`,
|
||||
requestBody,
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'X-APP-Key': this.fabricAppId,
|
||||
Authorization: fabricToken,
|
||||
},
|
||||
);
|
||||
|
||||
const tradeStatus = response.biz_content?.trade_status;
|
||||
const providerTxnId =
|
||||
response.biz_content?.trans_id ?? response.biz_content?.payment_order_id;
|
||||
const mapped = this.mapTradeStatus(tradeStatus);
|
||||
|
||||
return {
|
||||
status: mapped,
|
||||
providerTxnId,
|
||||
failureCode:
|
||||
mapped === PaymentIntentStatus.FAILED && tradeStatus ? tradeStatus : undefined,
|
||||
rawResponse: response as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
mapTradeStatus(tradeStatus: string | undefined): PaymentIntentStatus {
|
||||
switch (tradeStatus) {
|
||||
case 'PAY_SUCCESS':
|
||||
return PaymentIntentStatus.SUCCEEDED;
|
||||
case 'PAY_FAILED':
|
||||
case 'ORDER_CLOSED':
|
||||
return PaymentIntentStatus.FAILED;
|
||||
case 'WAIT_PAY':
|
||||
return PaymentIntentStatus.REQUIRES_ACTION;
|
||||
case 'PAYING':
|
||||
return PaymentIntentStatus.PROCESSING;
|
||||
default:
|
||||
return PaymentIntentStatus.PROCESSING;
|
||||
}
|
||||
}
|
||||
|
||||
mapWebhookTradeStatus(tradeStatus: string | undefined): PaymentIntentStatus {
|
||||
switch (tradeStatus) {
|
||||
case 'Completed':
|
||||
return PaymentIntentStatus.SUCCEEDED;
|
||||
case 'Failure':
|
||||
case 'Expired':
|
||||
return PaymentIntentStatus.FAILED;
|
||||
case 'Paying':
|
||||
case 'Pending':
|
||||
return PaymentIntentStatus.PROCESSING;
|
||||
default:
|
||||
return PaymentIntentStatus.PROCESSING;
|
||||
}
|
||||
}
|
||||
|
||||
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
|
||||
if (!this.publicKey) {
|
||||
this.logger.error('TELEBIRR_PUBLIC_KEY not configured; rejecting all webhooks');
|
||||
return false;
|
||||
}
|
||||
return verifyRequestObject(payload, this.publicKey);
|
||||
}
|
||||
|
||||
private async applyFabricToken(): Promise<string> {
|
||||
const response = await this.postJson<FabricTokenResponse>(
|
||||
`${this.baseUrl}/payment/v1/token`,
|
||||
{ appSecret: this.appSecret },
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'X-APP-Key': this.fabricAppId,
|
||||
},
|
||||
);
|
||||
if (!response?.token) {
|
||||
throw new Error(`Telebirr token request failed: ${JSON.stringify(response)}`);
|
||||
}
|
||||
return response.token;
|
||||
}
|
||||
|
||||
private async requestCreateOrder(
|
||||
fabricToken: string,
|
||||
body: CreateOrderRequest,
|
||||
): Promise<CreateOrderResponse> {
|
||||
return this.postJson<CreateOrderResponse>(
|
||||
`${this.baseUrl}/payment/v1/inapp/createOrder`,
|
||||
body,
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'X-APP-Key': this.fabricAppId,
|
||||
Authorization: fabricToken,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private buildCreateOrderRequest(input: ProviderInitiationInput): CreateOrderRequest {
|
||||
const totalAmount = String(input.amountMinor / 100);
|
||||
const req = {
|
||||
timestamp: createTimestamp(),
|
||||
nonce_str: createNonceStr(),
|
||||
method: 'payment.preorder' as const,
|
||||
version: '1.0' as const,
|
||||
biz_content: {
|
||||
notify_url: this.notifyUrl,
|
||||
appid: this.merchantAppId,
|
||||
merch_code: this.merchantCode,
|
||||
merch_order_id: input.merchantOrderId,
|
||||
trade_type: 'Checkout' as const,
|
||||
title: `EDR Booking`,
|
||||
total_amount: totalAmount,
|
||||
trans_currency: input.currency,
|
||||
timeout_express: this.timeoutExpress,
|
||||
},
|
||||
};
|
||||
const sign = signRequestObject(req as unknown as Record<string, unknown>, this.privateKey);
|
||||
return { ...req, sign, sign_type: 'SHA256WithRSA' };
|
||||
}
|
||||
|
||||
private buildQueryOrderRequest(merchantOrderId: string): Record<string, unknown> {
|
||||
const req = {
|
||||
timestamp: createTimestamp(),
|
||||
nonce_str: createNonceStr(),
|
||||
method: 'payment.queryorder',
|
||||
version: '1.0',
|
||||
biz_content: {
|
||||
appid: this.merchantAppId,
|
||||
merch_code: this.merchantCode,
|
||||
merch_order_id: merchantOrderId,
|
||||
},
|
||||
};
|
||||
const sign = signRequestObject(req as Record<string, unknown>, this.privateKey);
|
||||
return { ...req, sign, sign_type: 'SHA256WithRSA' };
|
||||
}
|
||||
|
||||
private buildCheckoutUrl(prepayId: string): string {
|
||||
const map: Record<string, string> = {
|
||||
appid: this.merchantAppId,
|
||||
merch_code: this.merchantCode,
|
||||
nonce_str: createNonceStr(),
|
||||
prepay_id: prepayId,
|
||||
timestamp: createTimestamp(),
|
||||
};
|
||||
const sign = signRequestObject(map, this.privateKey);
|
||||
const rawRequest = [
|
||||
`appid=${map.appid}`,
|
||||
`merch_code=${map.merch_code}`,
|
||||
`nonce_str=${map.nonce_str}`,
|
||||
`prepay_id=${map.prepay_id}`,
|
||||
`timestamp=${map.timestamp}`,
|
||||
'sign_type=SHA256WithRSA',
|
||||
`sign=${sign}`,
|
||||
'version=1.0',
|
||||
'trade_type=Checkout',
|
||||
].join('&');
|
||||
return `${this.webBaseUrl}${rawRequest}`;
|
||||
}
|
||||
|
||||
private computeExpiresAt(timeoutExpress: string): Date {
|
||||
const match = /^(\d+)([smhd])$/.exec(timeoutExpress);
|
||||
const minutes = match ? this.toMinutes(parseInt(match[1], 10), match[2]) : 15;
|
||||
return new Date(Date.now() + minutes * 60_000);
|
||||
}
|
||||
|
||||
private toMinutes(n: number, unit: string): number {
|
||||
switch (unit) {
|
||||
case 's': return Math.max(1, Math.round(n / 60));
|
||||
case 'm': return n;
|
||||
case 'h': return n * 60;
|
||||
case 'd': return n * 60 * 24;
|
||||
default: return 15;
|
||||
}
|
||||
}
|
||||
|
||||
private async postJson<T>(
|
||||
url: string,
|
||||
body: unknown,
|
||||
headers: Record<string, string>,
|
||||
): Promise<T> {
|
||||
const config: AxiosRequestConfig = {
|
||||
headers,
|
||||
timeout: TELEBIRR_HTTP_TIMEOUT_MS,
|
||||
httpsAgent: this.httpsAgent,
|
||||
};
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await firstValueFrom(this.http.post<T>(url, body, config));
|
||||
this.logger.debug(`Telebirr POST ${url} status=${res.status} latency=${Date.now() - started}ms`);
|
||||
return res.data;
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError) {
|
||||
this.logger.error(
|
||||
`Telebirr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)} code=${err.code} message=${err.message}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.error(`Telebirr POST ${url} threw: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private sanitize(body: CreateOrderRequest): Record<string, unknown> {
|
||||
const { sign: _sign, ...rest } = body;
|
||||
return rest;
|
||||
}
|
||||
|
||||
private get baseUrl(): string { return this.config.get<string>('telebirr.baseUrl') ?? ''; }
|
||||
private get webBaseUrl(): string { return this.config.get<string>('telebirr.webBaseUrl') ?? ''; }
|
||||
private get fabricAppId(): string { return this.config.get<string>('telebirr.fabricAppId') ?? ''; }
|
||||
private get appSecret(): string { return this.config.get<string>('telebirr.appSecret') ?? ''; }
|
||||
private get merchantAppId(): string { return this.config.get<string>('telebirr.merchantAppId') ?? ''; }
|
||||
private get merchantCode(): string { return this.config.get<string>('telebirr.merchantCode') ?? ''; }
|
||||
private get notifyUrl(): string { return this.config.get<string>('telebirr.notifyUrl') ?? ''; }
|
||||
private get timeoutExpress(): string { return this.config.get<string>('telebirr.timeoutExpress') ?? '15m'; }
|
||||
private get privateKey(): string { return this.config.get<string>('telebirr.privateKey') ?? ''; }
|
||||
private get publicKey(): string { return this.config.get<string>('telebirr.publicKey') ?? ''; }
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
export interface FabricTokenResponse {
|
||||
token: string;
|
||||
expires_in?: number | string;
|
||||
}
|
||||
|
||||
export interface CreateOrderBizContent {
|
||||
notify_url: string;
|
||||
appid: string;
|
||||
merch_code: string;
|
||||
merch_order_id: string;
|
||||
trade_type: 'Checkout' | 'InApp' | 'MiniApp';
|
||||
title: string;
|
||||
total_amount: string;
|
||||
trans_currency: string;
|
||||
timeout_express: string;
|
||||
}
|
||||
|
||||
export interface CreateOrderRequest {
|
||||
timestamp: string;
|
||||
nonce_str: string;
|
||||
method: 'payment.preorder';
|
||||
version: '1.0';
|
||||
biz_content: CreateOrderBizContent;
|
||||
sign: string;
|
||||
sign_type: 'SHA256WithRSA';
|
||||
}
|
||||
|
||||
export interface CreateOrderResponse {
|
||||
code?: string;
|
||||
msg?: string;
|
||||
biz_content?: {
|
||||
prepay_id?: string;
|
||||
receiveCode?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type TelebirrTradeStatus =
|
||||
| 'PAY_SUCCESS'
|
||||
| 'PAY_FAILED'
|
||||
| 'WAIT_PAY'
|
||||
| 'ORDER_CLOSED'
|
||||
| 'PAYING'
|
||||
| 'ACCEPTED'
|
||||
| 'REFUNDING'
|
||||
| 'REFUND_SUCCESS'
|
||||
| 'REFUND_FAILED';
|
||||
|
||||
export interface QueryOrderResponse {
|
||||
result?: 'SUCCESS' | 'FAIL';
|
||||
code?: string;
|
||||
msg?: string;
|
||||
nonce_str?: string;
|
||||
sign?: string;
|
||||
sign_type?: string;
|
||||
biz_content?: {
|
||||
merch_order_id?: string;
|
||||
order_status?: string;
|
||||
trade_status?: TelebirrTradeStatus | string;
|
||||
payment_order_id?: string;
|
||||
trans_id?: string;
|
||||
trans_time?: string;
|
||||
trans_currency?: string;
|
||||
total_amount?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
||||
import { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import {
|
||||
PaymentProvider,
|
||||
ProviderInitiationInput,
|
||||
ProviderInitiationResult,
|
||||
ProviderStatus,
|
||||
} from '../payments.types';
|
||||
|
||||
const WAAFI_HTTP_TIMEOUT_MS = 10_000;
|
||||
|
||||
interface WaafiInitiateRequest {
|
||||
schemaVersion: string;
|
||||
requestId: string;
|
||||
timestamp: string;
|
||||
channelName: string;
|
||||
serviceName: string;
|
||||
serviceParams: {
|
||||
merchantUid: string;
|
||||
apiUserId: string;
|
||||
apiKey: string;
|
||||
paymentMethod: string;
|
||||
payerInfo: {
|
||||
accountNo: string;
|
||||
};
|
||||
transactionInfo: {
|
||||
referenceId: string;
|
||||
invoiceId: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
description: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface WaafiInitiateResponse {
|
||||
responseCode: string;
|
||||
responseMsg: string;
|
||||
params?: {
|
||||
state: string;
|
||||
referenceId: string;
|
||||
transactionId: string;
|
||||
checkoutUrl?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface WaafiQueryRequest {
|
||||
schemaVersion: string;
|
||||
requestId: string;
|
||||
timestamp: string;
|
||||
channelName: string;
|
||||
serviceName: string;
|
||||
serviceParams: {
|
||||
merchantUid: string;
|
||||
apiUserId: string;
|
||||
apiKey: string;
|
||||
transactionId?: string;
|
||||
referenceId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface WaafiQueryResponse {
|
||||
responseCode: string;
|
||||
responseMsg: string;
|
||||
params?: {
|
||||
state: string;
|
||||
referenceId: string;
|
||||
transactionId: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
paidAmount?: number;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WaafiProvider implements PaymentProvider {
|
||||
readonly method = PaymentMethodType.WAAFI;
|
||||
private readonly logger = new Logger(WaafiProvider.name);
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly http: HttpService,
|
||||
) {}
|
||||
|
||||
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
|
||||
const requestBody = this.buildInitiateRequest(input);
|
||||
const response = await this.postJson<WaafiInitiateResponse>(
|
||||
`${this.baseUrl}/asm`,
|
||||
requestBody,
|
||||
);
|
||||
|
||||
if (response.responseCode !== '2001') {
|
||||
throw new Error(
|
||||
`Waafi initiate failed: ${response.responseCode} - ${response.responseMsg}`,
|
||||
);
|
||||
}
|
||||
|
||||
const transactionId = response.params?.transactionId;
|
||||
const checkoutUrl = response.params?.checkoutUrl || `${this.baseUrl}/checkout?ref=${transactionId}`;
|
||||
|
||||
if (!transactionId) {
|
||||
throw new Error(`Waafi returned no transactionId: ${JSON.stringify(response)}`);
|
||||
}
|
||||
|
||||
const expiresAt = new Date(Date.now() + 15 * 60_000); // 15 minutes
|
||||
|
||||
return {
|
||||
providerOrderId: transactionId,
|
||||
clientAction: { type: 'REDIRECT', url: checkoutUrl },
|
||||
expiresAt,
|
||||
rawInitiation: {
|
||||
request: this.sanitize(requestBody),
|
||||
response,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
|
||||
const requestBody = this.buildQueryRequest(merchantOrderId);
|
||||
const response = await this.postJson<WaafiQueryResponse>(
|
||||
`${this.baseUrl}/asm`,
|
||||
requestBody,
|
||||
);
|
||||
|
||||
const state = response.params?.state;
|
||||
const transactionId = response.params?.transactionId;
|
||||
const mapped = this.mapState(state);
|
||||
|
||||
return {
|
||||
status: mapped,
|
||||
providerTxnId: transactionId,
|
||||
failureCode: mapped === PaymentIntentStatus.FAILED && state ? state : undefined,
|
||||
rawResponse: response as unknown as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
mapState(state: string | undefined): PaymentIntentStatus {
|
||||
switch (state) {
|
||||
case 'APPROVED':
|
||||
case 'SUCCESS':
|
||||
return PaymentIntentStatus.SUCCEEDED;
|
||||
case 'FAILED':
|
||||
case 'DECLINED':
|
||||
case 'CANCELLED':
|
||||
case 'EXPIRED':
|
||||
return PaymentIntentStatus.FAILED;
|
||||
case 'PENDING':
|
||||
case 'INITIATED':
|
||||
return PaymentIntentStatus.REQUIRES_ACTION;
|
||||
case 'PROCESSING':
|
||||
return PaymentIntentStatus.PROCESSING;
|
||||
default:
|
||||
return PaymentIntentStatus.PROCESSING;
|
||||
}
|
||||
}
|
||||
|
||||
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
|
||||
// Waafi webhook signature verification
|
||||
// Implementation depends on Waafi's webhook signature mechanism
|
||||
const signature = payload.signature as string;
|
||||
const apiKey = this.apiKey;
|
||||
|
||||
if (!signature || !apiKey) {
|
||||
this.logger.error('Waafi webhook missing signature or API key not configured');
|
||||
return false;
|
||||
}
|
||||
|
||||
// TODO: Implement actual signature verification based on Waafi documentation
|
||||
// For now, basic validation
|
||||
return signature.length > 0;
|
||||
}
|
||||
|
||||
private buildInitiateRequest(input: ProviderInitiationInput): WaafiInitiateRequest {
|
||||
const amount = input.amountMinor / 100; // Convert minor units to major
|
||||
|
||||
return {
|
||||
schemaVersion: '1.0',
|
||||
requestId: this.generateRequestId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
channelName: 'WEB',
|
||||
serviceName: 'API_PURCHASE',
|
||||
serviceParams: {
|
||||
merchantUid: this.merchantUid,
|
||||
apiUserId: this.apiUserId,
|
||||
apiKey: this.apiKey,
|
||||
paymentMethod: 'MWALLET_ACCOUNT',
|
||||
payerInfo: {
|
||||
accountNo: 'CUSTOMER', // Customer enters their number on Waafi page
|
||||
},
|
||||
transactionInfo: {
|
||||
referenceId: input.merchantOrderId,
|
||||
invoiceId: input.bookingRef,
|
||||
amount,
|
||||
currency: input.currency === 'ETB' ? 'DJF' : input.currency, // Convert ETB to DJF
|
||||
description: `EDR Train Booking ${input.bookingRef}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private buildQueryRequest(merchantOrderId: string): WaafiQueryRequest {
|
||||
return {
|
||||
schemaVersion: '1.0',
|
||||
requestId: this.generateRequestId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
channelName: 'WEB',
|
||||
serviceName: 'API_QUERY',
|
||||
serviceParams: {
|
||||
merchantUid: this.merchantUid,
|
||||
apiUserId: this.apiUserId,
|
||||
apiKey: this.apiKey,
|
||||
referenceId: merchantOrderId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private generateRequestId(): string {
|
||||
return `EDR-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
|
||||
private async postJson<T>(url: string, body: unknown): Promise<T> {
|
||||
const config: AxiosRequestConfig = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: WAAFI_HTTP_TIMEOUT_MS,
|
||||
};
|
||||
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await firstValueFrom(this.http.post<T>(url, body, config));
|
||||
this.logger.debug(
|
||||
`Waafi POST ${url} status=${res.status} latency=${Date.now() - started}ms`,
|
||||
);
|
||||
return res.data;
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError) {
|
||||
this.logger.error(
|
||||
`Waafi POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)} code=${err.code} message=${err.message}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.error(
|
||||
`Waafi POST ${url} threw: ${err instanceof Error ? err.message : err}`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private sanitize(body: WaafiInitiateRequest): Record<string, unknown> {
|
||||
const sanitized = { ...body };
|
||||
if (sanitized.serviceParams?.apiKey) {
|
||||
sanitized.serviceParams.apiKey = '***REDACTED***';
|
||||
}
|
||||
return sanitized as unknown as Record<string, unknown>;
|
||||
}
|
||||
|
||||
private get baseUrl(): string {
|
||||
return this.config.get<string>('waafi.baseUrl') ?? 'https://api.waafipay.net';
|
||||
}
|
||||
private get merchantUid(): string {
|
||||
return this.config.get<string>('waafi.merchantUid') ?? '';
|
||||
}
|
||||
private get apiUserId(): string {
|
||||
return this.config.get<string>('waafi.apiUserId') ?? '';
|
||||
}
|
||||
private get apiKey(): string {
|
||||
return this.config.get<string>('waafi.apiKey') ?? '';
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,12 @@
|
||||
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';
|
||||
import { CardProvider } from '../providers/card.provider';
|
||||
|
||||
export interface CardWebhookPayload {
|
||||
id: string;
|
||||
type: string;
|
||||
data: {
|
||||
object: {
|
||||
id: string;
|
||||
status: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
metadata: {
|
||||
merchantOrderId: string;
|
||||
bookingRef: string;
|
||||
};
|
||||
transaction_id?: string;
|
||||
paid_at?: number;
|
||||
failure_code?: string;
|
||||
failure_message?: string;
|
||||
};
|
||||
};
|
||||
created: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CardWebhookService {
|
||||
@@ -76,13 +58,13 @@ export class CardWebhookService {
|
||||
const mapped = this.provider.mapWebhookStatus(payload.data.object.status);
|
||||
|
||||
try {
|
||||
if (mapped === PaymentIntentStatus.SUCCEEDED) {
|
||||
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 === PaymentIntentStatus.FAILED) {
|
||||
} else if (mapped === ProviderPaymentStatus.FAILED) {
|
||||
await this.payments.markPaymentFailed({
|
||||
intentId: intent.id,
|
||||
failureCode: payload.data.object.failure_code,
|
||||
@@ -92,7 +74,7 @@ export class CardWebhookService {
|
||||
await this.prisma.paymentIntent.update({
|
||||
where: { id: intent.id },
|
||||
data: {
|
||||
status: mapped,
|
||||
status: mapped as unknown as PaymentIntentStatus,
|
||||
providerTxnId: payload.data.object.transaction_id ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
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';
|
||||
import { CbeBirrProvider } from '../providers/cbe-birr.provider';
|
||||
|
||||
export interface CbeBirrWebhookPayload {
|
||||
merchantId: string;
|
||||
merchantOrderId: string;
|
||||
orderId: string;
|
||||
status: string;
|
||||
transactionId?: string;
|
||||
amount?: string;
|
||||
currency?: string;
|
||||
paidAt?: string;
|
||||
signature: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CbeBirrWebhookService {
|
||||
@@ -66,13 +57,13 @@ export class CbeBirrWebhookService {
|
||||
const mapped = this.provider.mapWebhookStatus(payload.status);
|
||||
|
||||
try {
|
||||
if (mapped === PaymentIntentStatus.SUCCEEDED) {
|
||||
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 === PaymentIntentStatus.FAILED) {
|
||||
} else if (mapped === ProviderPaymentStatus.FAILED) {
|
||||
await this.payments.markPaymentFailed({
|
||||
intentId: intent.id,
|
||||
failureCode: payload.status,
|
||||
@@ -80,7 +71,10 @@ export class CbeBirrWebhookService {
|
||||
} else {
|
||||
await this.prisma.paymentIntent.update({
|
||||
where: { id: intent.id },
|
||||
data: { status: mapped, providerTxnId: payload.transactionId ?? undefined },
|
||||
data: {
|
||||
status: mapped as unknown as PaymentIntentStatus,
|
||||
providerTxnId: payload.transactionId ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
await this.markProcessed(eventRow.id);
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
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';
|
||||
import { EBirrProvider } from '../providers/ebirr.provider';
|
||||
|
||||
export interface EBirrWebhookPayload {
|
||||
merchantCode: string;
|
||||
orderNo: string;
|
||||
tradeStatus: string;
|
||||
tradeNo?: string;
|
||||
totalAmount?: number;
|
||||
currency?: string;
|
||||
payTime?: number;
|
||||
timestamp: number;
|
||||
sign: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EBirrWebhookService {
|
||||
@@ -66,13 +57,13 @@ export class EBirrWebhookService {
|
||||
const mapped = this.provider.mapWebhookStatus(payload.tradeStatus);
|
||||
|
||||
try {
|
||||
if (mapped === PaymentIntentStatus.SUCCEEDED) {
|
||||
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 === PaymentIntentStatus.FAILED) {
|
||||
} else if (mapped === ProviderPaymentStatus.FAILED) {
|
||||
await this.payments.markPaymentFailed({
|
||||
intentId: intent.id,
|
||||
failureCode: payload.tradeStatus,
|
||||
@@ -80,7 +71,10 @@ export class EBirrWebhookService {
|
||||
} else {
|
||||
await this.prisma.paymentIntent.update({
|
||||
where: { id: intent.id },
|
||||
data: { status: mapped, providerTxnId: payload.tradeNo ?? undefined },
|
||||
data: {
|
||||
status: mapped as unknown as PaymentIntentStatus,
|
||||
providerTxnId: payload.tradeNo ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
await this.markProcessed(eventRow.id);
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
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';
|
||||
import { TelebirrProvider } from '../providers/telebirr.provider';
|
||||
|
||||
export interface TelebirrWebhookPayload {
|
||||
merch_order_id: string;
|
||||
payment_order_id: string;
|
||||
trade_status: string;
|
||||
trans_id?: string;
|
||||
total_amount?: string;
|
||||
trans_currency?: string;
|
||||
notify_time?: string;
|
||||
trans_end_time?: string;
|
||||
sign: string;
|
||||
sign_type?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TelebirrWebhookService {
|
||||
@@ -76,13 +66,13 @@ export class TelebirrWebhookService {
|
||||
const mapped = this.provider.mapWebhookTradeStatus(payload.trade_status);
|
||||
|
||||
try {
|
||||
if (mapped === PaymentIntentStatus.SUCCEEDED) {
|
||||
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 === PaymentIntentStatus.FAILED) {
|
||||
} else if (mapped === ProviderPaymentStatus.FAILED) {
|
||||
await this.payments.markPaymentFailed({
|
||||
intentId: intent.id,
|
||||
failureCode: payload.trade_status,
|
||||
@@ -90,7 +80,10 @@ export class TelebirrWebhookService {
|
||||
} else {
|
||||
await this.prisma.paymentIntent.update({
|
||||
where: { id: intent.id },
|
||||
data: { status: mapped, providerTxnId: payload.trans_id ?? undefined },
|
||||
data: {
|
||||
status: mapped as unknown as PaymentIntentStatus,
|
||||
providerTxnId: payload.trans_id ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
await this.markProcessed(eventRow.id);
|
||||
|
||||
@@ -1,24 +1,12 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
WaafiProvider,
|
||||
WaafiWebhookPayload,
|
||||
ProviderPaymentStatus,
|
||||
} from '@edr/payment-providers';
|
||||
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
||||
import { PrismaService } from '../../../common/prisma.service';
|
||||
import { PaymentsService } from '../payments.service';
|
||||
import { WaafiProvider } from '../providers/waafi.provider';
|
||||
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
||||
|
||||
interface WaafiWebhookPayload {
|
||||
schemaVersion: string;
|
||||
requestId: string;
|
||||
timestamp: string;
|
||||
eventType: string;
|
||||
params: {
|
||||
state: string;
|
||||
referenceId: string;
|
||||
transactionId: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
description?: string;
|
||||
};
|
||||
signature?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WaafiWebhookService {
|
||||
@@ -76,13 +64,13 @@ export class WaafiWebhookService {
|
||||
|
||||
const mappedStatus = this.waafiProvider.mapState(state);
|
||||
|
||||
if (mappedStatus === PaymentIntentStatus.SUCCEEDED) {
|
||||
if (mappedStatus === ProviderPaymentStatus.SUCCEEDED) {
|
||||
await this.paymentsService.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
providerTxnId: transactionId,
|
||||
});
|
||||
this.logger.log(`Waafi payment succeeded: intent=${intent.id} txn=${transactionId}`);
|
||||
} else if (mappedStatus === PaymentIntentStatus.FAILED) {
|
||||
} else if (mappedStatus === ProviderPaymentStatus.FAILED) {
|
||||
await this.paymentsService.markPaymentFailed({
|
||||
intentId: intent.id,
|
||||
failureCode: state,
|
||||
@@ -93,7 +81,7 @@ export class WaafiWebhookService {
|
||||
await this.prisma.paymentIntent.update({
|
||||
where: { id: intent.id },
|
||||
data: {
|
||||
status: mappedStatus,
|
||||
status: mappedStatus as unknown as PaymentIntentStatus,
|
||||
providerTxnId: transactionId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,20 +2,14 @@ import {All, Body, Controller, Headers, HttpCode, HttpStatus, Logger, Post} from
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
TelebirrWebhookPayload,
|
||||
TelebirrWebhookService,
|
||||
} from './telebirr-webhook.service';
|
||||
import {
|
||||
CbeBirrWebhookPayload,
|
||||
CbeBirrWebhookService,
|
||||
} from './cbe-birr-webhook.service';
|
||||
import {
|
||||
EBirrWebhookPayload,
|
||||
EBirrWebhookService,
|
||||
} from './ebirr-webhook.service';
|
||||
import {
|
||||
CardWebhookPayload,
|
||||
CardWebhookService,
|
||||
} from './card-webhook.service';
|
||||
} 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')
|
||||
|
||||
Reference in New Issue
Block a user