diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index a3ddffc40..2ded27a63 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -5,3 +5,17 @@ DB_PORT=5433 DB_USER=postgres DB_PASSWORD= DB_NAME=edr_freight + +# Telebirr payment gateway (freight merchant credentials) +TELEBIRR_BASE_URL= +TELEBIRR_WEB_BASE_URL= +TELEBIRR_FABRIC_APP_ID= +TELEBIRR_APP_SECRET= +TELEBIRR_MERCHANT_APP_ID= +TELEBIRR_MERCHANT_CODE= +TELEBIRR_NOTIFY_URL=https://freight-api.edr.et/payments/webhooks/telebirr +TELEBIRR_RETURN_URL= +TELEBIRR_TIMEOUT_EXPRESS=15m +TELEBIRR_PRIVATE_KEY= +TELEBIRR_PUBLIC_KEY= +TELEBIRR_INSECURE_TLS=false diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 09a312ad6..d1d29bf4c 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -16,14 +16,18 @@ "@tria-plc/api-common": "^0.1.0", "@tria-plc/iamapi-common": "^0.1.0", "@edr/api-common": "workspace:*", + "@edr/payment-providers": "workspace:*", "@edr/types": "workspace:*", + "@nestjs/axios": "^4.0.0", "@nestjs/common": "^11.0.0", "@nestjs/config": "^4.0.0", "@nestjs/core": "^11.0.0", + "@nestjs/event-emitter": "^2.0.4", "@nestjs/microservices": "^11.0.0", "@nestjs/platform-express": "^11.0.0", "@nestjs/swagger": "^11.4.2", "@nestjs/typeorm": "^11.0.1", + "axios": "^1.7.7", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "pg": "^8.13.0", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 3eb7d30ec..b0b302ff8 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -1,9 +1,11 @@ import { Module } from "@nestjs/common"; import { ConfigModule, ConfigService } from "@nestjs/config"; +import { EventEmitterModule } from "@nestjs/event-emitter"; import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm"; import appConfig from "./config/app.config"; import databaseConfig from "./config/database.config"; +import telebirrConfig from "./config/telebirr.config"; import { BookingsModule } from "./modules/bookings/bookings.module"; import { ConsignmentsModule } from "./modules/consignments/consignments.module"; @@ -12,13 +14,15 @@ import { CustomersModule } from "./modules/customers/customers.module"; import { TrackingModule } from "./modules/tracking/tracking.module"; import { BillingModule } from "./modules/billing/billing.module"; import { NotificationsModule } from "./modules/notifications/notifications.module"; +import { PaymentsModule } from "./modules/payments/payments.module"; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, - load: [appConfig, databaseConfig], + load: [appConfig, databaseConfig, telebirrConfig], }), + EventEmitterModule.forRoot(), TypeOrmModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService): TypeOrmModuleOptions => @@ -31,6 +35,7 @@ import { NotificationsModule } from "./modules/notifications/notifications.modul TrackingModule, BillingModule, NotificationsModule, + PaymentsModule, ], }) export class AppModule {} diff --git a/apps/edr-freight-api/src/config/telebirr.config.ts b/apps/edr-freight-api/src/config/telebirr.config.ts new file mode 100644 index 000000000..8e5d1712a --- /dev/null +++ b/apps/edr-freight-api/src/config/telebirr.config.ts @@ -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", +})); diff --git a/apps/edr-freight-api/src/modules/payments/entities/payment-intent.entity.ts b/apps/edr-freight-api/src/modules/payments/entities/payment-intent.entity.ts new file mode 100644 index 000000000..ba6b3acf5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/payments/entities/payment-intent.entity.ts @@ -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 | null; + + @Column({ name: "raw_initiation", type: "jsonb", nullable: true }) + rawInitiation?: Record | 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; +} diff --git a/apps/edr-freight-api/src/modules/payments/entities/payment-webhook-event.entity.ts b/apps/edr-freight-api/src/modules/payments/entities/payment-webhook-event.entity.ts new file mode 100644 index 000000000..1633a17ac --- /dev/null +++ b/apps/edr-freight-api/src/modules/payments/entities/payment-webhook-event.entity.ts @@ -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; + + @Column({ name: "processed_at", type: "timestamptz", nullable: true }) + processedAt?: Date | null; + + @Column({ name: "processing_error", type: "text", nullable: true }) + processingError?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/payments/payments.controller.ts b/apps/edr-freight-api/src/modules/payments/payments.controller.ts new file mode 100644 index 000000000..b0414b0fd --- /dev/null +++ b/apps/edr-freight-api/src/modules/payments/payments.controller.ts @@ -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 { + return this.payments.initiatePayment(dto); + } + + @Get(":bookingId") + @ApiOperation({ summary: "Get the payment intent status for a booking" }) + getStatus(@Param("bookingId") bookingId: string): Promise { + return this.payments.getIntentByBookingId(bookingId); + } +} diff --git a/apps/edr-freight-api/src/modules/payments/payments.dto.ts b/apps/edr-freight-api/src/modules/payments/payments.dto.ts new file mode 100644 index 000000000..a3e3d256e --- /dev/null +++ b/apps/edr-freight-api/src/modules/payments/payments.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/payments/payments.module.ts b/apps/edr-freight-api/src/modules/payments/payments.module.ts new file mode 100644 index 000000000..5fdc00e16 --- /dev/null +++ b/apps/edr-freight-api/src/modules/payments/payments.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/payments/payments.repository.ts b/apps/edr-freight-api/src/modules/payments/payments.repository.ts new file mode 100644 index 000000000..51a2e12af --- /dev/null +++ b/apps/edr-freight-api/src/modules/payments/payments.repository.ts @@ -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 { + constructor( + @InjectRepository(PaymentIntent) + repository: Repository, + ) { + super(repository); + } + + findByBookingId(bookingId: string): Promise { + return this.repository.findOne({ where: { bookingId } }); + } + + findByMerchantOrderId(merchantOrderId: string): Promise { + return this.repository.findOne({ where: { merchantOrderId } }); + } +} + +@Injectable() +export class PaymentWebhookEventRepository extends BaseRepository { + constructor( + @InjectRepository(PaymentWebhookEvent) + repository: Repository, + ) { + 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, + ): Promise { + 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 { + await this.repository.update(id, { + processedAt: new Date(), + processingError: processingError ?? null, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/payments/payments.service.ts b/apps/edr-freight-api/src/modules/payments/payments.service.ts new file mode 100644 index 000000000..4a06dc840 --- /dev/null +++ b/apps/edr-freight-api/src/modules/payments/payments.service.ts @@ -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; + + 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.TELEBIRR, this.telebirrProvider], + // add ProviderMethod.CBE_BIRR etc. here when enabled for freight + ]); + } + + async initiatePayment(dto: InitiatePaymentDto): Promise { + 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 { + 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, + 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 { + 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 { + 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, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/payments/webhooks/telebirr-webhook.service.ts b/apps/edr-freight-api/src/modules/payments/webhooks/telebirr-webhook.service.ts new file mode 100644 index 000000000..5ff319008 --- /dev/null +++ b/apps/edr-freight-api/src/modules/payments/webhooks/telebirr-webhook.service.ts @@ -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 { + 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, + }); + + 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); + } +} diff --git a/apps/edr-freight-api/src/modules/payments/webhooks/webhooks.controller.ts b/apps/edr-freight-api/src/modules/payments/webhooks/webhooks.controller.ts new file mode 100644 index 000000000..bca97eaf3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/payments/webhooks/webhooks.controller.ts @@ -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" }; + } +} diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index 4649b2e4a..07a03fc2c 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -22,6 +22,8 @@ "seed": "ts-node prisma/seed.ts" }, "dependencies": { + "@edr/payment-providers": "workspace:*", + "@edr/types": "workspace:*", "@nestjs/axios": "^4.0.1", "@nestjs/common": "^11.0.0", "@nestjs/config": "^4.0.4", diff --git a/apps/edr-passenger-api/src/modules/payments/payments.module.ts b/apps/edr-passenger-api/src/modules/payments/payments.module.ts index 6c145ef9b..1f8086ac3 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.module.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.module.ts @@ -4,11 +4,13 @@ import { PaymentsController } from './payments.controller'; import { PaymentsService } from './payments.service'; import { SeatsModule } from '../seats/seats.module'; import { TicketsModule } from '../tickets/tickets.module'; -import { TelebirrProvider } from './providers/telebirr.provider'; -import { CbeBirrProvider } from './providers/cbe-birr.provider'; -import { EBirrProvider } from './providers/ebirr.provider'; -import { CardProvider } from './providers/card.provider'; -import { WaafiProvider } from './providers/waafi.provider'; +import { + TelebirrProvider, + CbeBirrProvider, + EBirrProvider, + CardProvider, + WaafiProvider, +} from '@edr/payment-providers'; import { WebhooksController } from './webhooks/webhooks.controller'; import { TelebirrWebhookService } from './webhooks/telebirr-webhook.service'; import { CbeBirrWebhookService } from './webhooks/cbe-birr-webhook.service'; diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts index 66775648a..d4a35e14f 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts @@ -4,10 +4,12 @@ import { PrismaService } from '../../common/prisma.service'; import { SeatsService } from '../seats/seats.service'; import { TicketsService } from '../tickets/tickets.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; -import { TelebirrProvider } from './providers/telebirr.provider'; -import { CbeBirrProvider } from './providers/cbe-birr.provider'; -import { EBirrProvider } from './providers/ebirr.provider'; -import { CardProvider } from './providers/card.provider'; +import { + TelebirrProvider, + CbeBirrProvider, + EBirrProvider, + CardProvider, +} from '@edr/payment-providers'; import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client'; import { BadRequestException, NotFoundException } from '@nestjs/common'; diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 5913f1cd9..f6c5ba458 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -5,13 +5,18 @@ import { TicketsService } from '../tickets/tickets.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { Prisma, PaymentIntentStatus, PaymentMethodType, PaymentRegion } from '@prisma/client'; import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, InitiateResponseDto, IntentStatusDto, PaymentRegionEnum } from './payments.dto'; -import { ClientAction, PaymentProvider, ProviderStatus } from './payments.types'; -import { TelebirrProvider } from './providers/telebirr.provider'; -import { CbeBirrProvider } from './providers/cbe-birr.provider'; -import { EBirrProvider } from './providers/ebirr.provider'; -import { CardProvider } from './providers/card.provider'; -import { WaafiProvider } from './providers/waafi.provider'; -import { createMerchantOrderId } from './providers/telebirr.crypto'; +import { + ClientAction, + PaymentProvider, + ProviderStatus, + ProviderPaymentStatus, + TelebirrProvider, + CbeBirrProvider, + EBirrProvider, + CardProvider, + WaafiProvider, + createMerchantOrderId, +} from '@edr/payment-providers'; const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [ PaymentIntentStatus.REQUIRES_ACTION, @@ -147,17 +152,18 @@ export class PaymentsService { const merchantOrderId = createMerchantOrderId(); const result = await provider.initiate({ merchantOrderId, - bookingRef: booking.bookingRef, + orderRef: booking.bookingRef, amountMinor: booking.totalMinor, currency: booking.currency, platform, }); + const providerMethod = provider.method as unknown as PaymentMethodType; const intent = await this.prisma.paymentIntent.upsert({ where: { bookingId: booking.id }, update: { status: PaymentIntentStatus.REQUIRES_ACTION, - method: provider.method, + method: providerMethod, merchantOrderId, providerOrderId: result.providerOrderId, clientAction: result.clientAction as unknown as Prisma.InputJsonValue, @@ -170,7 +176,7 @@ export class PaymentsService { bookingId: booking.id, amountMinor: booking.totalMinor, currency: booking.currency, - method: provider.method, + method: providerMethod, status: PaymentIntentStatus.REQUIRES_ACTION, merchantOrderId, providerOrderId: result.providerOrderId, @@ -235,14 +241,16 @@ export class PaymentsService { intentId: string, status: ProviderStatus, ): Promise { - if (status.rawResponse.biz_content.order_status === "PAY_SUCCESS") { + const bizContent = (status.rawResponse as { biz_content?: { order_status?: string } }) + ?.biz_content; + if (bizContent?.order_status === 'PAY_SUCCESS') { await this.finalizePaymentSuccess({ intentId, providerTxnId: status.providerTxnId, }); return; } - if (status.status === PaymentIntentStatus.FAILED) { + if (status.status === ProviderPaymentStatus.FAILED) { await this.markPaymentFailed({ intentId, failureCode: status.failureCode, @@ -253,7 +261,7 @@ export class PaymentsService { await this.prisma.paymentIntent.update({ where: { id: intentId }, data: { - status: status.status, + status: status.status as unknown as PaymentIntentStatus, providerTxnId: status.providerTxnId ?? undefined, }, }); diff --git a/apps/edr-passenger-api/src/modules/payments/payments.types.ts b/apps/edr-passenger-api/src/modules/payments/payments.types.ts index ba153924c..686274065 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.types.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.types.ts @@ -1,36 +1,11 @@ -import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client'; - -export type PaymentPlatform = 'web' | 'mobile'; - -export type ClientAction = - | { type: 'REDIRECT'; url: string } - | { type: 'LAUNCH_APP'; appId: string; receiveCode?: string; shortCode: string }; - -export interface ProviderInitiationInput { - merchantOrderId: string; - bookingRef: string; - amountMinor: number; - currency: string; - platform?: PaymentPlatform; -} - -export interface ProviderInitiationResult { - providerOrderId: string; - clientAction: ClientAction; - expiresAt: Date; - rawInitiation: Record; -} - -export interface ProviderStatus { - status: PaymentIntentStatus; - providerTxnId?: string; - failureCode?: string; - failureMessage?: string; - rawResponse: any; -} - -export interface PaymentProvider { - readonly method: PaymentMethodType; - initiate(input: ProviderInitiationInput): Promise; - queryStatus(merchantOrderId: string): Promise; -} +// The payment provider contract now lives in @edr/types (consumed via @edr/payment-providers). +// This file remains as a thin re-export so existing local imports keep working. +export type { + PaymentProvider, + ProviderInitiationInput, + ProviderInitiationResult, + ProviderStatus, + ClientAction, + PaymentPlatform, +} from '@edr/types'; +export { ProviderPaymentStatus, ProviderMethod } from '@edr/types'; diff --git a/apps/edr-passenger-api/src/modules/payments/providers/payment-provider.interface.ts b/apps/edr-passenger-api/src/modules/payments/providers/payment-provider.interface.ts deleted file mode 100644 index a571f28be..000000000 --- a/apps/edr-passenger-api/src/modules/payments/providers/payment-provider.interface.ts +++ /dev/null @@ -1,9 +0,0 @@ -export type { - PaymentProvider, - ProviderInitiationInput, - ProviderInitiationResult, - ProviderStatus, - ClientAction, -} from '../payments.types'; - -export const PAYMENT_PROVIDERS = Symbol('PAYMENT_PROVIDERS'); diff --git a/apps/edr-passenger-api/src/modules/payments/webhooks/card-webhook.service.ts b/apps/edr-passenger-api/src/modules/payments/webhooks/card-webhook.service.ts index 5612ac77d..5bf977107 100644 --- a/apps/edr-passenger-api/src/modules/payments/webhooks/card-webhook.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/webhooks/card-webhook.service.ts @@ -1,30 +1,12 @@ import { Injectable, Logger } from '@nestjs/common'; import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client'; +import { + CardProvider, + CardWebhookPayload, + ProviderPaymentStatus, +} from '@edr/payment-providers'; import { PrismaService } from '../../../common/prisma.service'; import { PaymentsService } from '../payments.service'; -import { CardProvider } from '../providers/card.provider'; - -export interface CardWebhookPayload { - id: string; - type: string; - data: { - object: { - id: string; - status: string; - amount: number; - currency: string; - metadata: { - merchantOrderId: string; - bookingRef: string; - }; - transaction_id?: string; - paid_at?: number; - failure_code?: string; - failure_message?: string; - }; - }; - created: number; -} @Injectable() export class CardWebhookService { @@ -76,13 +58,13 @@ export class CardWebhookService { const mapped = this.provider.mapWebhookStatus(payload.data.object.status); try { - if (mapped === PaymentIntentStatus.SUCCEEDED) { + if (mapped === ProviderPaymentStatus.SUCCEEDED) { await this.payments.finalizePaymentSuccess({ intentId: intent.id, providerTxnId: payload.data.object.transaction_id, paidAt: payload.data.object.paid_at ? new Date(payload.data.object.paid_at * 1000) : undefined, }); - } else if (mapped === PaymentIntentStatus.FAILED) { + } else if (mapped === ProviderPaymentStatus.FAILED) { await this.payments.markPaymentFailed({ intentId: intent.id, failureCode: payload.data.object.failure_code, @@ -92,7 +74,7 @@ export class CardWebhookService { await this.prisma.paymentIntent.update({ where: { id: intent.id }, data: { - status: mapped, + status: mapped as unknown as PaymentIntentStatus, providerTxnId: payload.data.object.transaction_id ?? undefined, }, }); diff --git a/apps/edr-passenger-api/src/modules/payments/webhooks/cbe-birr-webhook.service.ts b/apps/edr-passenger-api/src/modules/payments/webhooks/cbe-birr-webhook.service.ts index 2c34e85f6..42502e941 100644 --- a/apps/edr-passenger-api/src/modules/payments/webhooks/cbe-birr-webhook.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/webhooks/cbe-birr-webhook.service.ts @@ -1,21 +1,12 @@ import { Injectable, Logger } from '@nestjs/common'; import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client'; +import { + CbeBirrProvider, + CbeBirrWebhookPayload, + ProviderPaymentStatus, +} from '@edr/payment-providers'; import { PrismaService } from '../../../common/prisma.service'; import { PaymentsService } from '../payments.service'; -import { CbeBirrProvider } from '../providers/cbe-birr.provider'; - -export interface CbeBirrWebhookPayload { - merchantId: string; - merchantOrderId: string; - orderId: string; - status: string; - transactionId?: string; - amount?: string; - currency?: string; - paidAt?: string; - signature: string; - [key: string]: unknown; -} @Injectable() export class CbeBirrWebhookService { @@ -66,13 +57,13 @@ export class CbeBirrWebhookService { const mapped = this.provider.mapWebhookStatus(payload.status); try { - if (mapped === PaymentIntentStatus.SUCCEEDED) { + if (mapped === ProviderPaymentStatus.SUCCEEDED) { await this.payments.finalizePaymentSuccess({ intentId: intent.id, providerTxnId: payload.transactionId ?? payload.orderId, paidAt: payload.paidAt ? new Date(payload.paidAt) : undefined, }); - } else if (mapped === PaymentIntentStatus.FAILED) { + } else if (mapped === ProviderPaymentStatus.FAILED) { await this.payments.markPaymentFailed({ intentId: intent.id, failureCode: payload.status, @@ -80,7 +71,10 @@ export class CbeBirrWebhookService { } else { await this.prisma.paymentIntent.update({ where: { id: intent.id }, - data: { status: mapped, providerTxnId: payload.transactionId ?? undefined }, + data: { + status: mapped as unknown as PaymentIntentStatus, + providerTxnId: payload.transactionId ?? undefined, + }, }); } await this.markProcessed(eventRow.id); diff --git a/apps/edr-passenger-api/src/modules/payments/webhooks/ebirr-webhook.service.ts b/apps/edr-passenger-api/src/modules/payments/webhooks/ebirr-webhook.service.ts index 700078b79..ace727a2a 100644 --- a/apps/edr-passenger-api/src/modules/payments/webhooks/ebirr-webhook.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/webhooks/ebirr-webhook.service.ts @@ -1,21 +1,12 @@ import { Injectable, Logger } from '@nestjs/common'; import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client'; +import { + EBirrProvider, + EBirrWebhookPayload, + ProviderPaymentStatus, +} from '@edr/payment-providers'; import { PrismaService } from '../../../common/prisma.service'; import { PaymentsService } from '../payments.service'; -import { EBirrProvider } from '../providers/ebirr.provider'; - -export interface EBirrWebhookPayload { - merchantCode: string; - orderNo: string; - tradeStatus: string; - tradeNo?: string; - totalAmount?: number; - currency?: string; - payTime?: number; - timestamp: number; - sign: string; - [key: string]: unknown; -} @Injectable() export class EBirrWebhookService { @@ -66,13 +57,13 @@ export class EBirrWebhookService { const mapped = this.provider.mapWebhookStatus(payload.tradeStatus); try { - if (mapped === PaymentIntentStatus.SUCCEEDED) { + if (mapped === ProviderPaymentStatus.SUCCEEDED) { await this.payments.finalizePaymentSuccess({ intentId: intent.id, providerTxnId: payload.tradeNo, paidAt: payload.payTime ? new Date(payload.payTime) : undefined, }); - } else if (mapped === PaymentIntentStatus.FAILED) { + } else if (mapped === ProviderPaymentStatus.FAILED) { await this.payments.markPaymentFailed({ intentId: intent.id, failureCode: payload.tradeStatus, @@ -80,7 +71,10 @@ export class EBirrWebhookService { } else { await this.prisma.paymentIntent.update({ where: { id: intent.id }, - data: { status: mapped, providerTxnId: payload.tradeNo ?? undefined }, + data: { + status: mapped as unknown as PaymentIntentStatus, + providerTxnId: payload.tradeNo ?? undefined, + }, }); } await this.markProcessed(eventRow.id); diff --git a/apps/edr-passenger-api/src/modules/payments/webhooks/telebirr-webhook.service.ts b/apps/edr-passenger-api/src/modules/payments/webhooks/telebirr-webhook.service.ts index 5c8f1f9ac..1f2b06c3e 100644 --- a/apps/edr-passenger-api/src/modules/payments/webhooks/telebirr-webhook.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/webhooks/telebirr-webhook.service.ts @@ -1,22 +1,12 @@ import { Injectable, Logger } from '@nestjs/common'; import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client'; +import { + TelebirrProvider, + TelebirrWebhookPayload, + ProviderPaymentStatus, +} from '@edr/payment-providers'; import { PrismaService } from '../../../common/prisma.service'; import { PaymentsService } from '../payments.service'; -import { TelebirrProvider } from '../providers/telebirr.provider'; - -export interface TelebirrWebhookPayload { - merch_order_id: string; - payment_order_id: string; - trade_status: string; - trans_id?: string; - total_amount?: string; - trans_currency?: string; - notify_time?: string; - trans_end_time?: string; - sign: string; - sign_type?: string; - [key: string]: unknown; -} @Injectable() export class TelebirrWebhookService { @@ -76,13 +66,13 @@ export class TelebirrWebhookService { const mapped = this.provider.mapWebhookTradeStatus(payload.trade_status); try { - if (mapped === PaymentIntentStatus.SUCCEEDED) { + if (mapped === ProviderPaymentStatus.SUCCEEDED) { await this.payments.finalizePaymentSuccess({ intentId: intent.id, providerTxnId: payload.trans_id ?? payload.payment_order_id, paidAt: this.parseEpochSeconds(payload.trans_end_time), }); - } else if (mapped === PaymentIntentStatus.FAILED) { + } else if (mapped === ProviderPaymentStatus.FAILED) { await this.payments.markPaymentFailed({ intentId: intent.id, failureCode: payload.trade_status, @@ -90,7 +80,10 @@ export class TelebirrWebhookService { } else { await this.prisma.paymentIntent.update({ where: { id: intent.id }, - data: { status: mapped, providerTxnId: payload.trans_id ?? undefined }, + data: { + status: mapped as unknown as PaymentIntentStatus, + providerTxnId: payload.trans_id ?? undefined, + }, }); } await this.markProcessed(eventRow.id); diff --git a/apps/edr-passenger-api/src/modules/payments/webhooks/waafi-webhook.service.ts b/apps/edr-passenger-api/src/modules/payments/webhooks/waafi-webhook.service.ts index f69b7c3d3..972b09b3d 100644 --- a/apps/edr-passenger-api/src/modules/payments/webhooks/waafi-webhook.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/webhooks/waafi-webhook.service.ts @@ -1,24 +1,12 @@ import { Injectable, Logger } from '@nestjs/common'; +import { + WaafiProvider, + WaafiWebhookPayload, + ProviderPaymentStatus, +} from '@edr/payment-providers'; +import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client'; import { PrismaService } from '../../../common/prisma.service'; import { PaymentsService } from '../payments.service'; -import { WaafiProvider } from '../providers/waafi.provider'; -import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client'; - -interface WaafiWebhookPayload { - schemaVersion: string; - requestId: string; - timestamp: string; - eventType: string; - params: { - state: string; - referenceId: string; - transactionId: string; - amount: number; - currency: string; - description?: string; - }; - signature?: string; -} @Injectable() export class WaafiWebhookService { @@ -76,13 +64,13 @@ export class WaafiWebhookService { const mappedStatus = this.waafiProvider.mapState(state); - if (mappedStatus === PaymentIntentStatus.SUCCEEDED) { + if (mappedStatus === ProviderPaymentStatus.SUCCEEDED) { await this.paymentsService.finalizePaymentSuccess({ intentId: intent.id, providerTxnId: transactionId, }); this.logger.log(`Waafi payment succeeded: intent=${intent.id} txn=${transactionId}`); - } else if (mappedStatus === PaymentIntentStatus.FAILED) { + } else if (mappedStatus === ProviderPaymentStatus.FAILED) { await this.paymentsService.markPaymentFailed({ intentId: intent.id, failureCode: state, @@ -93,7 +81,7 @@ export class WaafiWebhookService { await this.prisma.paymentIntent.update({ where: { id: intent.id }, data: { - status: mappedStatus, + status: mappedStatus as unknown as PaymentIntentStatus, providerTxnId: transactionId, }, }); diff --git a/apps/edr-passenger-api/src/modules/payments/webhooks/webhooks.controller.ts b/apps/edr-passenger-api/src/modules/payments/webhooks/webhooks.controller.ts index 7bbf7b403..4dd340d06 100644 --- a/apps/edr-passenger-api/src/modules/payments/webhooks/webhooks.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/webhooks/webhooks.controller.ts @@ -2,20 +2,14 @@ import {All, Body, Controller, Headers, HttpCode, HttpStatus, Logger, Post} from import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { TelebirrWebhookPayload, - TelebirrWebhookService, -} from './telebirr-webhook.service'; -import { CbeBirrWebhookPayload, - CbeBirrWebhookService, -} from './cbe-birr-webhook.service'; -import { EBirrWebhookPayload, - EBirrWebhookService, -} from './ebirr-webhook.service'; -import { CardWebhookPayload, - CardWebhookService, -} from './card-webhook.service'; +} from '@edr/payment-providers'; +import { TelebirrWebhookService } from './telebirr-webhook.service'; +import { CbeBirrWebhookService } from './cbe-birr-webhook.service'; +import { EBirrWebhookService } from './ebirr-webhook.service'; +import { CardWebhookService } from './card-webhook.service'; import { WaafiWebhookService } from './waafi-webhook.service'; @ApiTags('Payment Webhooks') diff --git a/packages/payment-providers/package.json b/packages/payment-providers/package.json new file mode 100644 index 000000000..525d78d1b --- /dev/null +++ b/packages/payment-providers/package.json @@ -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" + } +} diff --git a/packages/payment-providers/src/index.ts b/packages/payment-providers/src/index.ts new file mode 100644 index 000000000..b7089cae4 --- /dev/null +++ b/packages/payment-providers/src/index.ts @@ -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'); diff --git a/apps/edr-passenger-api/src/modules/payments/providers/card.provider.ts b/packages/payment-providers/src/providers/card/card.provider.ts similarity index 90% rename from apps/edr-passenger-api/src/modules/payments/providers/card.provider.ts rename to packages/payment-providers/src/providers/card/card.provider.ts index 6f444945b..c296f39a3 100644 --- a/apps/edr-passenger-api/src/modules/payments/providers/card.provider.ts +++ b/packages/payment-providers/src/providers/card/card.provider.ts @@ -1,16 +1,17 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { HttpService } from '@nestjs/axios'; -import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client'; -import { AxiosError, AxiosRequestConfig } from 'axios'; -import { firstValueFrom } from 'rxjs'; -import * as crypto from 'node:crypto'; import { PaymentProvider, ProviderInitiationInput, ProviderInitiationResult, ProviderStatus, -} from '../payments.types'; + ProviderPaymentStatus, + ProviderMethod, +} from '@edr/types'; +import { AxiosError, AxiosRequestConfig } from 'axios'; +import { firstValueFrom } from 'rxjs'; +import * as crypto from 'node:crypto'; interface CardInitiateRequest { amount: number; @@ -18,7 +19,7 @@ interface CardInitiateRequest { description: string; metadata: { merchantOrderId: string; - bookingRef: string; + orderRef: string; }; return_url: string; webhook_url: string; @@ -45,7 +46,7 @@ interface CardQueryResponse { @Injectable() export class CardProvider implements PaymentProvider { - readonly method = PaymentMethodType.CARD; + readonly method = ProviderMethod.CARD; private readonly logger = new Logger(CardProvider.name); constructor( @@ -55,14 +56,14 @@ export class CardProvider implements PaymentProvider { async initiate(input: ProviderInitiationInput): Promise { const amount = input.amountMinor / 100; - + const requestBody: CardInitiateRequest = { amount, currency: input.currency, - description: `EDR Train Booking ${input.bookingRef}`, + description: `EDR ${input.orderRef}`, metadata: { merchantOrderId: input.merchantOrderId, - bookingRef: input.bookingRef, + orderRef: input.orderRef, }, return_url: this.returnUrl, webhook_url: this.webhookUrl, @@ -114,7 +115,7 @@ export class CardProvider implements PaymentProvider { .createHmac('sha256', this.webhookSecret) .update(payloadString) .digest('hex'); - + try { return crypto.timingSafeEqual( Buffer.from(signature), @@ -125,27 +126,27 @@ export class CardProvider implements PaymentProvider { } } - mapWebhookStatus(status: string): PaymentIntentStatus { + mapWebhookStatus(status: string): ProviderPaymentStatus { return this.mapStatus(status); } - private mapStatus(status: string): PaymentIntentStatus { + private mapStatus(status: string): ProviderPaymentStatus { switch (status?.toLowerCase()) { case 'succeeded': case 'paid': - return PaymentIntentStatus.SUCCEEDED; + return ProviderPaymentStatus.SUCCEEDED; case 'failed': case 'canceled': case 'expired': - return PaymentIntentStatus.FAILED; + return ProviderPaymentStatus.FAILED; case 'requires_payment_method': case 'requires_confirmation': case 'requires_action': - return PaymentIntentStatus.REQUIRES_ACTION; + return ProviderPaymentStatus.REQUIRES_ACTION; case 'processing': - return PaymentIntentStatus.PROCESSING; + return ProviderPaymentStatus.PROCESSING; default: - return PaymentIntentStatus.PROCESSING; + return ProviderPaymentStatus.PROCESSING; } } diff --git a/apps/edr-passenger-api/src/modules/payments/providers/cbe-birr.provider.ts b/packages/payment-providers/src/providers/cbe-birr/cbe-birr.provider.ts similarity index 89% rename from apps/edr-passenger-api/src/modules/payments/providers/cbe-birr.provider.ts rename to packages/payment-providers/src/providers/cbe-birr/cbe-birr.provider.ts index 9f94a235a..ebce5caba 100644 --- a/apps/edr-passenger-api/src/modules/payments/providers/cbe-birr.provider.ts +++ b/packages/payment-providers/src/providers/cbe-birr/cbe-birr.provider.ts @@ -1,16 +1,17 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { HttpService } from '@nestjs/axios'; -import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client'; -import { AxiosError, AxiosRequestConfig } from 'axios'; -import { firstValueFrom } from 'rxjs'; -import * as crypto from 'node:crypto'; import { PaymentProvider, ProviderInitiationInput, ProviderInitiationResult, ProviderStatus, -} from '../payments.types'; + ProviderPaymentStatus, + ProviderMethod, +} from '@edr/types'; +import { AxiosError, AxiosRequestConfig } from 'axios'; +import { firstValueFrom } from 'rxjs'; +import * as crypto from 'node:crypto'; interface CbeBirrInitiateRequest { merchantId: string; @@ -42,7 +43,7 @@ interface CbeBirrQueryResponse { @Injectable() export class CbeBirrProvider implements PaymentProvider { - readonly method = PaymentMethodType.CBE_BIRR; + readonly method = ProviderMethod.CBE_BIRR; private readonly logger = new Logger(CbeBirrProvider.name); constructor( @@ -53,13 +54,13 @@ export class CbeBirrProvider implements PaymentProvider { async initiate(input: ProviderInitiationInput): Promise { const amount = (input.amountMinor / 100).toFixed(2); const timestamp = new Date().toISOString(); - + const requestBody: CbeBirrInitiateRequest = { merchantId: this.merchantId, merchantOrderId: input.merchantOrderId, amount, currency: input.currency, - description: `EDR Booking ${input.bookingRef}`, + description: `EDR ${input.orderRef}`, returnUrl: this.returnUrl, notifyUrl: this.notifyUrl, timestamp, @@ -116,7 +117,7 @@ export class CbeBirrProvider implements PaymentProvider { return { status: mapped, providerTxnId: response.transactionId, - failureCode: mapped === PaymentIntentStatus.FAILED ? response.status : undefined, + failureCode: mapped === ProviderPaymentStatus.FAILED ? response.status : undefined, rawResponse: response as unknown as Record, }; } @@ -124,7 +125,7 @@ export class CbeBirrProvider implements PaymentProvider { verifyWebhookSignature(payload: Record): boolean { const { signature, ...data } = payload; if (!signature || typeof signature !== 'string') return false; - + const expectedSignature = this.signRequest(data); return crypto.timingSafeEqual( Buffer.from(signature), @@ -132,25 +133,25 @@ export class CbeBirrProvider implements PaymentProvider { ); } - mapWebhookStatus(status: string): PaymentIntentStatus { + mapWebhookStatus(status: string): ProviderPaymentStatus { return this.mapStatus(status); } - private mapStatus(status: string): PaymentIntentStatus { + private mapStatus(status: string): ProviderPaymentStatus { switch (status?.toUpperCase()) { case 'SUCCESS': case 'COMPLETED': - return PaymentIntentStatus.SUCCEEDED; + return ProviderPaymentStatus.SUCCEEDED; case 'FAILED': case 'REJECTED': case 'EXPIRED': - return PaymentIntentStatus.FAILED; + return ProviderPaymentStatus.FAILED; case 'PENDING': - return PaymentIntentStatus.REQUIRES_ACTION; + return ProviderPaymentStatus.REQUIRES_ACTION; case 'PROCESSING': - return PaymentIntentStatus.PROCESSING; + return ProviderPaymentStatus.PROCESSING; default: - return PaymentIntentStatus.PROCESSING; + return ProviderPaymentStatus.PROCESSING; } } @@ -159,7 +160,7 @@ export class CbeBirrProvider implements PaymentProvider { const signString = sortedKeys .map((key) => `${key}=${data[key]}`) .join('&'); - + return crypto .createHmac('sha256', this.secretKey) .update(signString) diff --git a/apps/edr-passenger-api/src/modules/payments/providers/ebirr.provider.ts b/packages/payment-providers/src/providers/ebirr/ebirr.provider.ts similarity index 89% rename from apps/edr-passenger-api/src/modules/payments/providers/ebirr.provider.ts rename to packages/payment-providers/src/providers/ebirr/ebirr.provider.ts index 701d4b5fb..ee84bf611 100644 --- a/apps/edr-passenger-api/src/modules/payments/providers/ebirr.provider.ts +++ b/packages/payment-providers/src/providers/ebirr/ebirr.provider.ts @@ -1,16 +1,17 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { HttpService } from '@nestjs/axios'; -import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client'; -import { AxiosError, AxiosRequestConfig } from 'axios'; -import { firstValueFrom } from 'rxjs'; -import * as crypto from 'node:crypto'; import { PaymentProvider, ProviderInitiationInput, ProviderInitiationResult, ProviderStatus, -} from '../payments.types'; + ProviderPaymentStatus, + ProviderMethod, +} from '@edr/types'; +import { AxiosError, AxiosRequestConfig } from 'axios'; +import { firstValueFrom } from 'rxjs'; +import * as crypto from 'node:crypto'; interface EBirrInitiateRequest { merchantCode: string; @@ -49,7 +50,7 @@ interface EBirrQueryResponse { @Injectable() export class EBirrProvider implements PaymentProvider { - readonly method = PaymentMethodType.EBIRR; + readonly method = ProviderMethod.EBIRR; private readonly logger = new Logger(EBirrProvider.name); constructor( @@ -60,14 +61,14 @@ export class EBirrProvider implements PaymentProvider { async initiate(input: ProviderInitiationInput): Promise { const amount = input.amountMinor / 100; const timestamp = Date.now(); - + const requestBody: EBirrInitiateRequest = { merchantCode: this.merchantCode, orderNo: input.merchantOrderId, amount, currency: input.currency, subject: `EDR Ticket`, - body: `Train booking ${input.bookingRef}`, + body: `Order ${input.orderRef}`, notifyUrl: this.notifyUrl, returnUrl: this.returnUrl, timestamp, @@ -128,7 +129,7 @@ export class EBirrProvider implements PaymentProvider { return { status: mapped, providerTxnId: response.data.tradeNo, - failureCode: mapped === PaymentIntentStatus.FAILED ? response.data.tradeStatus : undefined, + failureCode: mapped === ProviderPaymentStatus.FAILED ? response.data.tradeStatus : undefined, rawResponse: response as unknown as Record, }; } @@ -136,7 +137,7 @@ export class EBirrProvider implements PaymentProvider { verifyWebhookSignature(payload: Record): boolean { const { sign, ...data } = payload; if (!sign || typeof sign !== 'string') return false; - + const expectedSign = this.signRequest(data); return crypto.timingSafeEqual( Buffer.from(sign), @@ -144,26 +145,26 @@ export class EBirrProvider implements PaymentProvider { ); } - mapWebhookStatus(tradeStatus: string): PaymentIntentStatus { + mapWebhookStatus(tradeStatus: string): ProviderPaymentStatus { return this.mapStatus(tradeStatus); } - private mapStatus(tradeStatus: string): PaymentIntentStatus { + private mapStatus(tradeStatus: string): ProviderPaymentStatus { switch (tradeStatus?.toUpperCase()) { case 'TRADE_SUCCESS': case 'SUCCESS': - return PaymentIntentStatus.SUCCEEDED; + return ProviderPaymentStatus.SUCCEEDED; case 'TRADE_CLOSED': case 'TRADE_FAILED': case 'FAILED': - return PaymentIntentStatus.FAILED; + return ProviderPaymentStatus.FAILED; case 'WAIT_BUYER_PAY': case 'PENDING': - return PaymentIntentStatus.REQUIRES_ACTION; + return ProviderPaymentStatus.REQUIRES_ACTION; case 'PROCESSING': - return PaymentIntentStatus.PROCESSING; + return ProviderPaymentStatus.PROCESSING; default: - return PaymentIntentStatus.PROCESSING; + return ProviderPaymentStatus.PROCESSING; } } @@ -172,7 +173,7 @@ export class EBirrProvider implements PaymentProvider { const signString = sortedKeys .map((key) => `${key}=${data[key]}`) .join('&') + `&key=${this.secretKey}`; - + return crypto .createHash('md5') .update(signString) diff --git a/apps/edr-passenger-api/src/modules/payments/providers/telebirr.crypto.ts b/packages/payment-providers/src/providers/telebirr/telebirr.crypto.ts similarity index 100% rename from apps/edr-passenger-api/src/modules/payments/providers/telebirr.crypto.ts rename to packages/payment-providers/src/providers/telebirr/telebirr.crypto.ts diff --git a/apps/edr-passenger-api/src/modules/payments/providers/telebirr.provider.ts b/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts similarity index 91% rename from apps/edr-passenger-api/src/modules/payments/providers/telebirr.provider.ts rename to packages/payment-providers/src/providers/telebirr/telebirr.provider.ts index e38845023..c1d595e9e 100644 --- a/apps/edr-passenger-api/src/modules/payments/providers/telebirr.provider.ts +++ b/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts @@ -1,16 +1,17 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { HttpService } from '@nestjs/axios'; -import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client'; -import { AxiosError, AxiosRequestConfig } from 'axios'; -import { firstValueFrom } from 'rxjs'; -import * as https from 'node:https'; import { PaymentProvider, ProviderInitiationInput, ProviderInitiationResult, ProviderStatus, -} from '../payments.types'; + ProviderPaymentStatus, + ProviderMethod, +} from '@edr/types'; +import { AxiosError, AxiosRequestConfig } from 'axios'; +import { firstValueFrom } from 'rxjs'; +import * as https from 'node:https'; import { createNonceStr, createTimestamp, @@ -28,7 +29,7 @@ const TELEBIRR_HTTP_TIMEOUT_MS = 10_000; @Injectable() export class TelebirrProvider implements PaymentProvider { - readonly method = PaymentMethodType.TELEBIRR; + readonly method = ProviderMethod.TELEBIRR; private readonly logger = new Logger(TelebirrProvider.name); private readonly httpsAgent: https.Agent; @@ -64,7 +65,7 @@ export class TelebirrProvider implements PaymentProvider { platform === 'mobile' ? { type: 'LAUNCH_APP' as const, - appId:this.merchantAppId, + appId: this.merchantAppId, receiveCode: response.biz_content?.receiveCode, shortCode: this.merchantCode, } @@ -103,39 +104,39 @@ export class TelebirrProvider implements PaymentProvider { status: mapped, providerTxnId, failureCode: - mapped === PaymentIntentStatus.FAILED && tradeStatus ? tradeStatus : undefined, + mapped === ProviderPaymentStatus.FAILED && tradeStatus ? tradeStatus : undefined, rawResponse: response as Record, }; } - mapTradeStatus(tradeStatus: string | undefined): PaymentIntentStatus { + mapTradeStatus(tradeStatus: string | undefined): ProviderPaymentStatus { switch (tradeStatus) { case 'PAY_SUCCESS': - return PaymentIntentStatus.SUCCEEDED; + return ProviderPaymentStatus.SUCCEEDED; case 'PAY_FAILED': case 'ORDER_CLOSED': - return PaymentIntentStatus.FAILED; + return ProviderPaymentStatus.FAILED; case 'WAIT_PAY': - return PaymentIntentStatus.REQUIRES_ACTION; + return ProviderPaymentStatus.REQUIRES_ACTION; case 'PAYING': - return PaymentIntentStatus.PROCESSING; + return ProviderPaymentStatus.PROCESSING; default: - return PaymentIntentStatus.PROCESSING; + return ProviderPaymentStatus.PROCESSING; } } - mapWebhookTradeStatus(tradeStatus: string | undefined): PaymentIntentStatus { + mapWebhookTradeStatus(tradeStatus: string | undefined): ProviderPaymentStatus { switch (tradeStatus) { case 'Completed': - return PaymentIntentStatus.SUCCEEDED; + return ProviderPaymentStatus.SUCCEEDED; case 'Failure': case 'Expired': - return PaymentIntentStatus.FAILED; + return ProviderPaymentStatus.FAILED; case 'Paying': case 'Pending': - return PaymentIntentStatus.PROCESSING; + return ProviderPaymentStatus.PROCESSING; default: - return PaymentIntentStatus.PROCESSING; + return ProviderPaymentStatus.PROCESSING; } } @@ -190,7 +191,7 @@ export class TelebirrProvider implements PaymentProvider { merch_code: this.merchantCode, merch_order_id: input.merchantOrderId, trade_type: 'Checkout' as const, - title: `EDR Booking`, + title: `EDR ${input.orderRef}`, total_amount: totalAmount, trans_currency: input.currency, timeout_express: this.timeoutExpress, diff --git a/apps/edr-passenger-api/src/modules/payments/providers/telebirr.types.ts b/packages/payment-providers/src/providers/telebirr/telebirr.types.ts similarity index 100% rename from apps/edr-passenger-api/src/modules/payments/providers/telebirr.types.ts rename to packages/payment-providers/src/providers/telebirr/telebirr.types.ts diff --git a/apps/edr-passenger-api/src/modules/payments/providers/waafi.provider.ts b/packages/payment-providers/src/providers/waafi/waafi.provider.ts similarity index 91% rename from apps/edr-passenger-api/src/modules/payments/providers/waafi.provider.ts rename to packages/payment-providers/src/providers/waafi/waafi.provider.ts index 53afea737..ea16eccb5 100644 --- a/apps/edr-passenger-api/src/modules/payments/providers/waafi.provider.ts +++ b/packages/payment-providers/src/providers/waafi/waafi.provider.ts @@ -1,15 +1,16 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { HttpService } from '@nestjs/axios'; -import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client'; -import { AxiosError, AxiosRequestConfig } from 'axios'; -import { firstValueFrom } from 'rxjs'; import { PaymentProvider, ProviderInitiationInput, ProviderInitiationResult, ProviderStatus, -} from '../payments.types'; + ProviderPaymentStatus, + ProviderMethod, +} from '@edr/types'; +import { AxiosError, AxiosRequestConfig } from 'axios'; +import { firstValueFrom } from 'rxjs'; const WAAFI_HTTP_TIMEOUT_MS = 10_000; @@ -78,7 +79,7 @@ interface WaafiQueryResponse { @Injectable() export class WaafiProvider implements PaymentProvider { - readonly method = PaymentMethodType.WAAFI; + readonly method = ProviderMethod.WAAFI; private readonly logger = new Logger(WaafiProvider.name); constructor( @@ -133,28 +134,28 @@ export class WaafiProvider implements PaymentProvider { return { status: mapped, providerTxnId: transactionId, - failureCode: mapped === PaymentIntentStatus.FAILED && state ? state : undefined, + failureCode: mapped === ProviderPaymentStatus.FAILED && state ? state : undefined, rawResponse: response as unknown as Record, }; } - mapState(state: string | undefined): PaymentIntentStatus { + mapState(state: string | undefined): ProviderPaymentStatus { switch (state) { case 'APPROVED': case 'SUCCESS': - return PaymentIntentStatus.SUCCEEDED; + return ProviderPaymentStatus.SUCCEEDED; case 'FAILED': case 'DECLINED': case 'CANCELLED': case 'EXPIRED': - return PaymentIntentStatus.FAILED; + return ProviderPaymentStatus.FAILED; case 'PENDING': case 'INITIATED': - return PaymentIntentStatus.REQUIRES_ACTION; + return ProviderPaymentStatus.REQUIRES_ACTION; case 'PROCESSING': - return PaymentIntentStatus.PROCESSING; + return ProviderPaymentStatus.PROCESSING; default: - return PaymentIntentStatus.PROCESSING; + return ProviderPaymentStatus.PROCESSING; } } @@ -193,10 +194,10 @@ export class WaafiProvider implements PaymentProvider { }, transactionInfo: { referenceId: input.merchantOrderId, - invoiceId: input.bookingRef, + invoiceId: input.orderRef, amount, currency: input.currency === 'ETB' ? 'DJF' : input.currency, // Convert ETB to DJF - description: `EDR Train Booking ${input.bookingRef}`, + description: `EDR ${input.orderRef}`, }, }, }; diff --git a/packages/payment-providers/src/utils/merchant-order-id.ts b/packages/payment-providers/src/utils/merchant-order-id.ts new file mode 100644 index 000000000..552b96d33 --- /dev/null +++ b/packages/payment-providers/src/utils/merchant-order-id.ts @@ -0,0 +1 @@ +export { createMerchantOrderId } from '../providers/telebirr/telebirr.crypto'; diff --git a/packages/payment-providers/src/webhooks/card-webhook.types.ts b/packages/payment-providers/src/webhooks/card-webhook.types.ts new file mode 100644 index 000000000..442a7f565 --- /dev/null +++ b/packages/payment-providers/src/webhooks/card-webhook.types.ts @@ -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; +} diff --git a/packages/payment-providers/src/webhooks/cbe-birr-webhook.types.ts b/packages/payment-providers/src/webhooks/cbe-birr-webhook.types.ts new file mode 100644 index 000000000..a4154c605 --- /dev/null +++ b/packages/payment-providers/src/webhooks/cbe-birr-webhook.types.ts @@ -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; +} diff --git a/packages/payment-providers/src/webhooks/ebirr-webhook.types.ts b/packages/payment-providers/src/webhooks/ebirr-webhook.types.ts new file mode 100644 index 000000000..38516bf29 --- /dev/null +++ b/packages/payment-providers/src/webhooks/ebirr-webhook.types.ts @@ -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; +} diff --git a/packages/payment-providers/src/webhooks/telebirr-webhook.types.ts b/packages/payment-providers/src/webhooks/telebirr-webhook.types.ts new file mode 100644 index 000000000..39a0f60f8 --- /dev/null +++ b/packages/payment-providers/src/webhooks/telebirr-webhook.types.ts @@ -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; +} diff --git a/packages/payment-providers/src/webhooks/waafi-webhook.types.ts b/packages/payment-providers/src/webhooks/waafi-webhook.types.ts new file mode 100644 index 000000000..247c46d40 --- /dev/null +++ b/packages/payment-providers/src/webhooks/waafi-webhook.types.ts @@ -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; +} diff --git a/packages/payment-providers/tsconfig.json b/packages/payment-providers/tsconfig.json new file mode 100644 index 000000000..d41177909 --- /dev/null +++ b/packages/payment-providers/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@edr/tsconfig/nestjs.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "declarationMap": true + }, + "include": ["src"] +} diff --git a/packages/types/src/common/index.ts b/packages/types/src/common/index.ts index 5690a6ca6..4ccd3dca3 100644 --- a/packages/types/src/common/index.ts +++ b/packages/types/src/common/index.ts @@ -1,3 +1,5 @@ +export * from "./payments"; + export interface BaseEntity { id: string; createdAt: string; diff --git a/packages/types/src/common/payments.ts b/packages/types/src/common/payments.ts new file mode 100644 index 000000000..243c020f2 --- /dev/null +++ b/packages/types/src/common/payments.ts @@ -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; +} + +export interface ProviderStatus { + status: ProviderPaymentStatus; + providerTxnId?: string; + failureCode?: string; + failureMessage?: string; + rawResponse: Record; +} + +export interface PaymentProvider { + readonly method: ProviderMethod; + initiate(input: ProviderInitiationInput): Promise; + queryStatus(merchantOrderId: string): Promise; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f16059fb8..850e29b43 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,9 +35,15 @@ importers: '@edr/api-common': specifier: workspace:* version: link:../../packages/api-common + '@edr/payment-providers': + specifier: workspace:* + version: link:../../packages/payment-providers '@edr/types': specifier: workspace:* version: link:../../packages/types + '@nestjs/axios': + specifier: ^4.0.0 + version: 4.0.1(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.16.1)(rxjs@7.8.2) '@nestjs/common': specifier: ^11.0.0 version: 11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -47,6 +53,9 @@ importers: '@nestjs/core': specifier: ^11.0.0 version: 11.1.23(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.23)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/event-emitter': + specifier: ^2.0.4 + version: 2.1.1(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.23) '@nestjs/microservices': specifier: ^11.0.0 version: 11.1.24(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.23)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -65,6 +74,9 @@ importers: '@tria-plc/iamapi-common': specifier: ^0.1.0 version: 0.1.6(08624e896246f4d3d786f63e867db9c1) + axios: + specifier: ^1.7.7 + version: 1.16.1 class-transformer: specifier: ^0.5.1 version: 0.5.1 @@ -135,6 +147,12 @@ importers: apps/edr-passenger-api: dependencies: + '@edr/payment-providers': + specifier: workspace:* + version: link:../../packages/payment-providers + '@edr/types': + specifier: workspace:* + version: link:../../packages/types '@nestjs/axios': specifier: ^4.0.1 version: 4.0.1(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.16.1)(rxjs@7.8.2) @@ -474,6 +492,43 @@ importers: packages/config/tsconfig: {} + packages/payment-providers: + dependencies: + '@edr/types': + specifier: workspace:* + version: link:../types + devDependencies: + '@edr/eslint-config': + specifier: workspace:* + version: link:../config/eslint-config + '@edr/tsconfig': + specifier: workspace:* + version: link:../config/tsconfig + '@nestjs/axios': + specifier: ^4.0.0 + version: 4.0.1(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.16.1)(rxjs@7.8.2) + '@nestjs/common': + specifier: ^11.0.0 + version: 11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/config': + specifier: ^4.0.0 + version: 4.0.4(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) + '@types/node': + specifier: ^20.14.0 + version: 20.19.41 + axios: + specifier: ^1.7.0 + version: 1.16.1 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.1 + version: 7.8.2 + typescript: + specifier: ^5.5.4 + version: 5.9.3 + packages/types: devDependencies: '@edr/eslint-config':