mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #7 from Tria-plc/feat/telebirr-integration
Feat/telebirr integration
This commit is contained in:
@@ -18,6 +18,15 @@ SENDGRID_API_KEY=
|
||||
SENDGRID_FROM_EMAIL=noreply@edr-platform.com
|
||||
|
||||
# Telebirr
|
||||
TELEBIRR_API_URL=https://api.telebirr.com
|
||||
TELEBIRR_APP_ID=
|
||||
TELEBIRR_APP_KEY=
|
||||
TELEBIRR_BASE_URL=
|
||||
TELEBIRR_WEB_BASE_URL=
|
||||
TELEBIRR_FABRIC_APP_ID=
|
||||
TELEBIRR_APP_SECRET=
|
||||
TELEBIRR_MERCHANT_APP_ID=
|
||||
TELEBIRR_MERCHANT_CODE=
|
||||
TELEBIRR_NOTIFY_URL=
|
||||
TELEBIRR_RETURN_URL=
|
||||
TELEBIRR_TIMEOUT_EXPRESS=15m
|
||||
TELEBIRR_PRIVATE_KEY=
|
||||
TELEBIRR_PUBLIC_KEY=
|
||||
TELEBIRR_INSECURE_TLS=false
|
||||
@@ -19,6 +19,7 @@
|
||||
"seed": "ts-node prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.1.19",
|
||||
@@ -30,6 +31,7 @@
|
||||
"@nestjs/swagger": "^7.4.0",
|
||||
"@prisma/client": "^5.8.0",
|
||||
"@sendgrid/mail": "^8.1.0",
|
||||
"axios": "^1.7.7",
|
||||
"bcrypt": "^5.1.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
@@ -61,11 +63,19 @@
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": { "^.+\\.(t|j)s$": "ts-jest" },
|
||||
"collectCoverageFrom": ["**/*.(t|j)s"],
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[merchantOrderId]` on the table `PaymentIntent` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "PaymentIntent" ADD COLUMN "expiresAt" TIMESTAMP(3),
|
||||
ADD COLUMN "failureCode" TEXT,
|
||||
ADD COLUMN "failureMessage" TEXT,
|
||||
ADD COLUMN "merchantOrderId" TEXT,
|
||||
ADD COLUMN "paidAt" TIMESTAMP(3),
|
||||
ADD COLUMN "providerOrderId" TEXT,
|
||||
ADD COLUMN "providerTxnId" TEXT,
|
||||
ADD COLUMN "rawInitiation" JSONB;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PaymentWebhookEvent" (
|
||||
"id" TEXT NOT NULL,
|
||||
"provider" "PaymentMethodType" NOT NULL,
|
||||
"externalEventId" TEXT NOT NULL,
|
||||
"merchantOrderId" TEXT,
|
||||
"providerTxnId" TEXT,
|
||||
"signatureValid" BOOLEAN NOT NULL,
|
||||
"status" TEXT NOT NULL,
|
||||
"payload" JSONB NOT NULL,
|
||||
"receivedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"processedAt" TIMESTAMP(3),
|
||||
"processingError" TEXT,
|
||||
|
||||
CONSTRAINT "PaymentWebhookEvent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PaymentRefund" (
|
||||
"id" TEXT NOT NULL,
|
||||
"paymentIntentId" TEXT NOT NULL,
|
||||
"amountMinor" INTEGER NOT NULL,
|
||||
"reason" TEXT,
|
||||
"providerRefundId" TEXT,
|
||||
"status" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "PaymentRefund_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PaymentWebhookEvent_merchantOrderId_idx" ON "PaymentWebhookEvent"("merchantOrderId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PaymentWebhookEvent_provider_externalEventId_key" ON "PaymentWebhookEvent"("provider", "externalEventId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PaymentIntent_merchantOrderId_key" ON "PaymentIntent"("merchantOrderId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PaymentIntent_providerOrderId_idx" ON "PaymentIntent"("providerOrderId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PaymentIntent_providerTxnId_idx" ON "PaymentIntent"("providerTxnId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "PaymentIntent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -343,9 +343,47 @@ model PaymentIntent {
|
||||
status PaymentIntentStatus @default(REQUIRES_ACTION)
|
||||
providerRef String?
|
||||
clientAction Json?
|
||||
merchantOrderId String? @unique
|
||||
providerOrderId String?
|
||||
providerTxnId String?
|
||||
rawInitiation Json?
|
||||
paidAt DateTime?
|
||||
failureCode String?
|
||||
failureMessage String?
|
||||
expiresAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
booking Booking @relation(fields: [bookingId], references: [id])
|
||||
refunds PaymentRefund[]
|
||||
@@index([providerOrderId])
|
||||
@@index([providerTxnId])
|
||||
}
|
||||
|
||||
model PaymentWebhookEvent {
|
||||
id String @id @default(uuid())
|
||||
provider PaymentMethodType
|
||||
externalEventId String
|
||||
merchantOrderId String?
|
||||
providerTxnId String?
|
||||
signatureValid Boolean
|
||||
status String
|
||||
payload Json
|
||||
receivedAt DateTime @default(now())
|
||||
processedAt DateTime?
|
||||
processingError String?
|
||||
@@unique([provider, externalEventId])
|
||||
@@index([merchantOrderId])
|
||||
}
|
||||
|
||||
model PaymentRefund {
|
||||
id String @id @default(uuid())
|
||||
paymentIntentId String
|
||||
amountMinor Int
|
||||
reason String?
|
||||
providerRefundId String?
|
||||
status String
|
||||
createdAt DateTime @default(now())
|
||||
paymentIntent PaymentIntent @relation(fields: [paymentIntentId], references: [id])
|
||||
}
|
||||
|
||||
model Ticket {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { PrismaModule } from './common/prisma.module';
|
||||
import appConfig from './config/app.config';
|
||||
import dbConfig from './config/database.config';
|
||||
import telebirrConfig from './config/telebirr.config';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { StationsModule } from './modules/stations/stations.module';
|
||||
import { FleetModule } from './modules/fleet/fleet.module';
|
||||
@@ -26,7 +27,7 @@ import { SegmentsModule } from './modules/segments/segments.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true, load: [appConfig, dbConfig] }),
|
||||
ConfigModule.forRoot({ isGlobal: true, load: [appConfig, dbConfig, telebirrConfig] }),
|
||||
ScheduleModule.forRoot(),
|
||||
EventEmitterModule.forRoot(),
|
||||
PrismaModule,
|
||||
|
||||
16
apps/edr-passenger-api/src/config/telebirr.config.ts
Normal file
16
apps/edr-passenger-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',
|
||||
}));
|
||||
@@ -11,6 +11,7 @@ import { JwtGuard } from '../../common/jwt.guard';
|
||||
export class PaymentsController {
|
||||
constructor(private service: PaymentsService) {}
|
||||
@Post('initiate') @ApiOperation({ summary: 'Initiate payment for a booking' }) initiatePayment(@Body() dto: InitiatePaymentDto) { return this.service.initiatePayment(dto); }
|
||||
@Get('intents/:bookingId') @ApiOperation({ summary: 'Get payment intent status for a booking' }) getIntent(@Param('bookingId') bookingId: string) { return this.service.getIntentByBookingId(bookingId); }
|
||||
@Post('refund') @ApiOperation({ summary: 'Refund a confirmed booking' }) refund(@Body() dto: RefundDto) { return this.service.refund(dto); }
|
||||
@Post('methods') @ApiOperation({ summary: 'Add a payment method' }) addMethod(@Body() dto: AddPaymentMethodDto) { return this.service.addPaymentMethod(dto); }
|
||||
@Get('methods/:userId') @ApiOperation({ summary: 'Get payment methods for user' }) getMethods(@Param('userId') userId: string) { return this.service.getPaymentMethods(userId); }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { IsString, IsEnum, IsOptional } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { PaymentIntentStatus } from '@prisma/client';
|
||||
|
||||
export enum PaymentMethodTypeEnum { TELEBIRR = 'TELEBIRR', CBE_BIRR = 'CBE_BIRR', EBIRR = 'EBIRR', CARD = 'CARD', WALLET = 'WALLET' }
|
||||
|
||||
@@ -20,3 +21,25 @@ export class AddPaymentMethodDto {
|
||||
@ApiProperty() @IsString() displayName: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() maskedHint?: string;
|
||||
}
|
||||
|
||||
export class ClientActionDto {
|
||||
@ApiProperty({ enum: ['REDIRECT'] }) type: 'REDIRECT';
|
||||
@ApiProperty() url: string;
|
||||
}
|
||||
|
||||
export class InitiateResponseDto {
|
||||
@ApiProperty() intentId: string;
|
||||
@ApiProperty({ enum: PaymentIntentStatus }) status: PaymentIntentStatus;
|
||||
@ApiPropertyOptional({ type: ClientActionDto }) clientAction?: ClientActionDto;
|
||||
@ApiPropertyOptional() merchantOrderId?: string;
|
||||
}
|
||||
|
||||
export class IntentStatusDto {
|
||||
@ApiProperty() intentId: string;
|
||||
@ApiProperty({ enum: PaymentIntentStatus }) status: PaymentIntentStatus;
|
||||
@ApiPropertyOptional({ type: ClientActionDto }) clientAction?: ClientActionDto;
|
||||
@ApiPropertyOptional() merchantOrderId?: string;
|
||||
@ApiPropertyOptional() paidAt?: string;
|
||||
@ApiPropertyOptional() failureCode?: string;
|
||||
@ApiPropertyOptional() failureMessage?: string;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
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 { WebhooksController } from './webhooks/webhooks.controller';
|
||||
import { TelebirrWebhookService } from './webhooks/telebirr-webhook.service';
|
||||
|
||||
@Module({ imports: [SeatsModule, TicketsModule], controllers: [PaymentsController], providers: [PaymentsService] })
|
||||
@Module({
|
||||
imports: [SeatsModule, TicketsModule, HttpModule.register({ timeout: 10_000 })],
|
||||
controllers: [PaymentsController, WebhooksController],
|
||||
providers: [PaymentsService, TelebirrProvider, TelebirrWebhookService],
|
||||
})
|
||||
export class PaymentsModule {}
|
||||
|
||||
@@ -1,56 +1,295 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { SeatsService } from '../seats/seats.service';
|
||||
import { TicketsService } from '../tickets/tickets.service';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto } from './payments.dto';
|
||||
import { telebirrAdapter, cbeBirrAdapter, eBirrAdapter, cardAdapter } from './payments.adapters';
|
||||
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
||||
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, InitiateResponseDto, IntentStatusDto } from './payments.dto';
|
||||
import { cbeBirrAdapter, eBirrAdapter, cardAdapter } from './payments.adapters';
|
||||
import { PaymentProvider, ProviderStatus } from './payments.types';
|
||||
import { TelebirrProvider } from './providers/telebirr.provider';
|
||||
import { createMerchantOrderId } from './providers/telebirr.crypto';
|
||||
|
||||
const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [
|
||||
PaymentIntentStatus.REQUIRES_ACTION,
|
||||
PaymentIntentStatus.PROCESSING,
|
||||
PaymentIntentStatus.SUCCEEDED,
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class PaymentsService {
|
||||
private readonly logger = new Logger(PaymentsService.name);
|
||||
private readonly providers: Map<PaymentMethodType, PaymentProvider>;
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private seatsService: SeatsService,
|
||||
private ticketsService: TicketsService,
|
||||
private eventEmitter: EventEmitter2,
|
||||
) {}
|
||||
private telebirrProvider: TelebirrProvider,
|
||||
) {
|
||||
this.providers = new Map<PaymentMethodType, PaymentProvider>([
|
||||
[PaymentMethodType.TELEBIRR, this.telebirrProvider],
|
||||
]);
|
||||
}
|
||||
|
||||
async initiatePayment(dto: InitiatePaymentDto) {
|
||||
const booking = await this.prisma.booking.findUnique({ where: { id: dto.bookingId }, include: { seats: true } });
|
||||
async initiatePayment(dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: dto.bookingId },
|
||||
include: { seats: true },
|
||||
});
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
if (booking.status !== 'PENDING_PAYMENT') throw new BadRequestException('Booking not payable');
|
||||
|
||||
let result;
|
||||
if (dto.method === 'WALLET') {
|
||||
result = await this.prisma.$transaction(async (tx) => {
|
||||
const wallet = await tx.walletAccount.findUnique({ where: { passengerId: booking.passengerId } });
|
||||
if (!wallet || wallet.balanceMinor < booking.totalMinor) return { success: false, providerRef: '' };
|
||||
const newBalance = wallet.balanceMinor - booking.totalMinor;
|
||||
await tx.walletAccount.update({ where: { passengerId: booking.passengerId }, data: { balanceMinor: newBalance } });
|
||||
await tx.walletLedgerEntry.create({ data: { walletId: wallet.id, type: 'DEBIT', amountMinor: booking.totalMinor, balanceAfterMinor: newBalance, description: `Train Ticket - ${booking.bookingRef}`, relatedBookingId: booking.id } });
|
||||
return { success: true, providerRef: `WALLET-${Date.now()}` };
|
||||
});
|
||||
} else {
|
||||
const adapters = { TELEBIRR: telebirrAdapter, CBE_BIRR: cbeBirrAdapter, EBIRR: eBirrAdapter, CARD: cardAdapter } as any;
|
||||
result = await adapters[dto.method](booking.totalMinor, booking.bookingRef);
|
||||
if (booking.status !== 'PENDING_PAYMENT') {
|
||||
throw new BadRequestException('Booking not payable');
|
||||
}
|
||||
|
||||
const status = result.success ? 'SUCCEEDED' : 'FAILED';
|
||||
const intent = await this.prisma.paymentIntent.upsert({
|
||||
const existing = await this.prisma.paymentIntent.findUnique({
|
||||
where: { bookingId: dto.bookingId },
|
||||
update: { status, providerRef: result.providerRef, clientAction: result.clientAction as any },
|
||||
create: { bookingId: dto.bookingId, amountMinor: booking.totalMinor, method: dto.method as any, status: status as any, providerRef: result.providerRef, clientAction: result.clientAction as any },
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId));
|
||||
await this.prisma.booking.update({ where: { id: dto.bookingId }, data: { status: 'CONFIRMED' } });
|
||||
await this.ticketsService.generate(dto.bookingId);
|
||||
await this.awardLoyaltyPoints(booking.passengerId, booking.totalMinor, booking.id);
|
||||
this.eventEmitter.emit('payment.succeeded', { booking });
|
||||
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
|
||||
return this.formatIntentResponse(existing);
|
||||
}
|
||||
|
||||
return { id: intent.id, status: result.success ? 'SUCCESS' : 'FAILED', success: result.success };
|
||||
const method = dto.method as PaymentMethodType;
|
||||
|
||||
if (method === PaymentMethodType.WALLET) {
|
||||
return this.initiateWalletPayment(booking);
|
||||
}
|
||||
|
||||
const provider = this.providers.get(method);
|
||||
if (provider) {
|
||||
return this.initiateProviderPayment(booking, provider);
|
||||
}
|
||||
|
||||
// TODO: convert CBE_BIRR, EBIRR, CARD into PaymentProvider implementations.
|
||||
return this.initiateStubPayment(booking, method);
|
||||
}
|
||||
|
||||
private async initiateWalletPayment(
|
||||
booking: Prisma.BookingGetPayload<{ include: { seats: true } }>,
|
||||
): Promise<InitiateResponseDto> {
|
||||
const debitResult = await this.prisma.$transaction(async (tx) => {
|
||||
const wallet = await tx.walletAccount.findUnique({
|
||||
where: { passengerId: booking.passengerId },
|
||||
});
|
||||
if (!wallet || wallet.balanceMinor < booking.totalMinor) {
|
||||
return { success: false };
|
||||
}
|
||||
const newBalance = wallet.balanceMinor - booking.totalMinor;
|
||||
await tx.walletAccount.update({
|
||||
where: { passengerId: booking.passengerId },
|
||||
data: { balanceMinor: newBalance },
|
||||
});
|
||||
await tx.walletLedgerEntry.create({
|
||||
data: {
|
||||
walletId: wallet.id,
|
||||
type: 'DEBIT',
|
||||
amountMinor: booking.totalMinor,
|
||||
balanceAfterMinor: newBalance,
|
||||
description: `Train Ticket - ${booking.bookingRef}`,
|
||||
relatedBookingId: booking.id,
|
||||
},
|
||||
});
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
if (!debitResult.success) {
|
||||
const failed = await this.prisma.paymentIntent.upsert({
|
||||
where: { bookingId: booking.id },
|
||||
update: {
|
||||
status: PaymentIntentStatus.FAILED,
|
||||
failureCode: 'INSUFFICIENT_BALANCE',
|
||||
},
|
||||
create: {
|
||||
bookingId: booking.id,
|
||||
amountMinor: booking.totalMinor,
|
||||
method: PaymentMethodType.WALLET,
|
||||
status: PaymentIntentStatus.FAILED,
|
||||
failureCode: 'INSUFFICIENT_BALANCE',
|
||||
},
|
||||
});
|
||||
return this.formatIntentResponse(failed);
|
||||
}
|
||||
|
||||
const intent = await this.prisma.paymentIntent.upsert({
|
||||
where: { bookingId: booking.id },
|
||||
update: { status: PaymentIntentStatus.PROCESSING },
|
||||
create: {
|
||||
bookingId: booking.id,
|
||||
amountMinor: booking.totalMinor,
|
||||
method: PaymentMethodType.WALLET,
|
||||
status: PaymentIntentStatus.PROCESSING,
|
||||
providerRef: `WALLET-${Date.now()}`,
|
||||
},
|
||||
});
|
||||
await this.finalizePaymentSuccess({ intentId: intent.id });
|
||||
const refreshed = await this.prisma.paymentIntent.findUniqueOrThrow({
|
||||
where: { id: intent.id },
|
||||
});
|
||||
return this.formatIntentResponse(refreshed);
|
||||
}
|
||||
|
||||
private async initiateProviderPayment(
|
||||
booking: Prisma.BookingGetPayload<{ include: { seats: true } }>,
|
||||
provider: PaymentProvider,
|
||||
): Promise<InitiateResponseDto> {
|
||||
const merchantOrderId = createMerchantOrderId();
|
||||
const result = await provider.initiate({
|
||||
merchantOrderId,
|
||||
bookingRef: booking.bookingRef,
|
||||
amountMinor: booking.totalMinor,
|
||||
currency: booking.currency,
|
||||
});
|
||||
|
||||
const intent = await this.prisma.paymentIntent.upsert({
|
||||
where: { bookingId: booking.id },
|
||||
update: {
|
||||
status: PaymentIntentStatus.REQUIRES_ACTION,
|
||||
method: provider.method,
|
||||
merchantOrderId,
|
||||
providerOrderId: result.providerOrderId,
|
||||
clientAction: result.clientAction as unknown as Prisma.InputJsonValue,
|
||||
rawInitiation: result.rawInitiation as Prisma.InputJsonValue,
|
||||
expiresAt: result.expiresAt,
|
||||
failureCode: null,
|
||||
failureMessage: null,
|
||||
},
|
||||
create: {
|
||||
bookingId: booking.id,
|
||||
amountMinor: booking.totalMinor,
|
||||
currency: booking.currency,
|
||||
method: provider.method,
|
||||
status: PaymentIntentStatus.REQUIRES_ACTION,
|
||||
merchantOrderId,
|
||||
providerOrderId: result.providerOrderId,
|
||||
clientAction: result.clientAction as unknown as Prisma.InputJsonValue,
|
||||
rawInitiation: result.rawInitiation as Prisma.InputJsonValue,
|
||||
expiresAt: result.expiresAt,
|
||||
},
|
||||
});
|
||||
return this.formatIntentResponse(intent);
|
||||
}
|
||||
|
||||
private async initiateStubPayment(
|
||||
booking: Prisma.BookingGetPayload<{ include: { seats: true } }>,
|
||||
method: PaymentMethodType,
|
||||
): Promise<InitiateResponseDto> {
|
||||
const adapters = {
|
||||
[PaymentMethodType.CBE_BIRR]: cbeBirrAdapter,
|
||||
[PaymentMethodType.EBIRR]: eBirrAdapter,
|
||||
[PaymentMethodType.CARD]: cardAdapter,
|
||||
} as Partial<Record<PaymentMethodType, (a: number, ref: string) => Promise<{ success: boolean; providerRef: string }>>>;
|
||||
const adapter = adapters[method];
|
||||
if (!adapter) {
|
||||
throw new BadRequestException(`Unsupported payment method: ${method}`);
|
||||
}
|
||||
const result = await adapter(booking.totalMinor, booking.bookingRef);
|
||||
const status = result.success ? PaymentIntentStatus.PROCESSING : PaymentIntentStatus.FAILED;
|
||||
const intent = await this.prisma.paymentIntent.upsert({
|
||||
where: { bookingId: booking.id },
|
||||
update: { status, providerRef: result.providerRef },
|
||||
create: {
|
||||
bookingId: booking.id,
|
||||
amountMinor: booking.totalMinor,
|
||||
method,
|
||||
status,
|
||||
providerRef: result.providerRef,
|
||||
},
|
||||
});
|
||||
if (result.success) {
|
||||
await this.finalizePaymentSuccess({ intentId: intent.id });
|
||||
const refreshed = await this.prisma.paymentIntent.findUniqueOrThrow({
|
||||
where: { id: intent.id },
|
||||
});
|
||||
return this.formatIntentResponse(refreshed);
|
||||
}
|
||||
return this.formatIntentResponse(intent);
|
||||
}
|
||||
|
||||
private formatIntentResponse(
|
||||
intent: Prisma.PaymentIntentGetPayload<Record<string, never>>,
|
||||
): InitiateResponseDto {
|
||||
const clientAction =
|
||||
intent.clientAction && typeof intent.clientAction === 'object'
|
||||
? (intent.clientAction as unknown as { type: 'REDIRECT'; url: string })
|
||||
: undefined;
|
||||
return {
|
||||
intentId: intent.id,
|
||||
status: intent.status,
|
||||
clientAction,
|
||||
merchantOrderId: intent.merchantOrderId ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async getIntentByBookingId(bookingId: string): Promise<IntentStatusDto> {
|
||||
const intent = await this.prisma.paymentIntent.findUnique({
|
||||
where: { bookingId },
|
||||
});
|
||||
if (!intent) throw new NotFoundException('PaymentIntent not found');
|
||||
|
||||
const refreshable =
|
||||
intent.status === PaymentIntentStatus.REQUIRES_ACTION ||
|
||||
intent.status === PaymentIntentStatus.PROCESSING;
|
||||
const stale = intent.updatedAt.getTime() < Date.now() - 5_000;
|
||||
const provider = this.providers.get(intent.method);
|
||||
|
||||
if (refreshable && stale && intent.merchantOrderId && provider) {
|
||||
try {
|
||||
const status = await provider.queryStatus(intent.merchantOrderId);
|
||||
await this.applyProviderStatus(intent.id, status);
|
||||
const refreshed = await this.prisma.paymentIntent.findUniqueOrThrow({
|
||||
where: { id: intent.id },
|
||||
});
|
||||
return this.formatIntentStatus(refreshed);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(
|
||||
`queryStatus failed for intent ${intent.id}: ${message}; returning cached`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return this.formatIntentStatus(intent);
|
||||
}
|
||||
|
||||
private async applyProviderStatus(
|
||||
intentId: string,
|
||||
status: ProviderStatus,
|
||||
): Promise<void> {
|
||||
if (status.status === PaymentIntentStatus.SUCCEEDED) {
|
||||
await this.finalizePaymentSuccess({
|
||||
intentId,
|
||||
providerTxnId: status.providerTxnId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (status.status === PaymentIntentStatus.FAILED) {
|
||||
await this.markPaymentFailed({
|
||||
intentId,
|
||||
failureCode: status.failureCode,
|
||||
failureMessage: status.failureMessage,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await this.prisma.paymentIntent.update({
|
||||
where: { id: intentId },
|
||||
data: {
|
||||
status: status.status,
|
||||
providerTxnId: status.providerTxnId ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private formatIntentStatus(
|
||||
intent: Prisma.PaymentIntentGetPayload<Record<string, never>>,
|
||||
): IntentStatusDto {
|
||||
const base = this.formatIntentResponse(intent);
|
||||
return {
|
||||
...base,
|
||||
paidAt: intent.paidAt?.toISOString(),
|
||||
failureCode: intent.failureCode ?? undefined,
|
||||
failureMessage: intent.failureMessage ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async refund(dto: RefundDto) {
|
||||
@@ -69,6 +308,76 @@ export class PaymentsService {
|
||||
|
||||
getPaymentMethods(userId: string) { return this.prisma.paymentMethod.findMany({ where: { userId }, orderBy: { isDefault: 'desc' } }); }
|
||||
|
||||
async finalizePaymentSuccess(input: {
|
||||
intentId: string;
|
||||
providerTxnId?: string;
|
||||
paidAt?: Date;
|
||||
}): Promise<{ alreadyFinalized: boolean }> {
|
||||
const intent = await this.prisma.paymentIntent.findUnique({
|
||||
where: { id: input.intentId },
|
||||
});
|
||||
if (!intent) throw new NotFoundException('PaymentIntent not found');
|
||||
if (intent.status === PaymentIntentStatus.SUCCEEDED) {
|
||||
return { alreadyFinalized: true };
|
||||
}
|
||||
if (intent.status === PaymentIntentStatus.CANCELLED) {
|
||||
throw new BadRequestException('PaymentIntent is cancelled; cannot finalize');
|
||||
}
|
||||
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: intent.bookingId },
|
||||
include: { seats: true },
|
||||
});
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
|
||||
const paidAt = input.paidAt ?? new Date();
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.paymentIntent.update({
|
||||
where: { id: intent.id },
|
||||
data: {
|
||||
status: PaymentIntentStatus.SUCCEEDED,
|
||||
providerTxnId: input.providerTxnId ?? intent.providerTxnId ?? undefined,
|
||||
paidAt,
|
||||
},
|
||||
});
|
||||
await tx.booking.update({
|
||||
where: { id: booking.id },
|
||||
data: { status: 'CONFIRMED' },
|
||||
});
|
||||
});
|
||||
|
||||
await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId));
|
||||
await this.ticketsService.generate(booking.id);
|
||||
await this.awardLoyaltyPoints(booking.passengerId, booking.totalMinor, booking.id);
|
||||
this.eventEmitter.emit('payment.succeeded', { booking });
|
||||
return { alreadyFinalized: false };
|
||||
}
|
||||
|
||||
async markPaymentFailed(input: {
|
||||
intentId: string;
|
||||
failureCode?: string;
|
||||
failureMessage?: string;
|
||||
}): Promise<void> {
|
||||
const intent = await this.prisma.paymentIntent.findUnique({
|
||||
where: { id: input.intentId },
|
||||
});
|
||||
if (!intent) throw new NotFoundException('PaymentIntent not found');
|
||||
if (
|
||||
intent.status === PaymentIntentStatus.SUCCEEDED ||
|
||||
intent.status === PaymentIntentStatus.CANCELLED
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.prisma.paymentIntent.update({
|
||||
where: { id: intent.id },
|
||||
data: {
|
||||
status: PaymentIntentStatus.FAILED,
|
||||
failureCode: input.failureCode,
|
||||
failureMessage: input.failureMessage,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async awardLoyaltyPoints(passengerId: string, amountMinor: number, bookingId: string) {
|
||||
const points = Math.floor(amountMinor / 100);
|
||||
const account = await this.prisma.loyaltyAccount.findUnique({ where: { passengerId } });
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
||||
|
||||
export interface ClientAction {
|
||||
type: 'REDIRECT';
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ProviderInitiationInput {
|
||||
merchantOrderId: string;
|
||||
bookingRef: string;
|
||||
amountMinor: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
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: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PaymentProvider {
|
||||
readonly method: PaymentMethodType;
|
||||
initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult>;
|
||||
queryStatus(merchantOrderId: string): Promise<ProviderStatus>;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type {
|
||||
PaymentProvider,
|
||||
ProviderInitiationInput,
|
||||
ProviderInitiationResult,
|
||||
ProviderStatus,
|
||||
ClientAction,
|
||||
} from '../payments.types';
|
||||
|
||||
export const PAYMENT_PROVIDERS = Symbol('PAYMENT_PROVIDERS');
|
||||
@@ -0,0 +1,98 @@
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
const EXCLUDE_FIELDS = new Set([
|
||||
'sign',
|
||||
'sign_type',
|
||||
'header',
|
||||
'refund_info',
|
||||
'openType',
|
||||
'raw_request',
|
||||
'biz_content',
|
||||
]);
|
||||
|
||||
const NONCE_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
|
||||
export function buildCanonicalString(requestObject: Record<string, unknown>): string {
|
||||
const fieldMap: Record<string, unknown> = {};
|
||||
|
||||
for (const key of Object.keys(requestObject)) {
|
||||
if (EXCLUDE_FIELDS.has(key)) continue;
|
||||
fieldMap[key] = requestObject[key];
|
||||
}
|
||||
|
||||
const biz = requestObject['biz_content'];
|
||||
if (biz && typeof biz === 'object') {
|
||||
for (const key of Object.keys(biz as Record<string, unknown>)) {
|
||||
if (EXCLUDE_FIELDS.has(key)) continue;
|
||||
fieldMap[key] = (biz as Record<string, unknown>)[key];
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(fieldMap)
|
||||
.sort()
|
||||
.map((k) => `${k}=${fieldMap[k]}`)
|
||||
.join('&');
|
||||
}
|
||||
|
||||
export function signRequestObject(
|
||||
requestObject: Record<string, unknown>,
|
||||
privateKey: string,
|
||||
): string {
|
||||
return signString(buildCanonicalString(requestObject), privateKey);
|
||||
}
|
||||
|
||||
export function verifyRequestObject(
|
||||
requestObject: Record<string, unknown>,
|
||||
publicKey: string,
|
||||
): boolean {
|
||||
const signature = requestObject['sign'];
|
||||
if (typeof signature !== 'string' || signature.length === 0) return false;
|
||||
return verifySignature(buildCanonicalString(requestObject), signature, publicKey);
|
||||
}
|
||||
|
||||
export function signString(text: string, privateKey: string): string {
|
||||
const signature = crypto.sign('sha256', Buffer.from(text), {
|
||||
key: privateKey,
|
||||
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
|
||||
saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST,
|
||||
});
|
||||
return signature.toString('base64');
|
||||
}
|
||||
|
||||
export function verifySignature(
|
||||
text: string,
|
||||
signatureBase64: string,
|
||||
publicKey: string,
|
||||
): boolean {
|
||||
try {
|
||||
return crypto.verify(
|
||||
'sha256',
|
||||
Buffer.from(text),
|
||||
{
|
||||
key: publicKey,
|
||||
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
|
||||
saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST,
|
||||
},
|
||||
Buffer.from(signatureBase64, 'base64'),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function createTimestamp(): string {
|
||||
return Math.round(Date.now() / 1000).toString();
|
||||
}
|
||||
|
||||
export function createNonceStr(length = 32): string {
|
||||
const bytes = crypto.randomBytes(length);
|
||||
let out = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
out += NONCE_CHARS[bytes[i] % NONCE_CHARS.length];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function createMerchantOrderId(): string {
|
||||
return `${Date.now()}${crypto.randomBytes(4).toString('hex')}`;
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
||||
import { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import * as https from 'node:https';
|
||||
import {
|
||||
PaymentProvider,
|
||||
ProviderInitiationInput,
|
||||
ProviderInitiationResult,
|
||||
ProviderStatus,
|
||||
} from '../payments.types';
|
||||
import {
|
||||
createNonceStr,
|
||||
createTimestamp,
|
||||
signRequestObject,
|
||||
verifyRequestObject,
|
||||
} from './telebirr.crypto';
|
||||
import {
|
||||
CreateOrderRequest,
|
||||
CreateOrderResponse,
|
||||
FabricTokenResponse,
|
||||
QueryOrderResponse,
|
||||
} from './telebirr.types';
|
||||
|
||||
const TELEBIRR_HTTP_TIMEOUT_MS = 10_000;
|
||||
|
||||
@Injectable()
|
||||
export class TelebirrProvider implements PaymentProvider {
|
||||
readonly method = PaymentMethodType.TELEBIRR;
|
||||
private readonly logger = new Logger(TelebirrProvider.name);
|
||||
private readonly httpsAgent: https.Agent;
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly http: HttpService,
|
||||
) {
|
||||
const insecure = this.config.get<boolean>('telebirr.insecureTls');
|
||||
if (insecure) {
|
||||
this.logger.warn('TELEBIRR_INSECURE_TLS=true — TLS verification disabled for Telebirr calls. DEV ONLY.');
|
||||
}
|
||||
this.httpsAgent = new https.Agent({
|
||||
rejectUnauthorized: !insecure,
|
||||
secureProtocol: 'TLSv1_2_method',
|
||||
});
|
||||
}
|
||||
|
||||
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
|
||||
const fabricToken = await this.applyFabricToken();
|
||||
const requestBody = this.buildCreateOrderRequest(input);
|
||||
const response = await this.requestCreateOrder(fabricToken, requestBody);
|
||||
|
||||
const prepayId = response.biz_content?.prepay_id;
|
||||
if (!prepayId) {
|
||||
throw new Error(
|
||||
`Telebirr createOrder returned no prepay_id: ${JSON.stringify(response)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const checkoutUrl = this.buildCheckoutUrl(prepayId);
|
||||
const expiresAt = this.computeExpiresAt(requestBody.biz_content.timeout_express);
|
||||
|
||||
return {
|
||||
providerOrderId: prepayId,
|
||||
clientAction: { type: 'REDIRECT', url: checkoutUrl },
|
||||
expiresAt,
|
||||
rawInitiation: {
|
||||
request: this.sanitize(requestBody),
|
||||
response,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
|
||||
const fabricToken = await this.applyFabricToken();
|
||||
const requestBody = this.buildQueryOrderRequest(merchantOrderId);
|
||||
const response = await this.postJson<QueryOrderResponse>(
|
||||
`${this.baseUrl}/payment/v1/merchant/queryOrder`,
|
||||
requestBody,
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'X-APP-Key': this.fabricAppId,
|
||||
Authorization: fabricToken,
|
||||
},
|
||||
);
|
||||
|
||||
const tradeStatus = response.biz_content?.trade_status;
|
||||
const providerTxnId =
|
||||
response.biz_content?.trans_id ?? response.biz_content?.payment_order_id;
|
||||
const mapped = this.mapTradeStatus(tradeStatus);
|
||||
|
||||
return {
|
||||
status: mapped,
|
||||
providerTxnId,
|
||||
failureCode:
|
||||
mapped === PaymentIntentStatus.FAILED && tradeStatus ? tradeStatus : undefined,
|
||||
rawResponse: response as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
mapTradeStatus(tradeStatus: string | undefined): PaymentIntentStatus {
|
||||
switch (tradeStatus) {
|
||||
case 'PAY_SUCCESS':
|
||||
return PaymentIntentStatus.SUCCEEDED;
|
||||
case 'PAY_FAILED':
|
||||
case 'ORDER_CLOSED':
|
||||
return PaymentIntentStatus.FAILED;
|
||||
case 'WAIT_PAY':
|
||||
return PaymentIntentStatus.REQUIRES_ACTION;
|
||||
case 'PAYING':
|
||||
return PaymentIntentStatus.PROCESSING;
|
||||
default:
|
||||
return PaymentIntentStatus.PROCESSING;
|
||||
}
|
||||
}
|
||||
|
||||
mapWebhookTradeStatus(tradeStatus: string | undefined): PaymentIntentStatus {
|
||||
switch (tradeStatus) {
|
||||
case 'Completed':
|
||||
return PaymentIntentStatus.SUCCEEDED;
|
||||
case 'Failure':
|
||||
case 'Expired':
|
||||
return PaymentIntentStatus.FAILED;
|
||||
case 'Paying':
|
||||
case 'Pending':
|
||||
return PaymentIntentStatus.PROCESSING;
|
||||
default:
|
||||
return PaymentIntentStatus.PROCESSING;
|
||||
}
|
||||
}
|
||||
|
||||
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
|
||||
if (!this.publicKey) {
|
||||
this.logger.error('TELEBIRR_PUBLIC_KEY not configured; rejecting all webhooks');
|
||||
return false;
|
||||
}
|
||||
return verifyRequestObject(payload, this.publicKey);
|
||||
}
|
||||
|
||||
private async applyFabricToken(): Promise<string> {
|
||||
const response = await this.postJson<FabricTokenResponse>(
|
||||
`${this.baseUrl}/payment/v1/token`,
|
||||
{ appSecret: this.appSecret },
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'X-APP-Key': this.fabricAppId,
|
||||
},
|
||||
);
|
||||
if (!response?.token) {
|
||||
throw new Error(`Telebirr token request failed: ${JSON.stringify(response)}`);
|
||||
}
|
||||
return response.token;
|
||||
}
|
||||
|
||||
private async requestCreateOrder(
|
||||
fabricToken: string,
|
||||
body: CreateOrderRequest,
|
||||
): Promise<CreateOrderResponse> {
|
||||
return this.postJson<CreateOrderResponse>(
|
||||
`${this.baseUrl}/payment/v1/inapp/createOrder`,
|
||||
body,
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'X-APP-Key': this.fabricAppId,
|
||||
Authorization: fabricToken,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private buildCreateOrderRequest(input: ProviderInitiationInput): CreateOrderRequest {
|
||||
const totalAmount = String(input.amountMinor / 100);
|
||||
const req = {
|
||||
timestamp: createTimestamp(),
|
||||
nonce_str: createNonceStr(),
|
||||
method: 'payment.preorder' as const,
|
||||
version: '1.0' as const,
|
||||
biz_content: {
|
||||
notify_url: this.notifyUrl,
|
||||
appid: this.merchantAppId,
|
||||
merch_code: this.merchantCode,
|
||||
merch_order_id: input.merchantOrderId,
|
||||
trade_type: 'Checkout' as const,
|
||||
title: `EDR Booking ${input.bookingRef}`,
|
||||
total_amount: totalAmount,
|
||||
trans_currency: input.currency,
|
||||
timeout_express: this.timeoutExpress,
|
||||
},
|
||||
};
|
||||
const sign = signRequestObject(req as unknown as Record<string, unknown>, this.privateKey);
|
||||
return { ...req, sign, sign_type: 'SHA256WithRSA' };
|
||||
}
|
||||
|
||||
private buildQueryOrderRequest(merchantOrderId: string): Record<string, unknown> {
|
||||
const req = {
|
||||
timestamp: createTimestamp(),
|
||||
nonce_str: createNonceStr(),
|
||||
method: 'payment.queryorder',
|
||||
version: '1.0',
|
||||
biz_content: {
|
||||
appid: this.merchantAppId,
|
||||
merch_code: this.merchantCode,
|
||||
merch_order_id: merchantOrderId,
|
||||
},
|
||||
};
|
||||
const sign = signRequestObject(req as Record<string, unknown>, this.privateKey);
|
||||
return { ...req, sign, sign_type: 'SHA256WithRSA' };
|
||||
}
|
||||
|
||||
private buildCheckoutUrl(prepayId: string): string {
|
||||
const map: Record<string, string> = {
|
||||
appid: this.merchantAppId,
|
||||
merch_code: this.merchantCode,
|
||||
nonce_str: createNonceStr(),
|
||||
prepay_id: prepayId,
|
||||
timestamp: createTimestamp(),
|
||||
};
|
||||
const sign = signRequestObject(map, this.privateKey);
|
||||
const rawRequest = [
|
||||
`appid=${map.appid}`,
|
||||
`merch_code=${map.merch_code}`,
|
||||
`nonce_str=${map.nonce_str}`,
|
||||
`prepay_id=${map.prepay_id}`,
|
||||
`timestamp=${map.timestamp}`,
|
||||
'sign_type=SHA256WithRSA',
|
||||
`sign=${sign}`,
|
||||
'version=1.0',
|
||||
'trade_type=Checkout',
|
||||
].join('&');
|
||||
return `${this.webBaseUrl}${rawRequest}`;
|
||||
}
|
||||
|
||||
private computeExpiresAt(timeoutExpress: string): Date {
|
||||
const match = /^(\d+)([smhd])$/.exec(timeoutExpress);
|
||||
const minutes = match ? this.toMinutes(parseInt(match[1], 10), match[2]) : 15;
|
||||
return new Date(Date.now() + minutes * 60_000);
|
||||
}
|
||||
|
||||
private toMinutes(n: number, unit: string): number {
|
||||
switch (unit) {
|
||||
case 's': return Math.max(1, Math.round(n / 60));
|
||||
case 'm': return n;
|
||||
case 'h': return n * 60;
|
||||
case 'd': return n * 60 * 24;
|
||||
default: return 15;
|
||||
}
|
||||
}
|
||||
|
||||
private async postJson<T>(
|
||||
url: string,
|
||||
body: unknown,
|
||||
headers: Record<string, string>,
|
||||
): Promise<T> {
|
||||
const config: AxiosRequestConfig = {
|
||||
headers,
|
||||
timeout: TELEBIRR_HTTP_TIMEOUT_MS,
|
||||
httpsAgent: this.httpsAgent,
|
||||
};
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await firstValueFrom(this.http.post<T>(url, body, config));
|
||||
this.logger.debug(`Telebirr POST ${url} status=${res.status} latency=${Date.now() - started}ms`);
|
||||
return res.data;
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError) {
|
||||
this.logger.error(
|
||||
`Telebirr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)} code=${err.code} message=${err.message}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.error(`Telebirr POST ${url} threw: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private sanitize(body: CreateOrderRequest): Record<string, unknown> {
|
||||
const { sign: _sign, ...rest } = body;
|
||||
return rest;
|
||||
}
|
||||
|
||||
private get baseUrl(): string { return this.config.get<string>('telebirr.baseUrl') ?? ''; }
|
||||
private get webBaseUrl(): string { return this.config.get<string>('telebirr.webBaseUrl') ?? ''; }
|
||||
private get fabricAppId(): string { return this.config.get<string>('telebirr.fabricAppId') ?? ''; }
|
||||
private get appSecret(): string { return this.config.get<string>('telebirr.appSecret') ?? ''; }
|
||||
private get merchantAppId(): string { return this.config.get<string>('telebirr.merchantAppId') ?? ''; }
|
||||
private get merchantCode(): string { return this.config.get<string>('telebirr.merchantCode') ?? ''; }
|
||||
private get notifyUrl(): string { return this.config.get<string>('telebirr.notifyUrl') ?? ''; }
|
||||
private get timeoutExpress(): string { return this.config.get<string>('telebirr.timeoutExpress') ?? '15m'; }
|
||||
private get privateKey(): string { return this.config.get<string>('telebirr.privateKey') ?? ''; }
|
||||
private get publicKey(): string { return this.config.get<string>('telebirr.publicKey') ?? ''; }
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
export interface FabricTokenResponse {
|
||||
token: string;
|
||||
expires_in?: number | string;
|
||||
}
|
||||
|
||||
export interface CreateOrderBizContent {
|
||||
notify_url: string;
|
||||
appid: string;
|
||||
merch_code: string;
|
||||
merch_order_id: string;
|
||||
trade_type: 'Checkout' | 'InApp' | 'MiniApp';
|
||||
title: string;
|
||||
total_amount: string;
|
||||
trans_currency: string;
|
||||
timeout_express: string;
|
||||
}
|
||||
|
||||
export interface CreateOrderRequest {
|
||||
timestamp: string;
|
||||
nonce_str: string;
|
||||
method: 'payment.preorder';
|
||||
version: '1.0';
|
||||
biz_content: CreateOrderBizContent;
|
||||
sign: string;
|
||||
sign_type: 'SHA256WithRSA';
|
||||
}
|
||||
|
||||
export interface CreateOrderResponse {
|
||||
code?: string;
|
||||
msg?: string;
|
||||
biz_content?: {
|
||||
prepay_id?: string;
|
||||
receiveCode?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type TelebirrTradeStatus =
|
||||
| 'PAY_SUCCESS'
|
||||
| 'PAY_FAILED'
|
||||
| 'WAIT_PAY'
|
||||
| 'ORDER_CLOSED'
|
||||
| 'PAYING'
|
||||
| 'ACCEPTED'
|
||||
| 'REFUNDING'
|
||||
| 'REFUND_SUCCESS'
|
||||
| 'REFUND_FAILED';
|
||||
|
||||
export interface QueryOrderResponse {
|
||||
result?: 'SUCCESS' | 'FAIL';
|
||||
code?: string;
|
||||
msg?: string;
|
||||
nonce_str?: string;
|
||||
sign?: string;
|
||||
sign_type?: string;
|
||||
biz_content?: {
|
||||
merch_order_id?: string;
|
||||
order_status?: string;
|
||||
trade_status?: TelebirrTradeStatus | string;
|
||||
payment_order_id?: string;
|
||||
trans_id?: string;
|
||||
trans_time?: string;
|
||||
trans_currency?: string;
|
||||
total_amount?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
||||
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 {
|
||||
private readonly logger = new Logger(TelebirrWebhookService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly provider: TelebirrProvider,
|
||||
private readonly payments: PaymentsService,
|
||||
) {}
|
||||
|
||||
async handle(payload: TelebirrWebhookPayload): Promise<void> {
|
||||
const merchantOrderId = payload.merch_order_id;
|
||||
const externalEventId = this.buildExternalEventId(payload);
|
||||
const signatureValid = this.provider.verifyWebhookSignature(
|
||||
payload as unknown as Record<string, unknown>,
|
||||
);
|
||||
|
||||
const eventRow = await this.persistEvent({
|
||||
externalEventId,
|
||||
merchantOrderId,
|
||||
providerTxnId: payload.trans_id ?? payload.payment_order_id,
|
||||
signatureValid,
|
||||
status: payload.trade_status,
|
||||
payload,
|
||||
});
|
||||
|
||||
if (!eventRow) {
|
||||
this.logger.log(
|
||||
`Telebirr webhook duplicate: ${externalEventId} — short-circuit OK`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!signatureValid) {
|
||||
this.logger.warn(
|
||||
`Telebirr webhook signature invalid for merch_order_id=${merchantOrderId}`,
|
||||
);
|
||||
await this.markProcessed(eventRow.id, 'signature-invalid');
|
||||
return;
|
||||
}
|
||||
|
||||
const intent = await this.prisma.paymentIntent.findUnique({
|
||||
where: { merchantOrderId },
|
||||
});
|
||||
if (!intent) {
|
||||
this.logger.warn(
|
||||
`Telebirr webhook: no PaymentIntent for merch_order_id=${merchantOrderId}`,
|
||||
);
|
||||
await this.markProcessed(eventRow.id, 'intent-not-found');
|
||||
return;
|
||||
}
|
||||
|
||||
const mapped = this.provider.mapWebhookTradeStatus(payload.trade_status);
|
||||
|
||||
try {
|
||||
if (mapped === PaymentIntentStatus.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) {
|
||||
await this.payments.markPaymentFailed({
|
||||
intentId: intent.id,
|
||||
failureCode: payload.trade_status,
|
||||
});
|
||||
} else {
|
||||
await this.prisma.paymentIntent.update({
|
||||
where: { id: intent.id },
|
||||
data: { status: mapped, providerTxnId: payload.trans_id ?? undefined },
|
||||
});
|
||||
}
|
||||
await this.markProcessed(eventRow.id);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(
|
||||
`Telebirr webhook processing failed for ${merchantOrderId}: ${message}`,
|
||||
);
|
||||
await this.markProcessed(eventRow.id, `processing-error: ${message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private buildExternalEventId(payload: TelebirrWebhookPayload): string {
|
||||
return `${payload.payment_order_id}_${payload.trade_status}`;
|
||||
}
|
||||
|
||||
private async persistEvent(input: {
|
||||
externalEventId: string;
|
||||
merchantOrderId: string;
|
||||
providerTxnId?: string;
|
||||
signatureValid: boolean;
|
||||
status: string;
|
||||
payload: TelebirrWebhookPayload;
|
||||
}): Promise<{ id: string } | null> {
|
||||
try {
|
||||
return await this.prisma.paymentWebhookEvent.create({
|
||||
data: {
|
||||
provider: PaymentMethodType.TELEBIRR,
|
||||
externalEventId: input.externalEventId,
|
||||
merchantOrderId: input.merchantOrderId,
|
||||
providerTxnId: input.providerTxnId,
|
||||
signatureValid: input.signatureValid,
|
||||
status: input.status,
|
||||
payload: input.payload as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async markProcessed(eventId: string, processingError?: string): Promise<void> {
|
||||
await this.prisma.paymentWebhookEvent.update({
|
||||
where: { id: eventId },
|
||||
data: { processedAt: new Date(), processingError },
|
||||
});
|
||||
}
|
||||
|
||||
private parseEpochSeconds(raw: string | undefined): Date | undefined {
|
||||
if (!raw) return undefined;
|
||||
const n = parseInt(raw, 10);
|
||||
if (Number.isNaN(n)) return undefined;
|
||||
return new Date(n * 1000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Body, Controller, HttpCode, HttpStatus, Logger, Post } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
TelebirrWebhookPayload,
|
||||
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) {}
|
||||
|
||||
@Post('telebirr')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Telebirr payment notification callback' })
|
||||
async receiveTelebirr(@Body() payload: TelebirrWebhookPayload) {
|
||||
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' };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user