import { Body, Controller, Delete, Get, Headers, HttpStatus, Param, Patch, Post, Query, Res, SetMetadata, 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, BookingAmountResponseDto, ForceConfirmDto, ConfirmOtpDto, } from "./payments.dto"; import { PassengerStaff } from "../../common/passenger-guards"; import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; import { resolveAllowedOrigin } from "../../common/utils/redirect-origin.util"; import { SupplementaryChargesService } from "./supplementary-charges.service"; import { IsString, IsInt, IsOptional, Min, IsEnum, IsIn } from "class-validator"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; class CreateSupplementaryChargeDto { @ApiProperty({ example: 'EDR-20240001', description: 'Booking reference number' }) @IsString() bookingRef: string; @ApiProperty({ description: 'Amount owed in minor units (e.g. 5000 = 50 ETB)' }) @IsInt() @Min(1) amountMinor: number; @ApiProperty({ example: 'UNDERPAYMENT' }) @IsString() reason: string; @ApiPropertyOptional() @IsOptional() @IsString() notes?: string; } class WaiveSupplementaryChargeDto { @ApiPropertyOptional() @IsOptional() @IsString() notes?: string; } class PaySupplementaryChargeDto { @ApiProperty({ enum: PaymentMethodTypeEnum, example: 'TELEBIRR' }) @IsEnum(PaymentMethodTypeEnum) method: PaymentMethodTypeEnum; @ApiPropertyOptional({ enum: ['web', 'mobile', 'inapp'], default: 'web' }) @IsOptional() @IsIn(['web', 'mobile', 'inapp']) platform?: PaymentPlatformDto; } @ApiTags("Payment") @Controller("payments") // @Throttle({ strict: { limit: 20, ttl: 60_000 } }) export class PaymentsController { constructor( private service: PaymentsService, private supplementaryService: SupplementaryChargesService, ) {} @Delete(":id") @PassengerStaff([PASSENGER_PERMS.admin]) @ApiBearerAuth("IAM-auth") @ApiOperation({ summary: "Delete a payment intent record (admin only)" }) deletePayment(@Param("id") id: string) { return this.service.deletePayment(id); } @Get("all") @PassengerStaff([ PASSENGER_PERMS.payments.view, PASSENGER_PERMS.payments.viewAll, PASSENGER_PERMS.admin, ]) @ApiBearerAuth("IAM-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, ) { return this.service.getAll({ search, status, method, page: page ? parseInt(page) : 1, pageSize: pageSize ? parseInt(pageSize) : 10, }); } @Post("initiate") @SetMetadata("isPublic", true) @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, @Headers("origin") origin?: string, @Headers("referer") referer?: string, @Headers("x-frontend-base-url") frontendBaseUrl?: string, ) { // Browser-facing return URLs (telebirr/waafi) follow the domain the user is // on — derived from Origin, then Referer, then the X-Frontend-Base-URL // header the portal sets, all validated against the allowlist. return this.service.initiatePayment( dto, resolveAllowedOrigin(origin, referer, frontendBaseUrl), ); } @Get("intents/:bookingId") @SetMetadata("isPublic", true) @ApiOperation({ summary: "Get payment intent status for a booking" }) getIntent(@Param("bookingId") bookingId: string) { return this.service.getIntentByBookingId(bookingId); } @Get("status/:bookingRefOrId") @SetMetadata("isPublic", true) @ApiOperation({ summary: "Get payment status by booking id or booking reference (PNR)", description: "Accepts either a booking UUID or a booking reference / PNR (e.g. EDR-20240001), " + "resolves it to the booking, and returns the authoritative payment status pulled from " + "the payment microservice.", }) getStatusByBookingRefOrId(@Param("bookingRefOrId") bookingRefOrId: string) { return this.service.getIntentByBookingRefOrId(bookingRefOrId); } @Get("diagnostic/:bookingRefOrId") @SetMetadata("isPublic", true) @ApiOperation({ summary: "Get { db, provider } by booking id or booking reference (PNR) — diagnostic", description: "Accepts a booking UUID or a booking reference / PNR (e.g. EDR-20240001), resolves it to " + "the booking, and returns { db, provider }: the payment service's stored intent row and a " + "live provider status query, side by side. Pure read — does not reconcile the booking.", }) getPaymentDiagnostic(@Param("bookingRefOrId") bookingRefOrId: string) { return this.service.getPaymentDiagnosticByBookingRefOrId(bookingRefOrId); } @Post(":bookingId/confirm") @SetMetadata("isPublic", true) @ApiOperation({ summary: "Confirm an OTP-debit payment (CAC Bank)", description: "Submits the OTP the payer received by SMS. Returns the updated intent status. " + "A wrong or expired OTP returns 400 and the payment stays open for retry.", }) confirmOtp( @Param("bookingId") bookingId: string, @Body() dto: ConfirmOtpDto, ) { return this.service.confirmOtpPayment(bookingId, dto.otp); } @Get("waafi/return") @SetMetadata("isPublic", true) @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") @PassengerStaff([ PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.payments.refund, PASSENGER_PERMS.admin, ]) @ApiBearerAuth("IAM-auth") @ApiOperation({ summary: "Refund a confirmed booking (staff/agent only)" }) refund(@Body() dto: RefundDto) { return this.service.refund(dto); } @Post(":bookingId/force-confirm") @PassengerStaff([ PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.payments.manageMethods, PASSENGER_PERMS.admin, ]) @ApiBearerAuth("IAM-auth") @ApiOperation({ summary: "Force-confirm payment & generate ticket (back-office only)", description: "Marks the payment as SUCCEEDED, confirms the booking, and generates the ticket. " + "Use when a vendor payment completed but the webhook was never delivered. Idempotent.", }) forceConfirm( @Param("bookingId") bookingId: string, @Body() dto: ForceConfirmDto, ) { return this.service.forceConfirmPayment(bookingId, dto); } @Post("methods") @PassengerStaff([ PASSENGER_PERMS.paymentMethods.manage, PASSENGER_PERMS.payments.manageMethods, PASSENGER_PERMS.admin, ]) @ApiBearerAuth("IAM-auth") @ApiOperation({ summary: "Add a payment system to the platform catalog (admin only)", }) addMethod(@Body() dto: AddPaymentMethodDto) { return this.service.addPaymentMethod(dto); } @Patch("methods/:id") @PassengerStaff([ PASSENGER_PERMS.paymentMethods.manage, PASSENGER_PERMS.payments.manageMethods, PASSENGER_PERMS.admin, ]) @ApiBearerAuth("IAM-auth") @ApiOperation({ summary: "Update a payment method configuration (admin only)", }) updateMethod( @Param("id") id: string, @Body() dto: Partial, ) { return this.service.updatePaymentMethod(id, dto); } @Get("methods") @SetMetadata("isPublic", true) @ApiOperation({ summary: "List payment systems supported by the platform", description: "Returns all enabled payment methods. Optionally filter by `region` to narrow to methods available for a passenger's nationality.", }) @ApiQuery({ name: "region", enum: PaymentRegionEnum, required: false }) @ApiOkResponse({ type: [SupportedPaymentMethodDto] }) getMethods(@Query("region") region?: PaymentRegionEnum) { return this.service.getSupportedPaymentMethods(region); } @Get("booking-amount") @SetMetadata("isPublic", true) @ApiOperation({ summary: "Get booking amount in a specific currency", description: "Returns the booking total converted from the booking's stored currency to the requested currency using the latest exchange rate. " + "If currency is ETB the stored amount is returned as-is (no conversion). " + "Amounts are returned in major currency units (e.g. 162.50 DJF, not centimes).", }) @ApiQuery({ name: "bookingId", required: true, description: "Booking UUID" }) @ApiQuery({ name: "currency", required: true, example: "DJF", description: "Target currency: ETB, DJF, or USD", }) @ApiOkResponse({ type: BookingAmountResponseDto }) getBookingAmount( @Query("bookingId") bookingId: string, @Query("currency") currency: string, ) { return this.service.getBookingAmountByCurrency(bookingId, currency); } @Get("checkout") @SetMetadata("isPublic", true) @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, description: "Browser-only endpoint — `inapp` is not offered here. A mini-app payer has no browser " + "to redirect and must go through POST /payments/initiate for the bridge payload.", }) @ApiProduces("text/html") async checkout( @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"), ); } if (!method || !Object.values(PaymentMethodTypeEnum).includes(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; if (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)); } 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)); } } // ── Supplementary Charges ────────────────────────────────────────────────── @Post('supplementary') @PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Raise a supplementary charge for an underpayment (staff only)' }) createSupplementaryCharge( @Body() dto: CreateSupplementaryChargeDto, @Headers('x-iam-user-id') iamUserId?: string, ) { return this.supplementaryService.create({ ...dto, createdBy: iamUserId ?? 'staff', }); } @Get('supplementary') @PassengerStaff([PASSENGER_PERMS.payments.view, PASSENGER_PERMS.payments.viewAll, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'List supplementary charges (staff only)' }) @ApiQuery({ name: 'bookingRef', required: false }) @ApiQuery({ name: 'status', required: false }) @ApiQuery({ name: 'page', required: false }) @ApiQuery({ name: 'pageSize', required: false }) listSupplementaryCharges( @Query('bookingRef') bookingRef?: string, @Query('status') status?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string, ) { return this.supplementaryService.getAll({ bookingRef, status, page: page ? parseInt(page) : 1, pageSize: pageSize ? parseInt(pageSize) : 20, }); } @Get('supplementary/by-token/:token') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Get supplementary charge by payment token (public — for self-pay page)' }) getSupplementaryByToken(@Param('token') token: string) { return this.supplementaryService.getByToken(token); } @Post('supplementary/by-token/:token/pay') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Initiate payment for a supplementary charge (public — self-pay)' }) paySupplementaryCharge( @Param('token') token: string, @Body() dto: PaySupplementaryChargeDto, @Headers('origin') origin?: string, @Headers('referer') referer?: string, @Headers('x-frontend-base-url') frontendBaseUrl?: string, ) { // Same domain-follows-the-user rule as /initiate — the self-pay page can be // opened on either portal domain. return this.supplementaryService.pay( token, dto.method, dto.platform, resolveAllowedOrigin(origin, referer, frontendBaseUrl), ); } @Post('supplementary/:id/mark-paid') @PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Manually mark a supplementary charge as paid (staff only)' }) markSupplementaryPaid( @Param('id') id: string, @Body() body: { providerTxnId?: string }, ) { return this.supplementaryService.markPaid(id, body.providerTxnId); } @Post('supplementary/:id/waive') @PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Waive a supplementary charge (staff only)' }) waiveSupplementaryCharge( @Param('id') id: string, @Body() dto: WaiveSupplementaryChargeDto, @Headers('x-iam-user-id') iamUserId?: string, ) { return this.supplementaryService.waive(id, dto.notes ?? '', iamUserId ?? 'staff'); } @Post('supplementary/:id/resend') @PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Resend payment link for a supplementary charge (staff only)' }) resendSupplementaryLink(@Param('id') id: string) { return this.supplementaryService.resendLink(id); } private buildRedirectHtml(url: string): string { const escaped = url.replace(/\"/g, """); return ` Redirecting to payment…

Redirecting to payment provider…

Click here if you are not redirected

`; } private buildStatusHtml(status: string, intentId: string): string { return ` Payment status
${status}
Intent: ${intentId}
`; } private buildErrorHtml(message: string): string { return ` Payment error
Payment could not be initiated

${message}

`; } }