mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +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,9 +0,0 @@
|
||||
export type {
|
||||
PaymentProvider,
|
||||
ProviderInitiationInput,
|
||||
ProviderInitiationResult,
|
||||
ProviderStatus,
|
||||
ClientAction,
|
||||
} from '../payments.types';
|
||||
|
||||
export const PAYMENT_PROVIDERS = Symbol('PAYMENT_PROVIDERS');
|
||||
@@ -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')
|
||||
|
||||
42
packages/payment-providers/package.json
Normal file
42
packages/payment-providers/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@edr/payment-providers",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"dev": "tsc -w -p tsconfig.json",
|
||||
"type-check": "tsc --noEmit",
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/axios": "^4.0.0",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/config": "^4.0.0",
|
||||
"axios": "^1.7.0",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edr/types": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edr/eslint-config": "workspace:*",
|
||||
"@edr/tsconfig": "workspace:*",
|
||||
"@nestjs/axios": "^4.0.0",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/config": "^4.0.0",
|
||||
"@types/node": "^20.14.0",
|
||||
"axios": "^1.7.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
}
|
||||
50
packages/payment-providers/src/index.ts
Normal file
50
packages/payment-providers/src/index.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// Re-export the shared, ORM-agnostic payment contract from @edr/types so consumers can
|
||||
// import enums, interfaces, and providers from a single entry point.
|
||||
export {
|
||||
ProviderPaymentStatus,
|
||||
ProviderMethod,
|
||||
} from '@edr/types';
|
||||
export type {
|
||||
PaymentProvider,
|
||||
ProviderInitiationInput,
|
||||
ProviderInitiationResult,
|
||||
ProviderStatus,
|
||||
ClientAction,
|
||||
PaymentPlatform,
|
||||
} from '@edr/types';
|
||||
|
||||
// Providers
|
||||
export { TelebirrProvider } from './providers/telebirr/telebirr.provider';
|
||||
export { CbeBirrProvider } from './providers/cbe-birr/cbe-birr.provider';
|
||||
export { EBirrProvider } from './providers/ebirr/ebirr.provider';
|
||||
export { CardProvider } from './providers/card/card.provider';
|
||||
export { WaafiProvider } from './providers/waafi/waafi.provider';
|
||||
|
||||
// Telebirr crypto + types (exported for apps that build/verify signatures directly)
|
||||
export {
|
||||
buildCanonicalString,
|
||||
signRequestObject,
|
||||
verifyRequestObject,
|
||||
signString,
|
||||
verifySignature,
|
||||
createTimestamp,
|
||||
createNonceStr,
|
||||
createMerchantOrderId,
|
||||
} from './providers/telebirr/telebirr.crypto';
|
||||
export type {
|
||||
FabricTokenResponse,
|
||||
CreateOrderRequest,
|
||||
CreateOrderResponse,
|
||||
QueryOrderResponse,
|
||||
TelebirrTradeStatus,
|
||||
} from './providers/telebirr/telebirr.types';
|
||||
|
||||
// Webhook payload types
|
||||
export type { TelebirrWebhookPayload } from './webhooks/telebirr-webhook.types';
|
||||
export type { CbeBirrWebhookPayload } from './webhooks/cbe-birr-webhook.types';
|
||||
export type { EBirrWebhookPayload } from './webhooks/ebirr-webhook.types';
|
||||
export type { CardWebhookPayload } from './webhooks/card-webhook.types';
|
||||
export type { WaafiWebhookPayload } from './webhooks/waafi-webhook.types';
|
||||
|
||||
// DI token for injecting all providers as an array (future multi-provider wiring)
|
||||
export const PAYMENT_PROVIDERS = Symbol('PAYMENT_PROVIDERS');
|
||||
@@ -1,16 +1,17 @@
|
||||
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';
|
||||
ProviderPaymentStatus,
|
||||
ProviderMethod,
|
||||
} from '@edr/types';
|
||||
import { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import * as crypto from 'node:crypto';
|
||||
|
||||
interface CardInitiateRequest {
|
||||
amount: number;
|
||||
@@ -18,7 +19,7 @@ interface CardInitiateRequest {
|
||||
description: string;
|
||||
metadata: {
|
||||
merchantOrderId: string;
|
||||
bookingRef: string;
|
||||
orderRef: string;
|
||||
};
|
||||
return_url: string;
|
||||
webhook_url: string;
|
||||
@@ -45,7 +46,7 @@ interface CardQueryResponse {
|
||||
|
||||
@Injectable()
|
||||
export class CardProvider implements PaymentProvider {
|
||||
readonly method = PaymentMethodType.CARD;
|
||||
readonly method = ProviderMethod.CARD;
|
||||
private readonly logger = new Logger(CardProvider.name);
|
||||
|
||||
constructor(
|
||||
@@ -59,10 +60,10 @@ export class CardProvider implements PaymentProvider {
|
||||
const requestBody: CardInitiateRequest = {
|
||||
amount,
|
||||
currency: input.currency,
|
||||
description: `EDR Train Booking ${input.bookingRef}`,
|
||||
description: `EDR ${input.orderRef}`,
|
||||
metadata: {
|
||||
merchantOrderId: input.merchantOrderId,
|
||||
bookingRef: input.bookingRef,
|
||||
orderRef: input.orderRef,
|
||||
},
|
||||
return_url: this.returnUrl,
|
||||
webhook_url: this.webhookUrl,
|
||||
@@ -125,27 +126,27 @@ export class CardProvider implements PaymentProvider {
|
||||
}
|
||||
}
|
||||
|
||||
mapWebhookStatus(status: string): PaymentIntentStatus {
|
||||
mapWebhookStatus(status: string): ProviderPaymentStatus {
|
||||
return this.mapStatus(status);
|
||||
}
|
||||
|
||||
private mapStatus(status: string): PaymentIntentStatus {
|
||||
private mapStatus(status: string): ProviderPaymentStatus {
|
||||
switch (status?.toLowerCase()) {
|
||||
case 'succeeded':
|
||||
case 'paid':
|
||||
return PaymentIntentStatus.SUCCEEDED;
|
||||
return ProviderPaymentStatus.SUCCEEDED;
|
||||
case 'failed':
|
||||
case 'canceled':
|
||||
case 'expired':
|
||||
return PaymentIntentStatus.FAILED;
|
||||
return ProviderPaymentStatus.FAILED;
|
||||
case 'requires_payment_method':
|
||||
case 'requires_confirmation':
|
||||
case 'requires_action':
|
||||
return PaymentIntentStatus.REQUIRES_ACTION;
|
||||
return ProviderPaymentStatus.REQUIRES_ACTION;
|
||||
case 'processing':
|
||||
return PaymentIntentStatus.PROCESSING;
|
||||
return ProviderPaymentStatus.PROCESSING;
|
||||
default:
|
||||
return PaymentIntentStatus.PROCESSING;
|
||||
return ProviderPaymentStatus.PROCESSING;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
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';
|
||||
ProviderPaymentStatus,
|
||||
ProviderMethod,
|
||||
} from '@edr/types';
|
||||
import { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import * as crypto from 'node:crypto';
|
||||
|
||||
interface CbeBirrInitiateRequest {
|
||||
merchantId: string;
|
||||
@@ -42,7 +43,7 @@ interface CbeBirrQueryResponse {
|
||||
|
||||
@Injectable()
|
||||
export class CbeBirrProvider implements PaymentProvider {
|
||||
readonly method = PaymentMethodType.CBE_BIRR;
|
||||
readonly method = ProviderMethod.CBE_BIRR;
|
||||
private readonly logger = new Logger(CbeBirrProvider.name);
|
||||
|
||||
constructor(
|
||||
@@ -59,7 +60,7 @@ export class CbeBirrProvider implements PaymentProvider {
|
||||
merchantOrderId: input.merchantOrderId,
|
||||
amount,
|
||||
currency: input.currency,
|
||||
description: `EDR Booking ${input.bookingRef}`,
|
||||
description: `EDR ${input.orderRef}`,
|
||||
returnUrl: this.returnUrl,
|
||||
notifyUrl: this.notifyUrl,
|
||||
timestamp,
|
||||
@@ -116,7 +117,7 @@ export class CbeBirrProvider implements PaymentProvider {
|
||||
return {
|
||||
status: mapped,
|
||||
providerTxnId: response.transactionId,
|
||||
failureCode: mapped === PaymentIntentStatus.FAILED ? response.status : undefined,
|
||||
failureCode: mapped === ProviderPaymentStatus.FAILED ? response.status : undefined,
|
||||
rawResponse: response as unknown as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
@@ -132,25 +133,25 @@ export class CbeBirrProvider implements PaymentProvider {
|
||||
);
|
||||
}
|
||||
|
||||
mapWebhookStatus(status: string): PaymentIntentStatus {
|
||||
mapWebhookStatus(status: string): ProviderPaymentStatus {
|
||||
return this.mapStatus(status);
|
||||
}
|
||||
|
||||
private mapStatus(status: string): PaymentIntentStatus {
|
||||
private mapStatus(status: string): ProviderPaymentStatus {
|
||||
switch (status?.toUpperCase()) {
|
||||
case 'SUCCESS':
|
||||
case 'COMPLETED':
|
||||
return PaymentIntentStatus.SUCCEEDED;
|
||||
return ProviderPaymentStatus.SUCCEEDED;
|
||||
case 'FAILED':
|
||||
case 'REJECTED':
|
||||
case 'EXPIRED':
|
||||
return PaymentIntentStatus.FAILED;
|
||||
return ProviderPaymentStatus.FAILED;
|
||||
case 'PENDING':
|
||||
return PaymentIntentStatus.REQUIRES_ACTION;
|
||||
return ProviderPaymentStatus.REQUIRES_ACTION;
|
||||
case 'PROCESSING':
|
||||
return PaymentIntentStatus.PROCESSING;
|
||||
return ProviderPaymentStatus.PROCESSING;
|
||||
default:
|
||||
return PaymentIntentStatus.PROCESSING;
|
||||
return ProviderPaymentStatus.PROCESSING;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
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';
|
||||
ProviderPaymentStatus,
|
||||
ProviderMethod,
|
||||
} from '@edr/types';
|
||||
import { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import * as crypto from 'node:crypto';
|
||||
|
||||
interface EBirrInitiateRequest {
|
||||
merchantCode: string;
|
||||
@@ -49,7 +50,7 @@ interface EBirrQueryResponse {
|
||||
|
||||
@Injectable()
|
||||
export class EBirrProvider implements PaymentProvider {
|
||||
readonly method = PaymentMethodType.EBIRR;
|
||||
readonly method = ProviderMethod.EBIRR;
|
||||
private readonly logger = new Logger(EBirrProvider.name);
|
||||
|
||||
constructor(
|
||||
@@ -67,7 +68,7 @@ export class EBirrProvider implements PaymentProvider {
|
||||
amount,
|
||||
currency: input.currency,
|
||||
subject: `EDR Ticket`,
|
||||
body: `Train booking ${input.bookingRef}`,
|
||||
body: `Order ${input.orderRef}`,
|
||||
notifyUrl: this.notifyUrl,
|
||||
returnUrl: this.returnUrl,
|
||||
timestamp,
|
||||
@@ -128,7 +129,7 @@ export class EBirrProvider implements PaymentProvider {
|
||||
return {
|
||||
status: mapped,
|
||||
providerTxnId: response.data.tradeNo,
|
||||
failureCode: mapped === PaymentIntentStatus.FAILED ? response.data.tradeStatus : undefined,
|
||||
failureCode: mapped === ProviderPaymentStatus.FAILED ? response.data.tradeStatus : undefined,
|
||||
rawResponse: response as unknown as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
@@ -144,26 +145,26 @@ export class EBirrProvider implements PaymentProvider {
|
||||
);
|
||||
}
|
||||
|
||||
mapWebhookStatus(tradeStatus: string): PaymentIntentStatus {
|
||||
mapWebhookStatus(tradeStatus: string): ProviderPaymentStatus {
|
||||
return this.mapStatus(tradeStatus);
|
||||
}
|
||||
|
||||
private mapStatus(tradeStatus: string): PaymentIntentStatus {
|
||||
private mapStatus(tradeStatus: string): ProviderPaymentStatus {
|
||||
switch (tradeStatus?.toUpperCase()) {
|
||||
case 'TRADE_SUCCESS':
|
||||
case 'SUCCESS':
|
||||
return PaymentIntentStatus.SUCCEEDED;
|
||||
return ProviderPaymentStatus.SUCCEEDED;
|
||||
case 'TRADE_CLOSED':
|
||||
case 'TRADE_FAILED':
|
||||
case 'FAILED':
|
||||
return PaymentIntentStatus.FAILED;
|
||||
return ProviderPaymentStatus.FAILED;
|
||||
case 'WAIT_BUYER_PAY':
|
||||
case 'PENDING':
|
||||
return PaymentIntentStatus.REQUIRES_ACTION;
|
||||
return ProviderPaymentStatus.REQUIRES_ACTION;
|
||||
case 'PROCESSING':
|
||||
return PaymentIntentStatus.PROCESSING;
|
||||
return ProviderPaymentStatus.PROCESSING;
|
||||
default:
|
||||
return PaymentIntentStatus.PROCESSING;
|
||||
return ProviderPaymentStatus.PROCESSING;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
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';
|
||||
ProviderPaymentStatus,
|
||||
ProviderMethod,
|
||||
} from '@edr/types';
|
||||
import { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import * as https from 'node:https';
|
||||
import {
|
||||
createNonceStr,
|
||||
createTimestamp,
|
||||
@@ -28,7 +29,7 @@ const TELEBIRR_HTTP_TIMEOUT_MS = 10_000;
|
||||
|
||||
@Injectable()
|
||||
export class TelebirrProvider implements PaymentProvider {
|
||||
readonly method = PaymentMethodType.TELEBIRR;
|
||||
readonly method = ProviderMethod.TELEBIRR;
|
||||
private readonly logger = new Logger(TelebirrProvider.name);
|
||||
private readonly httpsAgent: https.Agent;
|
||||
|
||||
@@ -64,7 +65,7 @@ export class TelebirrProvider implements PaymentProvider {
|
||||
platform === 'mobile'
|
||||
? {
|
||||
type: 'LAUNCH_APP' as const,
|
||||
appId:this.merchantAppId,
|
||||
appId: this.merchantAppId,
|
||||
receiveCode: response.biz_content?.receiveCode,
|
||||
shortCode: this.merchantCode,
|
||||
}
|
||||
@@ -103,39 +104,39 @@ export class TelebirrProvider implements PaymentProvider {
|
||||
status: mapped,
|
||||
providerTxnId,
|
||||
failureCode:
|
||||
mapped === PaymentIntentStatus.FAILED && tradeStatus ? tradeStatus : undefined,
|
||||
mapped === ProviderPaymentStatus.FAILED && tradeStatus ? tradeStatus : undefined,
|
||||
rawResponse: response as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
mapTradeStatus(tradeStatus: string | undefined): PaymentIntentStatus {
|
||||
mapTradeStatus(tradeStatus: string | undefined): ProviderPaymentStatus {
|
||||
switch (tradeStatus) {
|
||||
case 'PAY_SUCCESS':
|
||||
return PaymentIntentStatus.SUCCEEDED;
|
||||
return ProviderPaymentStatus.SUCCEEDED;
|
||||
case 'PAY_FAILED':
|
||||
case 'ORDER_CLOSED':
|
||||
return PaymentIntentStatus.FAILED;
|
||||
return ProviderPaymentStatus.FAILED;
|
||||
case 'WAIT_PAY':
|
||||
return PaymentIntentStatus.REQUIRES_ACTION;
|
||||
return ProviderPaymentStatus.REQUIRES_ACTION;
|
||||
case 'PAYING':
|
||||
return PaymentIntentStatus.PROCESSING;
|
||||
return ProviderPaymentStatus.PROCESSING;
|
||||
default:
|
||||
return PaymentIntentStatus.PROCESSING;
|
||||
return ProviderPaymentStatus.PROCESSING;
|
||||
}
|
||||
}
|
||||
|
||||
mapWebhookTradeStatus(tradeStatus: string | undefined): PaymentIntentStatus {
|
||||
mapWebhookTradeStatus(tradeStatus: string | undefined): ProviderPaymentStatus {
|
||||
switch (tradeStatus) {
|
||||
case 'Completed':
|
||||
return PaymentIntentStatus.SUCCEEDED;
|
||||
return ProviderPaymentStatus.SUCCEEDED;
|
||||
case 'Failure':
|
||||
case 'Expired':
|
||||
return PaymentIntentStatus.FAILED;
|
||||
return ProviderPaymentStatus.FAILED;
|
||||
case 'Paying':
|
||||
case 'Pending':
|
||||
return PaymentIntentStatus.PROCESSING;
|
||||
return ProviderPaymentStatus.PROCESSING;
|
||||
default:
|
||||
return PaymentIntentStatus.PROCESSING;
|
||||
return ProviderPaymentStatus.PROCESSING;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +191,7 @@ export class TelebirrProvider implements PaymentProvider {
|
||||
merch_code: this.merchantCode,
|
||||
merch_order_id: input.merchantOrderId,
|
||||
trade_type: 'Checkout' as const,
|
||||
title: `EDR Booking`,
|
||||
title: `EDR ${input.orderRef}`,
|
||||
total_amount: totalAmount,
|
||||
trans_currency: input.currency,
|
||||
timeout_express: this.timeoutExpress,
|
||||
@@ -1,15 +1,16 @@
|
||||
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';
|
||||
ProviderPaymentStatus,
|
||||
ProviderMethod,
|
||||
} from '@edr/types';
|
||||
import { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
const WAAFI_HTTP_TIMEOUT_MS = 10_000;
|
||||
|
||||
@@ -78,7 +79,7 @@ interface WaafiQueryResponse {
|
||||
|
||||
@Injectable()
|
||||
export class WaafiProvider implements PaymentProvider {
|
||||
readonly method = PaymentMethodType.WAAFI;
|
||||
readonly method = ProviderMethod.WAAFI;
|
||||
private readonly logger = new Logger(WaafiProvider.name);
|
||||
|
||||
constructor(
|
||||
@@ -133,28 +134,28 @@ export class WaafiProvider implements PaymentProvider {
|
||||
return {
|
||||
status: mapped,
|
||||
providerTxnId: transactionId,
|
||||
failureCode: mapped === PaymentIntentStatus.FAILED && state ? state : undefined,
|
||||
failureCode: mapped === ProviderPaymentStatus.FAILED && state ? state : undefined,
|
||||
rawResponse: response as unknown as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
mapState(state: string | undefined): PaymentIntentStatus {
|
||||
mapState(state: string | undefined): ProviderPaymentStatus {
|
||||
switch (state) {
|
||||
case 'APPROVED':
|
||||
case 'SUCCESS':
|
||||
return PaymentIntentStatus.SUCCEEDED;
|
||||
return ProviderPaymentStatus.SUCCEEDED;
|
||||
case 'FAILED':
|
||||
case 'DECLINED':
|
||||
case 'CANCELLED':
|
||||
case 'EXPIRED':
|
||||
return PaymentIntentStatus.FAILED;
|
||||
return ProviderPaymentStatus.FAILED;
|
||||
case 'PENDING':
|
||||
case 'INITIATED':
|
||||
return PaymentIntentStatus.REQUIRES_ACTION;
|
||||
return ProviderPaymentStatus.REQUIRES_ACTION;
|
||||
case 'PROCESSING':
|
||||
return PaymentIntentStatus.PROCESSING;
|
||||
return ProviderPaymentStatus.PROCESSING;
|
||||
default:
|
||||
return PaymentIntentStatus.PROCESSING;
|
||||
return ProviderPaymentStatus.PROCESSING;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,10 +194,10 @@ export class WaafiProvider implements PaymentProvider {
|
||||
},
|
||||
transactionInfo: {
|
||||
referenceId: input.merchantOrderId,
|
||||
invoiceId: input.bookingRef,
|
||||
invoiceId: input.orderRef,
|
||||
amount,
|
||||
currency: input.currency === 'ETB' ? 'DJF' : input.currency, // Convert ETB to DJF
|
||||
description: `EDR Train Booking ${input.bookingRef}`,
|
||||
description: `EDR ${input.orderRef}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { createMerchantOrderId } from '../providers/telebirr/telebirr.crypto';
|
||||
@@ -0,0 +1,22 @@
|
||||
export interface CardWebhookPayload {
|
||||
id: string;
|
||||
type: string;
|
||||
data: {
|
||||
object: {
|
||||
id: string;
|
||||
status: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
metadata: {
|
||||
merchantOrderId: string;
|
||||
orderRef?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
transaction_id?: string;
|
||||
paid_at?: number;
|
||||
failure_code?: string;
|
||||
failure_message?: string;
|
||||
};
|
||||
};
|
||||
created: number;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface CbeBirrWebhookPayload {
|
||||
merchantId: string;
|
||||
merchantOrderId: string;
|
||||
orderId: string;
|
||||
status: string;
|
||||
transactionId?: string;
|
||||
amount?: string;
|
||||
currency?: string;
|
||||
paidAt?: string;
|
||||
signature: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface EBirrWebhookPayload {
|
||||
merchantCode: string;
|
||||
orderNo: string;
|
||||
tradeStatus: string;
|
||||
tradeNo?: string;
|
||||
totalAmount?: number;
|
||||
currency?: string;
|
||||
payTime?: number;
|
||||
timestamp: number;
|
||||
sign: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export interface WaafiWebhookPayload {
|
||||
schemaVersion: string;
|
||||
requestId: string;
|
||||
timestamp: string;
|
||||
eventType: string;
|
||||
params: {
|
||||
state: string;
|
||||
referenceId: string;
|
||||
transactionId: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
description?: string;
|
||||
};
|
||||
signature?: string;
|
||||
}
|
||||
10
packages/payment-providers/tsconfig.json
Normal file
10
packages/payment-providers/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@edr/tsconfig/nestjs.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"declarationMap": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from "./payments";
|
||||
|
||||
export interface BaseEntity {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
|
||||
66
packages/types/src/common/payments.ts
Normal file
66
packages/types/src/common/payments.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Shared, ORM-agnostic payment provider contract.
|
||||
*
|
||||
* These types are consumed by the @edr/payment-providers gateway package and by each
|
||||
* app's payment module. The enums intentionally mirror the string values of every app's
|
||||
* ORM-generated payment enums (Prisma `PaymentIntentStatus`/`PaymentMethodType` in the
|
||||
* passenger API, the TypeORM column enum in the freight API), so mapping between an app's
|
||||
* ORM enum and these shared enums is a one-line cast rather than a translation table.
|
||||
*/
|
||||
|
||||
export enum ProviderPaymentStatus {
|
||||
REQUIRES_ACTION = "REQUIRES_ACTION",
|
||||
PROCESSING = "PROCESSING",
|
||||
SUCCEEDED = "SUCCEEDED",
|
||||
FAILED = "FAILED",
|
||||
CANCELLED = "CANCELLED",
|
||||
}
|
||||
|
||||
export enum ProviderMethod {
|
||||
TELEBIRR = "TELEBIRR",
|
||||
CBE_BIRR = "CBE_BIRR",
|
||||
EBIRR = "EBIRR",
|
||||
WAAFI = "WAAFI",
|
||||
CARD = "CARD",
|
||||
}
|
||||
|
||||
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;
|
||||
/** Domain-neutral order reference (booking reference, shipment order reference, etc.). */
|
||||
orderRef: string;
|
||||
amountMinor: number;
|
||||
currency: string;
|
||||
platform?: PaymentPlatform;
|
||||
}
|
||||
|
||||
export interface ProviderInitiationResult {
|
||||
providerOrderId: string;
|
||||
clientAction: ClientAction;
|
||||
expiresAt: Date;
|
||||
rawInitiation: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ProviderStatus {
|
||||
status: ProviderPaymentStatus;
|
||||
providerTxnId?: string;
|
||||
failureCode?: string;
|
||||
failureMessage?: string;
|
||||
rawResponse: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PaymentProvider {
|
||||
readonly method: ProviderMethod;
|
||||
initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult>;
|
||||
queryStatus(merchantOrderId: string): Promise<ProviderStatus>;
|
||||
}
|
||||
55
pnpm-lock.yaml
generated
55
pnpm-lock.yaml
generated
@@ -35,9 +35,15 @@ importers:
|
||||
'@edr/api-common':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/api-common
|
||||
'@edr/payment-providers':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/payment-providers
|
||||
'@edr/types':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/types
|
||||
'@nestjs/axios':
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.1(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.16.1)(rxjs@7.8.2)
|
||||
'@nestjs/common':
|
||||
specifier: ^11.0.0
|
||||
version: 11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
@@ -47,6 +53,9 @@ importers:
|
||||
'@nestjs/core':
|
||||
specifier: ^11.0.0
|
||||
version: 11.1.23(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.23)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/event-emitter':
|
||||
specifier: ^2.0.4
|
||||
version: 2.1.1(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.23)
|
||||
'@nestjs/microservices':
|
||||
specifier: ^11.0.0
|
||||
version: 11.1.24(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.23)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
@@ -65,6 +74,9 @@ importers:
|
||||
'@tria-plc/iamapi-common':
|
||||
specifier: ^0.1.0
|
||||
version: 0.1.6(08624e896246f4d3d786f63e867db9c1)
|
||||
axios:
|
||||
specifier: ^1.7.7
|
||||
version: 1.16.1
|
||||
class-transformer:
|
||||
specifier: ^0.5.1
|
||||
version: 0.5.1
|
||||
@@ -135,6 +147,12 @@ importers:
|
||||
|
||||
apps/edr-passenger-api:
|
||||
dependencies:
|
||||
'@edr/payment-providers':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/payment-providers
|
||||
'@edr/types':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/types
|
||||
'@nestjs/axios':
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.16.1)(rxjs@7.8.2)
|
||||
@@ -474,6 +492,43 @@ importers:
|
||||
|
||||
packages/config/tsconfig: {}
|
||||
|
||||
packages/payment-providers:
|
||||
dependencies:
|
||||
'@edr/types':
|
||||
specifier: workspace:*
|
||||
version: link:../types
|
||||
devDependencies:
|
||||
'@edr/eslint-config':
|
||||
specifier: workspace:*
|
||||
version: link:../config/eslint-config
|
||||
'@edr/tsconfig':
|
||||
specifier: workspace:*
|
||||
version: link:../config/tsconfig
|
||||
'@nestjs/axios':
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.1(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.16.1)(rxjs@7.8.2)
|
||||
'@nestjs/common':
|
||||
specifier: ^11.0.0
|
||||
version: 11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/config':
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.4(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)
|
||||
'@types/node':
|
||||
specifier: ^20.14.0
|
||||
version: 20.19.41
|
||||
axios:
|
||||
specifier: ^1.7.0
|
||||
version: 1.16.1
|
||||
reflect-metadata:
|
||||
specifier: ^0.2.2
|
||||
version: 0.2.2
|
||||
rxjs:
|
||||
specifier: ^7.8.1
|
||||
version: 7.8.2
|
||||
typescript:
|
||||
specifier: ^5.5.4
|
||||
version: 5.9.3
|
||||
|
||||
packages/types:
|
||||
devDependencies:
|
||||
'@edr/eslint-config':
|
||||
|
||||
Reference in New Issue
Block a user