import { Body, Controller, Get, HttpStatus, Post, Query, Res, } from "@nestjs/common"; import { ApiTags, ApiOperation, ApiQuery, ApiOkResponse, ApiProduces, } from "@nestjs/swagger"; import { Response } from "express"; import { Public } from "@edr/api-common"; import { Freight } from "@edr/types"; import { BillingService } from "../billing/billing.service"; import { InitiatePaymentDto, InitiateResponseDto, PaymentMethodTypeEnum, PaymentPlatformDto, } from "../payment/payments.dto"; /** * Booking-payment entrypoints. This is the ONE place that knows a payment is for a * booking — it maps the request to {@link Freight.InvoiceSource.Booking} and hands * off to billing, which resolves the invoice/amount and drives the gateway. Billing * and payment stay source-agnostic; the booking knowledge lives here, in the domain. * Routes are unchanged (`/payments/*`) so the portal is unaffected. */ @ApiTags("Payment") @Controller("payments") export class BookingPaymentController { constructor(private readonly billing: BillingService) { } @Post("initiate") @ApiOperation({ summary: "Initiate payment for a freight booking", description: "Charges the booking's open invoice through the payment gateway.", }) @ApiOkResponse({ type: InitiateResponseDto }) initiate(@Body() dto: InitiatePaymentDto): Promise { return this.billing.payInvoice(Freight.InvoiceSource.Booking, dto.bookingId, { method: dto.method, platform: dto.platform, payerAccount: dto.payerAccount, returnUrl: dto.returnUrl, failureUrl: dto.failureUrl, }); } @Get("checkout") @Public() @ApiOperation({ summary: "Browser checkout redirect", description: "Charges the booking's invoice and returns an HTML page that auto-redirects to the provider checkout URL. Open 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.billing.payInvoice( Freight.InvoiceSource.Booking, 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, """); 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}

`; } }