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 373e10513..a64c4ae9c 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -79,6 +79,28 @@ export class PaymentsController { return this.service.getIntentByBookingId(bookingId); } + @Get("waafi/return") + @ApiOperation({ + summary: + "DEMO ONLY — confirm a Waafi payment from the browser-return params and return JSON for the " + + "UI to display. The frontend success page forwards the Waafi query params here. Gated by " + + "WAAFI_DEMO_TRUST_RETURN (INSECURE; real confirmation is the webhook/HPP_GETTRANINFO).", + }) + @ApiQuery({ name: "referenceId", required: true }) + @ApiQuery({ name: "state", required: true }) + @ApiQuery({ name: "transactionId", required: false }) + waafiReturn( + @Query("referenceId") referenceId: string, + @Query("state") state: string, + @Query("transactionId") transactionId: string, + ) { + return this.service.confirmWaafiReturnDemo({ + referenceId, + state, + transactionId, + }); + } + @Post("refund") @UseGuards(JwtGuard, RolesGuard) @Roles(UserRole.ADMIN, UserRole.STAFF, UserRole.AGENT) 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 c017c6332..c8d0580bf 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -44,6 +44,8 @@ export class PaymentsService { private readonly logger = new Logger(PaymentsService.name); private readonly walletDemoAutoSucceed = true; + private readonly waafiDemoTrustReturn = true; + constructor( private prisma: PrismaService, private seatsService: SeatsService, @@ -192,6 +194,41 @@ export class PaymentsService { return { returnUrl, failureUrl }; } + async confirmWaafiReturnDemo(params: { + referenceId?: string; + state?: string; + transactionId?: string; + }): Promise<{ confirmed: boolean; bookingId?: string; reason?: string }> { + if (!this.waafiDemoTrustReturn) { + return { confirmed: false, reason: "demo-disabled" }; + } + if ((params.state ?? "").toUpperCase() !== "APPROVED") { + return { confirmed: false, reason: `not-approved (${params.state})` }; + } + if (!params.referenceId) { + return { confirmed: false, reason: "missing-referenceId" }; + } + + const intent = await this.prisma.paymentIntent.findFirst({ + where: { merchantOrderId: params.referenceId }, + }); + if (!intent) { + this.logger.warn( + `waafi demo return: no local intent for referenceId ${params.referenceId}`, + ); + return { confirmed: false, reason: "intent-not-found" }; + } + + this.logger.warn( + `WAAFI_DEMO_TRUST_RETURN enabled — confirming booking ${intent.bookingId} from browser return (INSECURE, demo only)`, + ); + await this.finalizePaymentSuccess({ + intentId: intent.id, + providerTxnId: params.transactionId, + }); + return { confirmed: true, bookingId: intent.bookingId }; + } + private async syncIntentProjection( bookingId: string, snapshot: PaymentIntentSnapshot, @@ -453,6 +490,30 @@ export class PaymentsService { }); } + /** + * Guard against an implausible paidAt from a provider event (e.g. a Telebirr epoch parsed as + * ms×1000 → year 58429), which Prisma/Postgres rejects and would otherwise dead-letter the + * whole confirmation. Falls back to "now" for missing/invalid/far-future/ancient values so the + * booking still confirms. + */ + private sanitizePaidAt(value?: Date): Date { + const now = new Date(); + if (!value) return now; + const t = value.getTime(); + const oneDayMs = 86_400_000; + if ( + Number.isNaN(t) || + t > now.getTime() + oneDayMs || + t < Date.UTC(2000, 0, 1) + ) { + this.logger.warn( + `finalizePaymentSuccess: implausible paidAt (epoch=${t}); using current time instead`, + ); + return now; + } + return value; + } + async finalizePaymentSuccess(input: { intentId: string; providerTxnId?: string; @@ -477,7 +538,7 @@ export class PaymentsService { }); if (!booking) throw new NotFoundException("Booking not found"); - const paidAt = input.paidAt ?? new Date(); + const paidAt = this.sanitizePaidAt(input.paidAt); await this.prisma.$transaction(async (tx) => { await tx.paymentIntent.update({ where: { id: intent.id }, diff --git a/packages/payment-providers/src/providers/waafi/waafi.provider.ts b/packages/payment-providers/src/providers/waafi/waafi.provider.ts index 8551f6086..dbd04c642 100644 --- a/packages/payment-providers/src/providers/waafi/waafi.provider.ts +++ b/packages/payment-providers/src/providers/waafi/waafi.provider.ts @@ -86,6 +86,21 @@ export class WaafiProvider implements PaymentProvider { requestBody, ); + // Waafi returns transaction info (params.status) ONLY when responseCode is 2001. For an + // unpaid or not-yet-existing transaction it returns an error envelope (e.g. 5001 / E10206 + // "Failed to get transaction info") with no status. Treat that as still-pending (PROCESSING), + // never terminal — so the intent keeps waiting for the webhook / its expiry rather than being + // wrongly resolved off a "no info" response. + if (response.responseCode !== WAAFI_SUCCESS_CODE) { + this.logger.debug( + `Waafi HPP_GETTRANINFO ${merchantOrderId}: ${response.responseCode}/${response.errorCode} ${response.responseMsg} — treating as pending`, + ); + return { + status: ProviderPaymentStatus.PROCESSING, + rawResponse: response as unknown as Record, + }; + } + const rawState = response.params?.status ?? response.params?.tranStatusDesc; const transactionId = response.params?.transactionId; const mapped = this.mapStatus(rawState);