mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 08:48:11 +00:00
feat: ( payment ) create payment microservice
This commit is contained in:
@@ -5,3 +5,17 @@ DB_PORT=5433
|
|||||||
DB_USER=postgres
|
DB_USER=postgres
|
||||||
DB_PASSWORD=
|
DB_PASSWORD=
|
||||||
DB_NAME=edr_freight
|
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/api-common": "^0.1.0",
|
||||||
"@tria-plc/iamapi-common": "^0.1.0",
|
"@tria-plc/iamapi-common": "^0.1.0",
|
||||||
"@edr/api-common": "workspace:*",
|
"@edr/api-common": "workspace:*",
|
||||||
|
"@edr/payment-providers": "workspace:*",
|
||||||
"@edr/types": "workspace:*",
|
"@edr/types": "workspace:*",
|
||||||
|
"@nestjs/axios": "^4.0.0",
|
||||||
"@nestjs/common": "^11.0.0",
|
"@nestjs/common": "^11.0.0",
|
||||||
"@nestjs/config": "^4.0.0",
|
"@nestjs/config": "^4.0.0",
|
||||||
"@nestjs/core": "^11.0.0",
|
"@nestjs/core": "^11.0.0",
|
||||||
|
"@nestjs/event-emitter": "^2.0.4",
|
||||||
"@nestjs/microservices": "^11.0.0",
|
"@nestjs/microservices": "^11.0.0",
|
||||||
"@nestjs/platform-express": "^11.0.0",
|
"@nestjs/platform-express": "^11.0.0",
|
||||||
"@nestjs/swagger": "^11.4.2",
|
"@nestjs/swagger": "^11.4.2",
|
||||||
"@nestjs/typeorm": "^11.0.1",
|
"@nestjs/typeorm": "^11.0.1",
|
||||||
|
"axios": "^1.7.7",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
"pg": "^8.13.0",
|
"pg": "^8.13.0",
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||||
|
import { EventEmitterModule } from "@nestjs/event-emitter";
|
||||||
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
|
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
|
||||||
|
|
||||||
import appConfig from "./config/app.config";
|
import appConfig from "./config/app.config";
|
||||||
import databaseConfig from "./config/database.config";
|
import databaseConfig from "./config/database.config";
|
||||||
|
import telebirrConfig from "./config/telebirr.config";
|
||||||
|
|
||||||
import { BookingsModule } from "./modules/bookings/bookings.module";
|
import { BookingsModule } from "./modules/bookings/bookings.module";
|
||||||
import { ConsignmentsModule } from "./modules/consignments/consignments.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 { TrackingModule } from "./modules/tracking/tracking.module";
|
||||||
import { BillingModule } from "./modules/billing/billing.module";
|
import { BillingModule } from "./modules/billing/billing.module";
|
||||||
import { NotificationsModule } from "./modules/notifications/notifications.module";
|
import { NotificationsModule } from "./modules/notifications/notifications.module";
|
||||||
|
import { PaymentsModule } from "./modules/payments/payments.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
load: [appConfig, databaseConfig],
|
load: [appConfig, databaseConfig, telebirrConfig],
|
||||||
}),
|
}),
|
||||||
|
EventEmitterModule.forRoot(),
|
||||||
TypeOrmModule.forRootAsync({
|
TypeOrmModule.forRootAsync({
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
|
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
|
||||||
@@ -31,6 +35,7 @@ import { NotificationsModule } from "./modules/notifications/notifications.modul
|
|||||||
TrackingModule,
|
TrackingModule,
|
||||||
BillingModule,
|
BillingModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
|
PaymentsModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
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"
|
"seed": "ts-node prisma/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@edr/payment-providers": "workspace:*",
|
||||||
|
"@edr/types": "workspace:*",
|
||||||
"@nestjs/axios": "^4.0.1",
|
"@nestjs/axios": "^4.0.1",
|
||||||
"@nestjs/common": "^11.0.0",
|
"@nestjs/common": "^11.0.0",
|
||||||
"@nestjs/config": "^4.0.4",
|
"@nestjs/config": "^4.0.4",
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import { PaymentsController } from './payments.controller';
|
|||||||
import { PaymentsService } from './payments.service';
|
import { PaymentsService } from './payments.service';
|
||||||
import { SeatsModule } from '../seats/seats.module';
|
import { SeatsModule } from '../seats/seats.module';
|
||||||
import { TicketsModule } from '../tickets/tickets.module';
|
import { TicketsModule } from '../tickets/tickets.module';
|
||||||
import { TelebirrProvider } from './providers/telebirr.provider';
|
import {
|
||||||
import { CbeBirrProvider } from './providers/cbe-birr.provider';
|
TelebirrProvider,
|
||||||
import { EBirrProvider } from './providers/ebirr.provider';
|
CbeBirrProvider,
|
||||||
import { CardProvider } from './providers/card.provider';
|
EBirrProvider,
|
||||||
import { WaafiProvider } from './providers/waafi.provider';
|
CardProvider,
|
||||||
|
WaafiProvider,
|
||||||
|
} from '@edr/payment-providers';
|
||||||
import { WebhooksController } from './webhooks/webhooks.controller';
|
import { WebhooksController } from './webhooks/webhooks.controller';
|
||||||
import { TelebirrWebhookService } from './webhooks/telebirr-webhook.service';
|
import { TelebirrWebhookService } from './webhooks/telebirr-webhook.service';
|
||||||
import { CbeBirrWebhookService } from './webhooks/cbe-birr-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 { SeatsService } from '../seats/seats.service';
|
||||||
import { TicketsService } from '../tickets/tickets.service';
|
import { TicketsService } from '../tickets/tickets.service';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { TelebirrProvider } from './providers/telebirr.provider';
|
import {
|
||||||
import { CbeBirrProvider } from './providers/cbe-birr.provider';
|
TelebirrProvider,
|
||||||
import { EBirrProvider } from './providers/ebirr.provider';
|
CbeBirrProvider,
|
||||||
import { CardProvider } from './providers/card.provider';
|
EBirrProvider,
|
||||||
|
CardProvider,
|
||||||
|
} from '@edr/payment-providers';
|
||||||
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
||||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,18 @@ import { TicketsService } from '../tickets/tickets.service';
|
|||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { Prisma, PaymentIntentStatus, PaymentMethodType, PaymentRegion } from '@prisma/client';
|
import { Prisma, PaymentIntentStatus, PaymentMethodType, PaymentRegion } from '@prisma/client';
|
||||||
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, InitiateResponseDto, IntentStatusDto, PaymentRegionEnum } from './payments.dto';
|
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, InitiateResponseDto, IntentStatusDto, PaymentRegionEnum } from './payments.dto';
|
||||||
import { ClientAction, PaymentProvider, ProviderStatus } from './payments.types';
|
import {
|
||||||
import { TelebirrProvider } from './providers/telebirr.provider';
|
ClientAction,
|
||||||
import { CbeBirrProvider } from './providers/cbe-birr.provider';
|
PaymentProvider,
|
||||||
import { EBirrProvider } from './providers/ebirr.provider';
|
ProviderStatus,
|
||||||
import { CardProvider } from './providers/card.provider';
|
ProviderPaymentStatus,
|
||||||
import { WaafiProvider } from './providers/waafi.provider';
|
TelebirrProvider,
|
||||||
import { createMerchantOrderId } from './providers/telebirr.crypto';
|
CbeBirrProvider,
|
||||||
|
EBirrProvider,
|
||||||
|
CardProvider,
|
||||||
|
WaafiProvider,
|
||||||
|
createMerchantOrderId,
|
||||||
|
} from '@edr/payment-providers';
|
||||||
|
|
||||||
const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [
|
const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [
|
||||||
PaymentIntentStatus.REQUIRES_ACTION,
|
PaymentIntentStatus.REQUIRES_ACTION,
|
||||||
@@ -147,17 +152,18 @@ export class PaymentsService {
|
|||||||
const merchantOrderId = createMerchantOrderId();
|
const merchantOrderId = createMerchantOrderId();
|
||||||
const result = await provider.initiate({
|
const result = await provider.initiate({
|
||||||
merchantOrderId,
|
merchantOrderId,
|
||||||
bookingRef: booking.bookingRef,
|
orderRef: booking.bookingRef,
|
||||||
amountMinor: booking.totalMinor,
|
amountMinor: booking.totalMinor,
|
||||||
currency: booking.currency,
|
currency: booking.currency,
|
||||||
platform,
|
platform,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const providerMethod = provider.method as unknown as PaymentMethodType;
|
||||||
const intent = await this.prisma.paymentIntent.upsert({
|
const intent = await this.prisma.paymentIntent.upsert({
|
||||||
where: { bookingId: booking.id },
|
where: { bookingId: booking.id },
|
||||||
update: {
|
update: {
|
||||||
status: PaymentIntentStatus.REQUIRES_ACTION,
|
status: PaymentIntentStatus.REQUIRES_ACTION,
|
||||||
method: provider.method,
|
method: providerMethod,
|
||||||
merchantOrderId,
|
merchantOrderId,
|
||||||
providerOrderId: result.providerOrderId,
|
providerOrderId: result.providerOrderId,
|
||||||
clientAction: result.clientAction as unknown as Prisma.InputJsonValue,
|
clientAction: result.clientAction as unknown as Prisma.InputJsonValue,
|
||||||
@@ -170,7 +176,7 @@ export class PaymentsService {
|
|||||||
bookingId: booking.id,
|
bookingId: booking.id,
|
||||||
amountMinor: booking.totalMinor,
|
amountMinor: booking.totalMinor,
|
||||||
currency: booking.currency,
|
currency: booking.currency,
|
||||||
method: provider.method,
|
method: providerMethod,
|
||||||
status: PaymentIntentStatus.REQUIRES_ACTION,
|
status: PaymentIntentStatus.REQUIRES_ACTION,
|
||||||
merchantOrderId,
|
merchantOrderId,
|
||||||
providerOrderId: result.providerOrderId,
|
providerOrderId: result.providerOrderId,
|
||||||
@@ -235,14 +241,16 @@ export class PaymentsService {
|
|||||||
intentId: string,
|
intentId: string,
|
||||||
status: ProviderStatus,
|
status: ProviderStatus,
|
||||||
): Promise<void> {
|
): 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({
|
await this.finalizePaymentSuccess({
|
||||||
intentId,
|
intentId,
|
||||||
providerTxnId: status.providerTxnId,
|
providerTxnId: status.providerTxnId,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (status.status === PaymentIntentStatus.FAILED) {
|
if (status.status === ProviderPaymentStatus.FAILED) {
|
||||||
await this.markPaymentFailed({
|
await this.markPaymentFailed({
|
||||||
intentId,
|
intentId,
|
||||||
failureCode: status.failureCode,
|
failureCode: status.failureCode,
|
||||||
@@ -253,7 +261,7 @@ export class PaymentsService {
|
|||||||
await this.prisma.paymentIntent.update({
|
await this.prisma.paymentIntent.update({
|
||||||
where: { id: intentId },
|
where: { id: intentId },
|
||||||
data: {
|
data: {
|
||||||
status: status.status,
|
status: status.status as unknown as PaymentIntentStatus,
|
||||||
providerTxnId: status.providerTxnId ?? undefined,
|
providerTxnId: status.providerTxnId ?? undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,36 +1,11 @@
|
|||||||
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
// 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 PaymentPlatform = 'web' | 'mobile';
|
export type {
|
||||||
|
PaymentProvider,
|
||||||
export type ClientAction =
|
ProviderInitiationInput,
|
||||||
| { type: 'REDIRECT'; url: string }
|
ProviderInitiationResult,
|
||||||
| { type: 'LAUNCH_APP'; appId: string; receiveCode?: string; shortCode: string };
|
ProviderStatus,
|
||||||
|
ClientAction,
|
||||||
export interface ProviderInitiationInput {
|
PaymentPlatform,
|
||||||
merchantOrderId: string;
|
} from '@edr/types';
|
||||||
bookingRef: string;
|
export { ProviderPaymentStatus, ProviderMethod } from '@edr/types';
|
||||||
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>;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
CardProvider,
|
||||||
|
CardWebhookPayload,
|
||||||
|
ProviderPaymentStatus,
|
||||||
|
} from '@edr/payment-providers';
|
||||||
import { PrismaService } from '../../../common/prisma.service';
|
import { PrismaService } from '../../../common/prisma.service';
|
||||||
import { PaymentsService } from '../payments.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()
|
@Injectable()
|
||||||
export class CardWebhookService {
|
export class CardWebhookService {
|
||||||
@@ -76,13 +58,13 @@ export class CardWebhookService {
|
|||||||
const mapped = this.provider.mapWebhookStatus(payload.data.object.status);
|
const mapped = this.provider.mapWebhookStatus(payload.data.object.status);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (mapped === PaymentIntentStatus.SUCCEEDED) {
|
if (mapped === ProviderPaymentStatus.SUCCEEDED) {
|
||||||
await this.payments.finalizePaymentSuccess({
|
await this.payments.finalizePaymentSuccess({
|
||||||
intentId: intent.id,
|
intentId: intent.id,
|
||||||
providerTxnId: payload.data.object.transaction_id,
|
providerTxnId: payload.data.object.transaction_id,
|
||||||
paidAt: payload.data.object.paid_at ? new Date(payload.data.object.paid_at * 1000) : undefined,
|
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({
|
await this.payments.markPaymentFailed({
|
||||||
intentId: intent.id,
|
intentId: intent.id,
|
||||||
failureCode: payload.data.object.failure_code,
|
failureCode: payload.data.object.failure_code,
|
||||||
@@ -92,7 +74,7 @@ export class CardWebhookService {
|
|||||||
await this.prisma.paymentIntent.update({
|
await this.prisma.paymentIntent.update({
|
||||||
where: { id: intent.id },
|
where: { id: intent.id },
|
||||||
data: {
|
data: {
|
||||||
status: mapped,
|
status: mapped as unknown as PaymentIntentStatus,
|
||||||
providerTxnId: payload.data.object.transaction_id ?? undefined,
|
providerTxnId: payload.data.object.transaction_id ?? undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,21 +1,12 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
CbeBirrProvider,
|
||||||
|
CbeBirrWebhookPayload,
|
||||||
|
ProviderPaymentStatus,
|
||||||
|
} from '@edr/payment-providers';
|
||||||
import { PrismaService } from '../../../common/prisma.service';
|
import { PrismaService } from '../../../common/prisma.service';
|
||||||
import { PaymentsService } from '../payments.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()
|
@Injectable()
|
||||||
export class CbeBirrWebhookService {
|
export class CbeBirrWebhookService {
|
||||||
@@ -66,13 +57,13 @@ export class CbeBirrWebhookService {
|
|||||||
const mapped = this.provider.mapWebhookStatus(payload.status);
|
const mapped = this.provider.mapWebhookStatus(payload.status);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (mapped === PaymentIntentStatus.SUCCEEDED) {
|
if (mapped === ProviderPaymentStatus.SUCCEEDED) {
|
||||||
await this.payments.finalizePaymentSuccess({
|
await this.payments.finalizePaymentSuccess({
|
||||||
intentId: intent.id,
|
intentId: intent.id,
|
||||||
providerTxnId: payload.transactionId ?? payload.orderId,
|
providerTxnId: payload.transactionId ?? payload.orderId,
|
||||||
paidAt: payload.paidAt ? new Date(payload.paidAt) : undefined,
|
paidAt: payload.paidAt ? new Date(payload.paidAt) : undefined,
|
||||||
});
|
});
|
||||||
} else if (mapped === PaymentIntentStatus.FAILED) {
|
} else if (mapped === ProviderPaymentStatus.FAILED) {
|
||||||
await this.payments.markPaymentFailed({
|
await this.payments.markPaymentFailed({
|
||||||
intentId: intent.id,
|
intentId: intent.id,
|
||||||
failureCode: payload.status,
|
failureCode: payload.status,
|
||||||
@@ -80,7 +71,10 @@ export class CbeBirrWebhookService {
|
|||||||
} else {
|
} else {
|
||||||
await this.prisma.paymentIntent.update({
|
await this.prisma.paymentIntent.update({
|
||||||
where: { id: intent.id },
|
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);
|
await this.markProcessed(eventRow.id);
|
||||||
|
|||||||
@@ -1,21 +1,12 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
EBirrProvider,
|
||||||
|
EBirrWebhookPayload,
|
||||||
|
ProviderPaymentStatus,
|
||||||
|
} from '@edr/payment-providers';
|
||||||
import { PrismaService } from '../../../common/prisma.service';
|
import { PrismaService } from '../../../common/prisma.service';
|
||||||
import { PaymentsService } from '../payments.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()
|
@Injectable()
|
||||||
export class EBirrWebhookService {
|
export class EBirrWebhookService {
|
||||||
@@ -66,13 +57,13 @@ export class EBirrWebhookService {
|
|||||||
const mapped = this.provider.mapWebhookStatus(payload.tradeStatus);
|
const mapped = this.provider.mapWebhookStatus(payload.tradeStatus);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (mapped === PaymentIntentStatus.SUCCEEDED) {
|
if (mapped === ProviderPaymentStatus.SUCCEEDED) {
|
||||||
await this.payments.finalizePaymentSuccess({
|
await this.payments.finalizePaymentSuccess({
|
||||||
intentId: intent.id,
|
intentId: intent.id,
|
||||||
providerTxnId: payload.tradeNo,
|
providerTxnId: payload.tradeNo,
|
||||||
paidAt: payload.payTime ? new Date(payload.payTime) : undefined,
|
paidAt: payload.payTime ? new Date(payload.payTime) : undefined,
|
||||||
});
|
});
|
||||||
} else if (mapped === PaymentIntentStatus.FAILED) {
|
} else if (mapped === ProviderPaymentStatus.FAILED) {
|
||||||
await this.payments.markPaymentFailed({
|
await this.payments.markPaymentFailed({
|
||||||
intentId: intent.id,
|
intentId: intent.id,
|
||||||
failureCode: payload.tradeStatus,
|
failureCode: payload.tradeStatus,
|
||||||
@@ -80,7 +71,10 @@ export class EBirrWebhookService {
|
|||||||
} else {
|
} else {
|
||||||
await this.prisma.paymentIntent.update({
|
await this.prisma.paymentIntent.update({
|
||||||
where: { id: intent.id },
|
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);
|
await this.markProcessed(eventRow.id);
|
||||||
|
|||||||
@@ -1,22 +1,12 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
TelebirrProvider,
|
||||||
|
TelebirrWebhookPayload,
|
||||||
|
ProviderPaymentStatus,
|
||||||
|
} from '@edr/payment-providers';
|
||||||
import { PrismaService } from '../../../common/prisma.service';
|
import { PrismaService } from '../../../common/prisma.service';
|
||||||
import { PaymentsService } from '../payments.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()
|
@Injectable()
|
||||||
export class TelebirrWebhookService {
|
export class TelebirrWebhookService {
|
||||||
@@ -76,13 +66,13 @@ export class TelebirrWebhookService {
|
|||||||
const mapped = this.provider.mapWebhookTradeStatus(payload.trade_status);
|
const mapped = this.provider.mapWebhookTradeStatus(payload.trade_status);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (mapped === PaymentIntentStatus.SUCCEEDED) {
|
if (mapped === ProviderPaymentStatus.SUCCEEDED) {
|
||||||
await this.payments.finalizePaymentSuccess({
|
await this.payments.finalizePaymentSuccess({
|
||||||
intentId: intent.id,
|
intentId: intent.id,
|
||||||
providerTxnId: payload.trans_id ?? payload.payment_order_id,
|
providerTxnId: payload.trans_id ?? payload.payment_order_id,
|
||||||
paidAt: this.parseEpochSeconds(payload.trans_end_time),
|
paidAt: this.parseEpochSeconds(payload.trans_end_time),
|
||||||
});
|
});
|
||||||
} else if (mapped === PaymentIntentStatus.FAILED) {
|
} else if (mapped === ProviderPaymentStatus.FAILED) {
|
||||||
await this.payments.markPaymentFailed({
|
await this.payments.markPaymentFailed({
|
||||||
intentId: intent.id,
|
intentId: intent.id,
|
||||||
failureCode: payload.trade_status,
|
failureCode: payload.trade_status,
|
||||||
@@ -90,7 +80,10 @@ export class TelebirrWebhookService {
|
|||||||
} else {
|
} else {
|
||||||
await this.prisma.paymentIntent.update({
|
await this.prisma.paymentIntent.update({
|
||||||
where: { id: intent.id },
|
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);
|
await this.markProcessed(eventRow.id);
|
||||||
|
|||||||
@@ -1,24 +1,12 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
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 { PrismaService } from '../../../common/prisma.service';
|
||||||
import { PaymentsService } from '../payments.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()
|
@Injectable()
|
||||||
export class WaafiWebhookService {
|
export class WaafiWebhookService {
|
||||||
@@ -76,13 +64,13 @@ export class WaafiWebhookService {
|
|||||||
|
|
||||||
const mappedStatus = this.waafiProvider.mapState(state);
|
const mappedStatus = this.waafiProvider.mapState(state);
|
||||||
|
|
||||||
if (mappedStatus === PaymentIntentStatus.SUCCEEDED) {
|
if (mappedStatus === ProviderPaymentStatus.SUCCEEDED) {
|
||||||
await this.paymentsService.finalizePaymentSuccess({
|
await this.paymentsService.finalizePaymentSuccess({
|
||||||
intentId: intent.id,
|
intentId: intent.id,
|
||||||
providerTxnId: transactionId,
|
providerTxnId: transactionId,
|
||||||
});
|
});
|
||||||
this.logger.log(`Waafi payment succeeded: intent=${intent.id} txn=${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({
|
await this.paymentsService.markPaymentFailed({
|
||||||
intentId: intent.id,
|
intentId: intent.id,
|
||||||
failureCode: state,
|
failureCode: state,
|
||||||
@@ -93,7 +81,7 @@ export class WaafiWebhookService {
|
|||||||
await this.prisma.paymentIntent.update({
|
await this.prisma.paymentIntent.update({
|
||||||
where: { id: intent.id },
|
where: { id: intent.id },
|
||||||
data: {
|
data: {
|
||||||
status: mappedStatus,
|
status: mappedStatus as unknown as PaymentIntentStatus,
|
||||||
providerTxnId: transactionId,
|
providerTxnId: transactionId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,20 +2,14 @@ import {All, Body, Controller, Headers, HttpCode, HttpStatus, Logger, Post} from
|
|||||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import {
|
import {
|
||||||
TelebirrWebhookPayload,
|
TelebirrWebhookPayload,
|
||||||
TelebirrWebhookService,
|
|
||||||
} from './telebirr-webhook.service';
|
|
||||||
import {
|
|
||||||
CbeBirrWebhookPayload,
|
CbeBirrWebhookPayload,
|
||||||
CbeBirrWebhookService,
|
|
||||||
} from './cbe-birr-webhook.service';
|
|
||||||
import {
|
|
||||||
EBirrWebhookPayload,
|
EBirrWebhookPayload,
|
||||||
EBirrWebhookService,
|
|
||||||
} from './ebirr-webhook.service';
|
|
||||||
import {
|
|
||||||
CardWebhookPayload,
|
CardWebhookPayload,
|
||||||
CardWebhookService,
|
} from '@edr/payment-providers';
|
||||||
} from './card-webhook.service';
|
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';
|
import { WaafiWebhookService } from './waafi-webhook.service';
|
||||||
|
|
||||||
@ApiTags('Payment Webhooks')
|
@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 { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { HttpService } from '@nestjs/axios';
|
import { HttpService } from '@nestjs/axios';
|
||||||
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
|
||||||
import { AxiosError, AxiosRequestConfig } from 'axios';
|
|
||||||
import { firstValueFrom } from 'rxjs';
|
|
||||||
import * as crypto from 'node:crypto';
|
|
||||||
import {
|
import {
|
||||||
PaymentProvider,
|
PaymentProvider,
|
||||||
ProviderInitiationInput,
|
ProviderInitiationInput,
|
||||||
ProviderInitiationResult,
|
ProviderInitiationResult,
|
||||||
ProviderStatus,
|
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 {
|
interface CardInitiateRequest {
|
||||||
amount: number;
|
amount: number;
|
||||||
@@ -18,7 +19,7 @@ interface CardInitiateRequest {
|
|||||||
description: string;
|
description: string;
|
||||||
metadata: {
|
metadata: {
|
||||||
merchantOrderId: string;
|
merchantOrderId: string;
|
||||||
bookingRef: string;
|
orderRef: string;
|
||||||
};
|
};
|
||||||
return_url: string;
|
return_url: string;
|
||||||
webhook_url: string;
|
webhook_url: string;
|
||||||
@@ -45,7 +46,7 @@ interface CardQueryResponse {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CardProvider implements PaymentProvider {
|
export class CardProvider implements PaymentProvider {
|
||||||
readonly method = PaymentMethodType.CARD;
|
readonly method = ProviderMethod.CARD;
|
||||||
private readonly logger = new Logger(CardProvider.name);
|
private readonly logger = new Logger(CardProvider.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -59,10 +60,10 @@ export class CardProvider implements PaymentProvider {
|
|||||||
const requestBody: CardInitiateRequest = {
|
const requestBody: CardInitiateRequest = {
|
||||||
amount,
|
amount,
|
||||||
currency: input.currency,
|
currency: input.currency,
|
||||||
description: `EDR Train Booking ${input.bookingRef}`,
|
description: `EDR ${input.orderRef}`,
|
||||||
metadata: {
|
metadata: {
|
||||||
merchantOrderId: input.merchantOrderId,
|
merchantOrderId: input.merchantOrderId,
|
||||||
bookingRef: input.bookingRef,
|
orderRef: input.orderRef,
|
||||||
},
|
},
|
||||||
return_url: this.returnUrl,
|
return_url: this.returnUrl,
|
||||||
webhook_url: this.webhookUrl,
|
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);
|
return this.mapStatus(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapStatus(status: string): PaymentIntentStatus {
|
private mapStatus(status: string): ProviderPaymentStatus {
|
||||||
switch (status?.toLowerCase()) {
|
switch (status?.toLowerCase()) {
|
||||||
case 'succeeded':
|
case 'succeeded':
|
||||||
case 'paid':
|
case 'paid':
|
||||||
return PaymentIntentStatus.SUCCEEDED;
|
return ProviderPaymentStatus.SUCCEEDED;
|
||||||
case 'failed':
|
case 'failed':
|
||||||
case 'canceled':
|
case 'canceled':
|
||||||
case 'expired':
|
case 'expired':
|
||||||
return PaymentIntentStatus.FAILED;
|
return ProviderPaymentStatus.FAILED;
|
||||||
case 'requires_payment_method':
|
case 'requires_payment_method':
|
||||||
case 'requires_confirmation':
|
case 'requires_confirmation':
|
||||||
case 'requires_action':
|
case 'requires_action':
|
||||||
return PaymentIntentStatus.REQUIRES_ACTION;
|
return ProviderPaymentStatus.REQUIRES_ACTION;
|
||||||
case 'processing':
|
case 'processing':
|
||||||
return PaymentIntentStatus.PROCESSING;
|
return ProviderPaymentStatus.PROCESSING;
|
||||||
default:
|
default:
|
||||||
return PaymentIntentStatus.PROCESSING;
|
return ProviderPaymentStatus.PROCESSING;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { HttpService } from '@nestjs/axios';
|
import { HttpService } from '@nestjs/axios';
|
||||||
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
|
||||||
import { AxiosError, AxiosRequestConfig } from 'axios';
|
|
||||||
import { firstValueFrom } from 'rxjs';
|
|
||||||
import * as crypto from 'node:crypto';
|
|
||||||
import {
|
import {
|
||||||
PaymentProvider,
|
PaymentProvider,
|
||||||
ProviderInitiationInput,
|
ProviderInitiationInput,
|
||||||
ProviderInitiationResult,
|
ProviderInitiationResult,
|
||||||
ProviderStatus,
|
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 {
|
interface CbeBirrInitiateRequest {
|
||||||
merchantId: string;
|
merchantId: string;
|
||||||
@@ -42,7 +43,7 @@ interface CbeBirrQueryResponse {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CbeBirrProvider implements PaymentProvider {
|
export class CbeBirrProvider implements PaymentProvider {
|
||||||
readonly method = PaymentMethodType.CBE_BIRR;
|
readonly method = ProviderMethod.CBE_BIRR;
|
||||||
private readonly logger = new Logger(CbeBirrProvider.name);
|
private readonly logger = new Logger(CbeBirrProvider.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -59,7 +60,7 @@ export class CbeBirrProvider implements PaymentProvider {
|
|||||||
merchantOrderId: input.merchantOrderId,
|
merchantOrderId: input.merchantOrderId,
|
||||||
amount,
|
amount,
|
||||||
currency: input.currency,
|
currency: input.currency,
|
||||||
description: `EDR Booking ${input.bookingRef}`,
|
description: `EDR ${input.orderRef}`,
|
||||||
returnUrl: this.returnUrl,
|
returnUrl: this.returnUrl,
|
||||||
notifyUrl: this.notifyUrl,
|
notifyUrl: this.notifyUrl,
|
||||||
timestamp,
|
timestamp,
|
||||||
@@ -116,7 +117,7 @@ export class CbeBirrProvider implements PaymentProvider {
|
|||||||
return {
|
return {
|
||||||
status: mapped,
|
status: mapped,
|
||||||
providerTxnId: response.transactionId,
|
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>,
|
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);
|
return this.mapStatus(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapStatus(status: string): PaymentIntentStatus {
|
private mapStatus(status: string): ProviderPaymentStatus {
|
||||||
switch (status?.toUpperCase()) {
|
switch (status?.toUpperCase()) {
|
||||||
case 'SUCCESS':
|
case 'SUCCESS':
|
||||||
case 'COMPLETED':
|
case 'COMPLETED':
|
||||||
return PaymentIntentStatus.SUCCEEDED;
|
return ProviderPaymentStatus.SUCCEEDED;
|
||||||
case 'FAILED':
|
case 'FAILED':
|
||||||
case 'REJECTED':
|
case 'REJECTED':
|
||||||
case 'EXPIRED':
|
case 'EXPIRED':
|
||||||
return PaymentIntentStatus.FAILED;
|
return ProviderPaymentStatus.FAILED;
|
||||||
case 'PENDING':
|
case 'PENDING':
|
||||||
return PaymentIntentStatus.REQUIRES_ACTION;
|
return ProviderPaymentStatus.REQUIRES_ACTION;
|
||||||
case 'PROCESSING':
|
case 'PROCESSING':
|
||||||
return PaymentIntentStatus.PROCESSING;
|
return ProviderPaymentStatus.PROCESSING;
|
||||||
default:
|
default:
|
||||||
return PaymentIntentStatus.PROCESSING;
|
return ProviderPaymentStatus.PROCESSING;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { HttpService } from '@nestjs/axios';
|
import { HttpService } from '@nestjs/axios';
|
||||||
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
|
||||||
import { AxiosError, AxiosRequestConfig } from 'axios';
|
|
||||||
import { firstValueFrom } from 'rxjs';
|
|
||||||
import * as crypto from 'node:crypto';
|
|
||||||
import {
|
import {
|
||||||
PaymentProvider,
|
PaymentProvider,
|
||||||
ProviderInitiationInput,
|
ProviderInitiationInput,
|
||||||
ProviderInitiationResult,
|
ProviderInitiationResult,
|
||||||
ProviderStatus,
|
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 {
|
interface EBirrInitiateRequest {
|
||||||
merchantCode: string;
|
merchantCode: string;
|
||||||
@@ -49,7 +50,7 @@ interface EBirrQueryResponse {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EBirrProvider implements PaymentProvider {
|
export class EBirrProvider implements PaymentProvider {
|
||||||
readonly method = PaymentMethodType.EBIRR;
|
readonly method = ProviderMethod.EBIRR;
|
||||||
private readonly logger = new Logger(EBirrProvider.name);
|
private readonly logger = new Logger(EBirrProvider.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -67,7 +68,7 @@ export class EBirrProvider implements PaymentProvider {
|
|||||||
amount,
|
amount,
|
||||||
currency: input.currency,
|
currency: input.currency,
|
||||||
subject: `EDR Ticket`,
|
subject: `EDR Ticket`,
|
||||||
body: `Train booking ${input.bookingRef}`,
|
body: `Order ${input.orderRef}`,
|
||||||
notifyUrl: this.notifyUrl,
|
notifyUrl: this.notifyUrl,
|
||||||
returnUrl: this.returnUrl,
|
returnUrl: this.returnUrl,
|
||||||
timestamp,
|
timestamp,
|
||||||
@@ -128,7 +129,7 @@ export class EBirrProvider implements PaymentProvider {
|
|||||||
return {
|
return {
|
||||||
status: mapped,
|
status: mapped,
|
||||||
providerTxnId: response.data.tradeNo,
|
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>,
|
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);
|
return this.mapStatus(tradeStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapStatus(tradeStatus: string): PaymentIntentStatus {
|
private mapStatus(tradeStatus: string): ProviderPaymentStatus {
|
||||||
switch (tradeStatus?.toUpperCase()) {
|
switch (tradeStatus?.toUpperCase()) {
|
||||||
case 'TRADE_SUCCESS':
|
case 'TRADE_SUCCESS':
|
||||||
case 'SUCCESS':
|
case 'SUCCESS':
|
||||||
return PaymentIntentStatus.SUCCEEDED;
|
return ProviderPaymentStatus.SUCCEEDED;
|
||||||
case 'TRADE_CLOSED':
|
case 'TRADE_CLOSED':
|
||||||
case 'TRADE_FAILED':
|
case 'TRADE_FAILED':
|
||||||
case 'FAILED':
|
case 'FAILED':
|
||||||
return PaymentIntentStatus.FAILED;
|
return ProviderPaymentStatus.FAILED;
|
||||||
case 'WAIT_BUYER_PAY':
|
case 'WAIT_BUYER_PAY':
|
||||||
case 'PENDING':
|
case 'PENDING':
|
||||||
return PaymentIntentStatus.REQUIRES_ACTION;
|
return ProviderPaymentStatus.REQUIRES_ACTION;
|
||||||
case 'PROCESSING':
|
case 'PROCESSING':
|
||||||
return PaymentIntentStatus.PROCESSING;
|
return ProviderPaymentStatus.PROCESSING;
|
||||||
default:
|
default:
|
||||||
return PaymentIntentStatus.PROCESSING;
|
return ProviderPaymentStatus.PROCESSING;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { HttpService } from '@nestjs/axios';
|
import { HttpService } from '@nestjs/axios';
|
||||||
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
|
||||||
import { AxiosError, AxiosRequestConfig } from 'axios';
|
|
||||||
import { firstValueFrom } from 'rxjs';
|
|
||||||
import * as https from 'node:https';
|
|
||||||
import {
|
import {
|
||||||
PaymentProvider,
|
PaymentProvider,
|
||||||
ProviderInitiationInput,
|
ProviderInitiationInput,
|
||||||
ProviderInitiationResult,
|
ProviderInitiationResult,
|
||||||
ProviderStatus,
|
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 {
|
import {
|
||||||
createNonceStr,
|
createNonceStr,
|
||||||
createTimestamp,
|
createTimestamp,
|
||||||
@@ -28,7 +29,7 @@ const TELEBIRR_HTTP_TIMEOUT_MS = 10_000;
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TelebirrProvider implements PaymentProvider {
|
export class TelebirrProvider implements PaymentProvider {
|
||||||
readonly method = PaymentMethodType.TELEBIRR;
|
readonly method = ProviderMethod.TELEBIRR;
|
||||||
private readonly logger = new Logger(TelebirrProvider.name);
|
private readonly logger = new Logger(TelebirrProvider.name);
|
||||||
private readonly httpsAgent: https.Agent;
|
private readonly httpsAgent: https.Agent;
|
||||||
|
|
||||||
@@ -64,7 +65,7 @@ export class TelebirrProvider implements PaymentProvider {
|
|||||||
platform === 'mobile'
|
platform === 'mobile'
|
||||||
? {
|
? {
|
||||||
type: 'LAUNCH_APP' as const,
|
type: 'LAUNCH_APP' as const,
|
||||||
appId:this.merchantAppId,
|
appId: this.merchantAppId,
|
||||||
receiveCode: response.biz_content?.receiveCode,
|
receiveCode: response.biz_content?.receiveCode,
|
||||||
shortCode: this.merchantCode,
|
shortCode: this.merchantCode,
|
||||||
}
|
}
|
||||||
@@ -103,39 +104,39 @@ export class TelebirrProvider implements PaymentProvider {
|
|||||||
status: mapped,
|
status: mapped,
|
||||||
providerTxnId,
|
providerTxnId,
|
||||||
failureCode:
|
failureCode:
|
||||||
mapped === PaymentIntentStatus.FAILED && tradeStatus ? tradeStatus : undefined,
|
mapped === ProviderPaymentStatus.FAILED && tradeStatus ? tradeStatus : undefined,
|
||||||
rawResponse: response as Record<string, unknown>,
|
rawResponse: response as Record<string, unknown>,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
mapTradeStatus(tradeStatus: string | undefined): PaymentIntentStatus {
|
mapTradeStatus(tradeStatus: string | undefined): ProviderPaymentStatus {
|
||||||
switch (tradeStatus) {
|
switch (tradeStatus) {
|
||||||
case 'PAY_SUCCESS':
|
case 'PAY_SUCCESS':
|
||||||
return PaymentIntentStatus.SUCCEEDED;
|
return ProviderPaymentStatus.SUCCEEDED;
|
||||||
case 'PAY_FAILED':
|
case 'PAY_FAILED':
|
||||||
case 'ORDER_CLOSED':
|
case 'ORDER_CLOSED':
|
||||||
return PaymentIntentStatus.FAILED;
|
return ProviderPaymentStatus.FAILED;
|
||||||
case 'WAIT_PAY':
|
case 'WAIT_PAY':
|
||||||
return PaymentIntentStatus.REQUIRES_ACTION;
|
return ProviderPaymentStatus.REQUIRES_ACTION;
|
||||||
case 'PAYING':
|
case 'PAYING':
|
||||||
return PaymentIntentStatus.PROCESSING;
|
return ProviderPaymentStatus.PROCESSING;
|
||||||
default:
|
default:
|
||||||
return PaymentIntentStatus.PROCESSING;
|
return ProviderPaymentStatus.PROCESSING;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mapWebhookTradeStatus(tradeStatus: string | undefined): PaymentIntentStatus {
|
mapWebhookTradeStatus(tradeStatus: string | undefined): ProviderPaymentStatus {
|
||||||
switch (tradeStatus) {
|
switch (tradeStatus) {
|
||||||
case 'Completed':
|
case 'Completed':
|
||||||
return PaymentIntentStatus.SUCCEEDED;
|
return ProviderPaymentStatus.SUCCEEDED;
|
||||||
case 'Failure':
|
case 'Failure':
|
||||||
case 'Expired':
|
case 'Expired':
|
||||||
return PaymentIntentStatus.FAILED;
|
return ProviderPaymentStatus.FAILED;
|
||||||
case 'Paying':
|
case 'Paying':
|
||||||
case 'Pending':
|
case 'Pending':
|
||||||
return PaymentIntentStatus.PROCESSING;
|
return ProviderPaymentStatus.PROCESSING;
|
||||||
default:
|
default:
|
||||||
return PaymentIntentStatus.PROCESSING;
|
return ProviderPaymentStatus.PROCESSING;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +191,7 @@ export class TelebirrProvider implements PaymentProvider {
|
|||||||
merch_code: this.merchantCode,
|
merch_code: this.merchantCode,
|
||||||
merch_order_id: input.merchantOrderId,
|
merch_order_id: input.merchantOrderId,
|
||||||
trade_type: 'Checkout' as const,
|
trade_type: 'Checkout' as const,
|
||||||
title: `EDR Booking`,
|
title: `EDR ${input.orderRef}`,
|
||||||
total_amount: totalAmount,
|
total_amount: totalAmount,
|
||||||
trans_currency: input.currency,
|
trans_currency: input.currency,
|
||||||
timeout_express: this.timeoutExpress,
|
timeout_express: this.timeoutExpress,
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { HttpService } from '@nestjs/axios';
|
import { HttpService } from '@nestjs/axios';
|
||||||
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
|
||||||
import { AxiosError, AxiosRequestConfig } from 'axios';
|
|
||||||
import { firstValueFrom } from 'rxjs';
|
|
||||||
import {
|
import {
|
||||||
PaymentProvider,
|
PaymentProvider,
|
||||||
ProviderInitiationInput,
|
ProviderInitiationInput,
|
||||||
ProviderInitiationResult,
|
ProviderInitiationResult,
|
||||||
ProviderStatus,
|
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;
|
const WAAFI_HTTP_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
@@ -78,7 +79,7 @@ interface WaafiQueryResponse {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WaafiProvider implements PaymentProvider {
|
export class WaafiProvider implements PaymentProvider {
|
||||||
readonly method = PaymentMethodType.WAAFI;
|
readonly method = ProviderMethod.WAAFI;
|
||||||
private readonly logger = new Logger(WaafiProvider.name);
|
private readonly logger = new Logger(WaafiProvider.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -133,28 +134,28 @@ export class WaafiProvider implements PaymentProvider {
|
|||||||
return {
|
return {
|
||||||
status: mapped,
|
status: mapped,
|
||||||
providerTxnId: transactionId,
|
providerTxnId: transactionId,
|
||||||
failureCode: mapped === PaymentIntentStatus.FAILED && state ? state : undefined,
|
failureCode: mapped === ProviderPaymentStatus.FAILED && state ? state : undefined,
|
||||||
rawResponse: response as unknown as Record<string, unknown>,
|
rawResponse: response as unknown as Record<string, unknown>,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
mapState(state: string | undefined): PaymentIntentStatus {
|
mapState(state: string | undefined): ProviderPaymentStatus {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case 'APPROVED':
|
case 'APPROVED':
|
||||||
case 'SUCCESS':
|
case 'SUCCESS':
|
||||||
return PaymentIntentStatus.SUCCEEDED;
|
return ProviderPaymentStatus.SUCCEEDED;
|
||||||
case 'FAILED':
|
case 'FAILED':
|
||||||
case 'DECLINED':
|
case 'DECLINED':
|
||||||
case 'CANCELLED':
|
case 'CANCELLED':
|
||||||
case 'EXPIRED':
|
case 'EXPIRED':
|
||||||
return PaymentIntentStatus.FAILED;
|
return ProviderPaymentStatus.FAILED;
|
||||||
case 'PENDING':
|
case 'PENDING':
|
||||||
case 'INITIATED':
|
case 'INITIATED':
|
||||||
return PaymentIntentStatus.REQUIRES_ACTION;
|
return ProviderPaymentStatus.REQUIRES_ACTION;
|
||||||
case 'PROCESSING':
|
case 'PROCESSING':
|
||||||
return PaymentIntentStatus.PROCESSING;
|
return ProviderPaymentStatus.PROCESSING;
|
||||||
default:
|
default:
|
||||||
return PaymentIntentStatus.PROCESSING;
|
return ProviderPaymentStatus.PROCESSING;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,10 +194,10 @@ export class WaafiProvider implements PaymentProvider {
|
|||||||
},
|
},
|
||||||
transactionInfo: {
|
transactionInfo: {
|
||||||
referenceId: input.merchantOrderId,
|
referenceId: input.merchantOrderId,
|
||||||
invoiceId: input.bookingRef,
|
invoiceId: input.orderRef,
|
||||||
amount,
|
amount,
|
||||||
currency: input.currency === 'ETB' ? 'DJF' : input.currency, // Convert ETB to DJF
|
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 {
|
export interface BaseEntity {
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: 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':
|
'@edr/api-common':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/api-common
|
version: link:../../packages/api-common
|
||||||
|
'@edr/payment-providers':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/payment-providers
|
||||||
'@edr/types':
|
'@edr/types':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/types
|
version: link:../../packages/types
|
||||||
|
'@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':
|
'@nestjs/common':
|
||||||
specifier: ^11.0.0
|
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)
|
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':
|
'@nestjs/core':
|
||||||
specifier: ^11.0.0
|
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)
|
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':
|
'@nestjs/microservices':
|
||||||
specifier: ^11.0.0
|
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)
|
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':
|
'@tria-plc/iamapi-common':
|
||||||
specifier: ^0.1.0
|
specifier: ^0.1.0
|
||||||
version: 0.1.6(08624e896246f4d3d786f63e867db9c1)
|
version: 0.1.6(08624e896246f4d3d786f63e867db9c1)
|
||||||
|
axios:
|
||||||
|
specifier: ^1.7.7
|
||||||
|
version: 1.16.1
|
||||||
class-transformer:
|
class-transformer:
|
||||||
specifier: ^0.5.1
|
specifier: ^0.5.1
|
||||||
version: 0.5.1
|
version: 0.5.1
|
||||||
@@ -135,6 +147,12 @@ importers:
|
|||||||
|
|
||||||
apps/edr-passenger-api:
|
apps/edr-passenger-api:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@edr/payment-providers':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/payment-providers
|
||||||
|
'@edr/types':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/types
|
||||||
'@nestjs/axios':
|
'@nestjs/axios':
|
||||||
specifier: ^4.0.1
|
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)
|
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/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:
|
packages/types:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@edr/eslint-config':
|
'@edr/eslint-config':
|
||||||
|
|||||||
Reference in New Issue
Block a user