Files
edr-platform/apps/edr-passenger-api/src/modules/payments/payments.controller.ts

310 lines
10 KiB
TypeScript

import {
Body,
Controller,
Get,
HttpStatus,
Param,
Patch,
Post,
Query,
Res,
SetMetadata,
UseGuards,
} from "@nestjs/common";
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiQuery,
ApiOkResponse,
ApiProduces,
} from "@nestjs/swagger";
import { SkipThrottle, Throttle } from "@nestjs/throttler";
import { Response } from "express";
import { PaymentsService } from "./payments.service";
import {
InitiatePaymentDto,
RefundDto,
AddPaymentMethodDto,
PaymentRegionEnum,
SupportedPaymentMethodDto,
PaymentMethodTypeEnum,
PaymentPlatformDto,
BookingAmountResponseDto,
} from "./payments.dto";
import { PassengerStaff } from "../../common/passenger-guards";
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
@ApiTags("Payment")
@Controller("payments")
@Throttle({ strict: { limit: 20, ttl: 60_000 } })
export class PaymentsController {
constructor(private service: PaymentsService) {}
@Get("all")
@PassengerStaff([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) {
return this.service.initiatePayment(dto);
}
@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("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.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("methods")
@PassengerStaff([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.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<AddPaymentMethodDto>) {
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 ETB 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 })
@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));
}
}
private buildRedirectHtml(url: string): string {
const escaped = url.replace(/\"/g, "&quot;");
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0;url=${escaped}">
<title>Redirecting to payment…</title>
<style>
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
.spinner { width: 40px; height: 40px; border: 4px solid #e0e0e0; border-top-color: #1a73e8; border-radius: 50%; animation: spin .8s linear infinite; margin: 0 auto 20px; }
@keyframes spin { to { transform: rotate(360deg); } }
p { color: #555; margin: 0 0 16px; }
a { color: #1a73e8; }
</style>
</head>
<body>
<div class="card">
<div class="spinner"></div>
<p>Redirecting to payment provider…</p>
<p><a href="${escaped}">Click here if you are not redirected</a></p>
</div>
<script>window.location.href = "${escaped}";</script>
</body>
</html>`;
}
private buildStatusHtml(status: string, intentId: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Payment status</title>
<style>
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
.status { font-size: 1.1rem; font-weight: 600; color: #333; margin-bottom: 8px; }
small { color: #888; }
</style>
</head>
<body>
<div class="card">
<div class="status">${status}</div>
<small>Intent: ${intentId}</small>
</div>
</body>
</html>`;
}
private buildErrorHtml(message: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Payment error</title>
<style>
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
.error { color: #d32f2f; font-weight: 600; margin-bottom: 8px; }
p { color: #555; }
</style>
</head>
<body>
<div class="card">
<div class="error">Payment could not be initiated</div>
<p>${message}</p>
</div>
</body>
</html>`;
}
}