feat: ( payment ) create payment microservice

This commit is contained in:
Abubeker Yasin
2026-06-05 21:29:55 +03:00
parent 3922d813b1
commit 1d11cbda4d
44 changed files with 1172 additions and 266 deletions

View File

@@ -0,0 +1,65 @@
import { BaseEntity } from "@edr/api-common";
import { ProviderMethod, ProviderPaymentStatus } from "@edr/types";
import { Column, Entity, JoinColumn, ManyToOne } from "typeorm";
import { Booking } from "../../bookings/entities/booking.entity";
@Entity({ name: "payment_intents" })
export class PaymentIntent extends BaseEntity {
@Column({ name: "booking_id", type: "uuid", unique: true })
bookingId!: string;
@ManyToOne(() => Booking)
@JoinColumn({ name: "booking_id" })
booking?: Booking;
@Column({ name: "amount_minor", type: "int" })
amountMinor!: number;
@Column({ name: "currency", type: "varchar", length: 8, default: "ETB" })
currency!: string;
@Column({ name: "method", type: "enum", enum: ProviderMethod })
method!: ProviderMethod;
@Column({
name: "status",
type: "enum",
enum: ProviderPaymentStatus,
default: ProviderPaymentStatus.REQUIRES_ACTION,
})
status!: ProviderPaymentStatus;
@Column({
name: "merchant_order_id",
type: "varchar",
length: 64,
unique: true,
nullable: true,
})
merchantOrderId?: string | null;
@Column({ name: "provider_order_id", type: "varchar", length: 128, nullable: true })
providerOrderId?: string | null;
@Column({ name: "provider_txn_id", type: "varchar", length: 128, nullable: true })
providerTxnId?: string | null;
@Column({ name: "client_action", type: "jsonb", nullable: true })
clientAction?: Record<string, unknown> | null;
@Column({ name: "raw_initiation", type: "jsonb", nullable: true })
rawInitiation?: Record<string, unknown> | null;
@Column({ name: "expires_at", type: "timestamptz", nullable: true })
expiresAt?: Date | null;
@Column({ name: "paid_at", type: "timestamptz", nullable: true })
paidAt?: Date | null;
@Column({ name: "failure_code", type: "varchar", length: 128, nullable: true })
failureCode?: string | null;
@Column({ name: "failure_message", type: "text", nullable: true })
failureMessage?: string | null;
}

View File

@@ -0,0 +1,33 @@
import { BaseEntity } from "@edr/api-common";
import { ProviderMethod } from "@edr/types";
import { Column, Entity } from "typeorm";
@Entity({ name: "payment_webhook_events" })
export class PaymentWebhookEvent extends BaseEntity {
@Column({ name: "provider", type: "enum", enum: ProviderMethod })
provider!: ProviderMethod;
@Column({ name: "external_event_id", type: "varchar", length: 256, unique: true })
externalEventId!: string;
@Column({ name: "merchant_order_id", type: "varchar", length: 64 })
merchantOrderId!: string;
@Column({ name: "provider_txn_id", type: "varchar", length: 128, nullable: true })
providerTxnId?: string | null;
@Column({ name: "signature_valid", type: "boolean" })
signatureValid!: boolean;
@Column({ name: "status", type: "varchar", length: 64 })
status!: string;
@Column({ name: "payload", type: "jsonb" })
payload!: Record<string, unknown>;
@Column({ name: "processed_at", type: "timestamptz", nullable: true })
processedAt?: Date | null;
@Column({ name: "processing_error", type: "text", nullable: true })
processingError?: string | null;
}

View File

@@ -0,0 +1,27 @@
import { Body, Controller, Get, Param, Post } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import {
InitiatePaymentDto,
InitiateResponseDto,
IntentStatusDto,
} from "./payments.dto";
import { PaymentsService } from "./payments.service";
@ApiTags("Payments")
@Controller("payments")
export class PaymentsController {
constructor(private readonly payments: PaymentsService) {}
@Post("initiate")
@ApiOperation({ summary: "Initiate a payment for a freight booking" })
initiate(@Body() dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
return this.payments.initiatePayment(dto);
}
@Get(":bookingId")
@ApiOperation({ summary: "Get the payment intent status for a booking" })
getStatus(@Param("bookingId") bookingId: string): Promise<IntentStatusDto> {
return this.payments.getIntentByBookingId(bookingId);
}
}

View File

@@ -0,0 +1,62 @@
import { ProviderPaymentStatus } from "@edr/types";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsOptional, IsString } from "class-validator";
export type PaymentPlatformDto = "web" | "mobile";
export class InitiatePaymentDto {
@ApiProperty({ example: "booking-uuid" })
@IsString()
bookingId!: string;
@ApiProperty({ enum: ["TELEBIRR"], example: "TELEBIRR" })
@IsIn(["TELEBIRR"])
method!: "TELEBIRR";
@ApiPropertyOptional({ enum: ["web", "mobile"], default: "web" })
@IsOptional()
@IsIn(["web", "mobile"])
platform?: PaymentPlatformDto;
}
export class ClientActionDto {
@ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP"] })
type!: "REDIRECT" | "LAUNCH_APP";
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
url?: string;
@ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" })
appId?: string;
@ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" })
receiveCode?: string;
@ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" })
shortCode?: string;
}
export class InitiateResponseDto {
@ApiProperty()
intentId!: string;
@ApiProperty({ enum: ProviderPaymentStatus })
status!: ProviderPaymentStatus;
@ApiPropertyOptional({ type: ClientActionDto })
clientAction?: ClientActionDto;
@ApiPropertyOptional()
merchantOrderId?: string;
}
export class IntentStatusDto extends InitiateResponseDto {
@ApiPropertyOptional()
paidAt?: string;
@ApiPropertyOptional()
failureCode?: string;
@ApiPropertyOptional()
failureMessage?: string;
}

View File

@@ -0,0 +1,33 @@
import { HttpModule } from "@nestjs/axios";
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { TelebirrProvider } from "@edr/payment-providers";
import { BookingsModule } from "../bookings/bookings.module";
import { PaymentIntent } from "./entities/payment-intent.entity";
import { PaymentWebhookEvent } from "./entities/payment-webhook-event.entity";
import { PaymentsController } from "./payments.controller";
import {
PaymentIntentRepository,
PaymentWebhookEventRepository,
} from "./payments.repository";
import { PaymentsService } from "./payments.service";
import { TelebirrWebhookService } from "./webhooks/telebirr-webhook.service";
import { WebhooksController } from "./webhooks/webhooks.controller";
@Module({
imports: [
BookingsModule,
TypeOrmModule.forFeature([PaymentIntent, PaymentWebhookEvent]),
HttpModule.register({ timeout: 10_000 }),
],
controllers: [PaymentsController, WebhooksController],
providers: [
PaymentsService,
PaymentIntentRepository,
PaymentWebhookEventRepository,
TelebirrProvider,
TelebirrWebhookService,
],
})
export class PaymentsModule {}

View File

@@ -0,0 +1,67 @@
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { DeepPartial, QueryFailedError, Repository } from "typeorm";
import { PaymentIntent } from "./entities/payment-intent.entity";
import { PaymentWebhookEvent } from "./entities/payment-webhook-event.entity";
const PG_UNIQUE_VIOLATION = "23505";
function isUniqueViolation(err: unknown): boolean {
return (
err instanceof QueryFailedError &&
(err as QueryFailedError & { driverError?: { code?: string } }).driverError
?.code === PG_UNIQUE_VIOLATION
);
}
@Injectable()
export class PaymentIntentRepository extends BaseRepository<PaymentIntent> {
constructor(
@InjectRepository(PaymentIntent)
repository: Repository<PaymentIntent>,
) {
super(repository);
}
findByBookingId(bookingId: string): Promise<PaymentIntent | null> {
return this.repository.findOne({ where: { bookingId } });
}
findByMerchantOrderId(merchantOrderId: string): Promise<PaymentIntent | null> {
return this.repository.findOne({ where: { merchantOrderId } });
}
}
@Injectable()
export class PaymentWebhookEventRepository extends BaseRepository<PaymentWebhookEvent> {
constructor(
@InjectRepository(PaymentWebhookEvent)
repository: Repository<PaymentWebhookEvent>,
) {
super(repository);
}
/**
* Insert a webhook event, returning null if an event with the same
* externalEventId already exists (deduplication via unique constraint).
*/
async createIfNew(
data: DeepPartial<PaymentWebhookEvent>,
): Promise<PaymentWebhookEvent | null> {
try {
return await this.repository.save(this.repository.create(data));
} catch (err) {
if (isUniqueViolation(err)) return null;
throw err;
}
}
async markProcessed(id: string, processingError?: string): Promise<void> {
await this.repository.update(id, {
processedAt: new Date(),
processingError: processingError ?? null,
});
}
}

View File

@@ -0,0 +1,212 @@
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from "@nestjs/common";
import { EventEmitter2 } from "@nestjs/event-emitter";
import {
ClientAction,
createMerchantOrderId,
PaymentProvider,
ProviderMethod,
ProviderPaymentStatus,
TelebirrProvider,
} from "@edr/payment-providers";
import { Freight } from "@edr/types";
import { DataSource } from "typeorm";
import { BookingsService } from "../bookings/bookings.service";
import { Booking } from "../bookings/entities/booking.entity";
import { PaymentIntent } from "./entities/payment-intent.entity";
import { PaymentIntentRepository } from "./payments.repository";
import {
InitiatePaymentDto,
InitiateResponseDto,
IntentStatusDto,
PaymentPlatformDto,
} from "./payments.dto";
const NON_TERMINAL_STATUSES: ProviderPaymentStatus[] = [
ProviderPaymentStatus.REQUIRES_ACTION,
ProviderPaymentStatus.PROCESSING,
ProviderPaymentStatus.SUCCEEDED,
];
const DEFAULT_CURRENCY = "ETB";
@Injectable()
export class PaymentsService {
private readonly logger = new Logger(PaymentsService.name);
private readonly providers: Map<ProviderMethod, PaymentProvider>;
constructor(
private readonly dataSource: DataSource,
private readonly intents: PaymentIntentRepository,
private readonly bookings: BookingsService,
private readonly telebirrProvider: TelebirrProvider,
private readonly eventEmitter: EventEmitter2,
) {
this.providers = new Map<ProviderMethod, PaymentProvider>([
[ProviderMethod.TELEBIRR, this.telebirrProvider],
// add ProviderMethod.CBE_BIRR etc. here when enabled for freight
]);
}
async initiatePayment(dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
const booking = await this.bookings.findById(dto.bookingId);
if (booking.paymentStatus !== Freight.PaymentStatus.Pending) {
throw new BadRequestException("Booking is not payable");
}
const existing = await this.intents.findByBookingId(dto.bookingId);
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
return this.formatIntentResponse(existing);
}
const method = dto.method as unknown as ProviderMethod;
const provider = this.providers.get(method);
if (!provider) {
throw new BadRequestException(`Unsupported payment method: ${method}`);
}
return this.initiateProviderPayment(booking, provider, dto.platform);
}
private async initiateProviderPayment(
booking: Booking,
provider: PaymentProvider,
platform: PaymentPlatformDto | undefined,
): Promise<InitiateResponseDto> {
const merchantOrderId = createMerchantOrderId();
const amountMinor = Math.round(Number(booking.totalAmount) * 100);
const result = await provider.initiate({
merchantOrderId,
orderRef: booking.reference,
amountMinor,
currency: DEFAULT_CURRENCY,
platform,
});
this.logger.log(
`Initiated ${provider.method} payment for booking ${booking.reference} (merchantOrderId=${merchantOrderId})`,
);
const data = {
bookingId: booking.id,
amountMinor,
currency: DEFAULT_CURRENCY,
method: provider.method,
status: ProviderPaymentStatus.REQUIRES_ACTION,
merchantOrderId,
providerOrderId: result.providerOrderId,
clientAction: result.clientAction as unknown as Record<string, unknown>,
rawInitiation: result.rawInitiation,
expiresAt: result.expiresAt,
failureCode: null,
failureMessage: null,
};
const existing = await this.intents.findByBookingId(booking.id);
const intent = existing
? await this.intents.update(existing.id, data)
: await this.intents.create(data);
return this.formatIntentResponse(intent!);
}
async getIntentByBookingId(bookingId: string): Promise<IntentStatusDto> {
const intent = await this.intents.findByBookingId(bookingId);
if (!intent) throw new NotFoundException("PaymentIntent not found");
return this.formatIntentStatus(intent);
}
async finalizePaymentSuccess(input: {
intentId: string;
providerTxnId?: string;
paidAt?: Date;
}): Promise<{ alreadyFinalized: boolean }> {
const intent = await this.intents.findById(input.intentId);
if (!intent) throw new NotFoundException("PaymentIntent not found");
if (intent.status === ProviderPaymentStatus.SUCCEEDED) {
return { alreadyFinalized: true };
}
if (intent.status === ProviderPaymentStatus.CANCELLED) {
throw new BadRequestException("PaymentIntent is cancelled; cannot finalize");
}
const booking = await this.bookings.findById(intent.bookingId);
const paidAt = input.paidAt ?? new Date();
await this.dataSource.transaction(async (manager) => {
await manager.update(
PaymentIntent,
{ id: intent.id },
{
status: ProviderPaymentStatus.SUCCEEDED,
providerTxnId: input.providerTxnId ?? intent.providerTxnId ?? null,
paidAt,
},
);
// FREIGHT-SPECIFIC: mark the booking paid and confirm it.
await manager.update(
Booking,
{ id: booking.id },
{
paymentStatus: Freight.PaymentStatus.Paid,
status: Freight.BookingStatus.Confirmed,
},
);
});
this.eventEmitter.emit("payment.succeeded", {
bookingId: booking.id,
reference: booking.reference,
});
return { alreadyFinalized: false };
}
async markPaymentFailed(input: {
intentId: string;
failureCode?: string;
failureMessage?: string;
}): Promise<void> {
const intent = await this.intents.findById(input.intentId);
if (!intent) throw new NotFoundException("PaymentIntent not found");
if (
intent.status === ProviderPaymentStatus.SUCCEEDED ||
intent.status === ProviderPaymentStatus.CANCELLED
) {
return;
}
await this.intents.update(intent.id, {
status: ProviderPaymentStatus.FAILED,
failureCode: input.failureCode ?? null,
failureMessage: input.failureMessage ?? null,
});
}
private formatIntentResponse(intent: PaymentIntent): InitiateResponseDto {
const clientAction =
intent.clientAction && typeof intent.clientAction === "object"
? (intent.clientAction as unknown as ClientAction)
: undefined;
return {
intentId: intent.id,
status: intent.status,
clientAction,
merchantOrderId: intent.merchantOrderId ?? undefined,
};
}
private formatIntentStatus(intent: PaymentIntent): IntentStatusDto {
return {
...this.formatIntentResponse(intent),
paidAt: intent.paidAt?.toISOString(),
failureCode: intent.failureCode ?? undefined,
failureMessage: intent.failureMessage ?? undefined,
};
}
}

View File

@@ -0,0 +1,101 @@
import { Injectable, Logger } from "@nestjs/common";
import {
ProviderMethod,
ProviderPaymentStatus,
TelebirrProvider,
TelebirrWebhookPayload,
} from "@edr/payment-providers";
import { PaymentsService } from "../payments.service";
import {
PaymentIntentRepository,
PaymentWebhookEventRepository,
} from "../payments.repository";
@Injectable()
export class TelebirrWebhookService {
private readonly logger = new Logger(TelebirrWebhookService.name);
constructor(
private readonly events: PaymentWebhookEventRepository,
private readonly intents: PaymentIntentRepository,
private readonly provider: TelebirrProvider,
private readonly payments: PaymentsService,
) {}
async handle(payload: TelebirrWebhookPayload): Promise<void> {
const merchantOrderId = payload.merch_order_id;
const externalEventId = this.buildExternalEventId(payload);
// TODO: re-enable Telebirr public-key signature verification — skipped for now
// (matches the passenger implementation; see TelebirrProvider.verifyWebhookSignature)
const signatureValid = true;
const eventRow = await this.events.createIfNew({
provider: ProviderMethod.TELEBIRR,
externalEventId,
merchantOrderId,
providerTxnId: payload.trans_id ?? payload.payment_order_id,
signatureValid,
status: payload.trade_status,
payload: payload as unknown as Record<string, unknown>,
});
if (!eventRow) {
this.logger.log(
`Telebirr webhook duplicate: ${externalEventId} — short-circuit OK`,
);
return;
}
const intent = await this.intents.findByMerchantOrderId(merchantOrderId);
if (!intent) {
this.logger.warn(
`Telebirr webhook: no PaymentIntent for merch_order_id=${merchantOrderId}`,
);
await this.events.markProcessed(eventRow.id, "intent-not-found");
return;
}
const mapped = this.provider.mapWebhookTradeStatus(payload.trade_status);
try {
if (mapped === ProviderPaymentStatus.SUCCEEDED) {
await this.payments.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: payload.trans_id ?? payload.payment_order_id,
paidAt: this.parseEpochSeconds(payload.trans_end_time),
});
} else if (mapped === ProviderPaymentStatus.FAILED) {
await this.payments.markPaymentFailed({
intentId: intent.id,
failureCode: payload.trade_status,
});
} else {
await this.intents.update(intent.id, {
status: mapped,
providerTxnId: payload.trans_id ?? undefined,
});
}
await this.events.markProcessed(eventRow.id);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(
`Telebirr webhook processing failed for ${merchantOrderId}: ${message}`,
);
await this.events.markProcessed(eventRow.id, `processing-error: ${message}`);
throw err;
}
}
private buildExternalEventId(payload: TelebirrWebhookPayload): string {
return `${payload.payment_order_id}_${payload.trade_status}`;
}
private parseEpochSeconds(raw: string | undefined): Date | undefined {
if (!raw) return undefined;
const n = parseInt(raw, 10);
if (Number.isNaN(n)) return undefined;
return new Date(n * 1000);
}
}

View File

@@ -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" };
}
}