diff --git a/apps/edr-passenger-api/.env.example b/apps/edr-passenger-api/.env.example index 9d10c2b00..fa6f76e90 100644 --- a/apps/edr-passenger-api/.env.example +++ b/apps/edr-passenger-api/.env.example @@ -26,4 +26,7 @@ TELEBIRR_MERCHANT_APP_ID= TELEBIRR_MERCHANT_CODE= TELEBIRR_NOTIFY_URL= TELEBIRR_RETURN_URL= -TELEBIRR_TIMEOUT_EXPRESS=15m \ No newline at end of file +TELEBIRR_TIMEOUT_EXPRESS=15m +TELEBIRR_PRIVATE_KEY= +TELEBIRR_PUBLIC_KEY= +TELEBIRR_INSECURE_TLS=false \ No newline at end of file diff --git a/apps/edr-passenger-api/src/config/telebirr.config.ts b/apps/edr-passenger-api/src/config/telebirr.config.ts index 82b67b52d..45e3a79ff 100644 --- a/apps/edr-passenger-api/src/config/telebirr.config.ts +++ b/apps/edr-passenger-api/src/config/telebirr.config.ts @@ -10,4 +10,7 @@ export default registerAs('telebirr', () => ({ 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-passenger-api/src/modules/payments/payments.module.ts b/apps/edr-passenger-api/src/modules/payments/payments.module.ts index 9b2a3d738..50f60d52f 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.module.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.module.ts @@ -3,6 +3,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 { WebhooksController } from './webhooks/webhooks.controller'; +import { TelebirrWebhookService } from './webhooks/telebirr-webhook.service'; -@Module({ imports: [SeatsModule, TicketsModule], controllers: [PaymentsController], providers: [PaymentsService] }) +@Module({ + imports: [SeatsModule, TicketsModule], + controllers: [PaymentsController, WebhooksController], + providers: [PaymentsService, TelebirrProvider, TelebirrWebhookService], +}) export class PaymentsModule {} 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 8f1df1c7b..3ba957565 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -1,13 +1,15 @@ -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 { PaymentIntentStatus } from '@prisma/client'; import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto } from './payments.dto'; import { telebirrAdapter, cbeBirrAdapter, eBirrAdapter, cardAdapter } from './payments.adapters'; @Injectable() export class PaymentsService { + private readonly logger = new Logger(PaymentsService.name); constructor( private prisma: PrismaService, private seatsService: SeatsService, @@ -69,6 +71,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 { + 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 } }); diff --git a/apps/edr-passenger-api/src/modules/payments/providers/telebirr.crypto.ts b/apps/edr-passenger-api/src/modules/payments/providers/telebirr.crypto.ts new file mode 100644 index 000000000..12ea9624a --- /dev/null +++ b/apps/edr-passenger-api/src/modules/payments/providers/telebirr.crypto.ts @@ -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 { + const fieldMap: Record = {}; + + 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)) { + if (EXCLUDE_FIELDS.has(key)) continue; + fieldMap[key] = (biz as Record)[key]; + } + } + + return Object.keys(fieldMap) + .sort() + .map((k) => `${k}=${fieldMap[k]}`) + .join('&'); +} + +export function signRequestObject( + requestObject: Record, + privateKey: string, +): string { + return signString(buildCanonicalString(requestObject), privateKey); +} + +export function verifyRequestObject( + requestObject: Record, + 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')}`; +} diff --git a/apps/edr-passenger-api/src/modules/payments/providers/telebirr.provider.ts b/apps/edr-passenger-api/src/modules/payments/providers/telebirr.provider.ts new file mode 100644 index 000000000..cedf8aeeb --- /dev/null +++ b/apps/edr-passenger-api/src/modules/payments/providers/telebirr.provider.ts @@ -0,0 +1,274 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client'; +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); + + constructor(private readonly config: ConfigService) {} + + async initiate(input: ProviderInitiationInput): Promise { + 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 { + const fabricToken = await this.applyFabricToken(); + const requestBody = this.buildQueryOrderRequest(merchantOrderId); + const response = await this.postJson( + `${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, + }; + } + + 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): 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 { + const response = await this.postJson( + `${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 { + return this.postJson( + `${this.baseUrl}/payment/v1/merchant/preOrder`, + body, + { + 'Content-Type': 'application/json', + 'X-APP-Key': this.fabricAppId, + Authorization: fabricToken, + }, + ); + } + + private buildCreateOrderRequest(input: ProviderInitiationInput): CreateOrderRequest { + const totalAmount = (input.amountMinor / 100).toFixed(2); + 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, this.privateKey); + return { ...req, sign, sign_type: 'SHA256WithRSA' }; + } + + private buildQueryOrderRequest(merchantOrderId: string): Record { + 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, this.privateKey); + return { ...req, sign, sign_type: 'SHA256WithRSA' }; + } + + private buildCheckoutUrl(prepayId: string): string { + const fields: Record = { + appid: this.merchantAppId, + merch_code: this.merchantCode, + nonce_str: createNonceStr(), + prepay_id: prepayId, + timestamp: createTimestamp(), + }; + const sign = signRequestObject(fields, this.privateKey); + const query = [ + `appid=${fields.appid}`, + `merch_code=${fields.merch_code}`, + `nonce_str=${fields.nonce_str}`, + `prepay_id=${fields.prepay_id}`, + `timestamp=${fields.timestamp}`, + `sign=${encodeURIComponent(sign)}`, + 'sign_type=SHA256WithRSA', + 'version=1.0', + 'trade_type=Checkout', + ].join('&'); + return `${this.webBaseUrl}${query}`; + } + + 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( + url: string, + body: unknown, + headers: Record, + ): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TELEBIRR_HTTP_TIMEOUT_MS); + const started = Date.now(); + try { + const res = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + signal: controller.signal, + }); + const latency = Date.now() - started; + const text = await res.text(); + this.logger.debug(`Telebirr POST ${url} status=${res.status} latency=${latency}ms`); + if (!res.ok) { + throw new Error(`Telebirr request failed: ${res.status} ${text}`); + } + return JSON.parse(text) as T; + } finally { + clearTimeout(timer); + } + } + + private sanitize(body: CreateOrderRequest): Record { + const { sign: _sign, ...rest } = body; + return rest; + } + + private get baseUrl(): string { return this.config.get('telebirr.baseUrl') ?? ''; } + private get webBaseUrl(): string { return this.config.get('telebirr.webBaseUrl') ?? ''; } + private get fabricAppId(): string { return this.config.get('telebirr.fabricAppId') ?? ''; } + private get appSecret(): string { return this.config.get('telebirr.appSecret') ?? ''; } + private get merchantAppId(): string { return this.config.get('telebirr.merchantAppId') ?? ''; } + private get merchantCode(): string { return this.config.get('telebirr.merchantCode') ?? ''; } + private get notifyUrl(): string { return this.config.get('telebirr.notifyUrl') ?? ''; } + private get timeoutExpress(): string { return this.config.get('telebirr.timeoutExpress') ?? '15m'; } + private get privateKey(): string { return this.config.get('telebirr.privateKey') ?? ''; } + private get publicKey(): string { return this.config.get('telebirr.publicKey') ?? ''; } +} diff --git a/apps/edr-passenger-api/src/modules/payments/providers/telebirr.types.ts b/apps/edr-passenger-api/src/modules/payments/providers/telebirr.types.ts new file mode 100644 index 000000000..b540c1551 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/payments/providers/telebirr.types.ts @@ -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; +} 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 new file mode 100644 index 000000000..bb9b2127e --- /dev/null +++ b/apps/edr-passenger-api/src/modules/payments/webhooks/telebirr-webhook.service.ts @@ -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 { + const merchantOrderId = payload.merch_order_id; + const externalEventId = this.buildExternalEventId(payload); + const signatureValid = this.provider.verifyWebhookSignature( + payload as unknown as Record, + ); + + 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 { + 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); + } +} 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 new file mode 100644 index 000000000..3d5e0a3a0 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/payments/webhooks/webhooks.controller.ts @@ -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' }; + } +}