mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
498 lines
17 KiB
TypeScript
498 lines
17 KiB
TypeScript
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'], default: 'web' }) @IsOptional() @IsIn(['web', 'mobile']) platform?: 'web' | 'mobile';
|
|
}
|
|
|
|
@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);
|
|
}
|
|
|
|
@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<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 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 })
|
|
@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,
|
|
) {
|
|
return this.supplementaryService.pay(token, dto.method, dto.platform);
|
|
}
|
|
|
|
@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 `<!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>`;
|
|
}
|
|
}
|