import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query, Res, UploadedFile, UseInterceptors, } from "@nestjs/common"; import { FileInterceptor } from "@nestjs/platform-express"; import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags, } from "@nestjs/swagger"; import type { Response } from "express"; import { CurrentUser } from "@edr/api-common"; import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; import { BookingStaff } from "../../common/booking-guards"; import { resolveAuthUserId } from "../../common/resolve-auth-user-id"; import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { actorLabel } from "../warehouses/current-actor.util"; import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service"; import { BillingService } from "./billing.service"; import { FilterInvoiceDto } from "./dto/filter-invoice.dto"; @ApiTags("billing") @Controller("billing") // Class gate lists every key its routes use: Nest runs class AND method // guards, so a key missing here would deny before the route's own key runs. @BookingStaff([ FREIGHT_PERMS.invoices.view, FREIGHT_PERMS.invoices.export, FREIGHT_PERMS.invoices.confirmOffline, ]) @ApiBearerAuth() export class BillingController { constructor( private readonly billingService: BillingService, private readonly userTradeAccessService: UserTradeAccessService, ) {} @Get("invoices") @ApiOperation({ summary: "List invoices (paginated, filterable by company/status/search)", }) async findAll( @Query() query: FilterInvoiceDto, @CurrentUser() user: TCurrentUser, ) { // Per-user trade-direction scope, applied via each invoice's source booking. const allowed = await this.userTradeAccessService.resolveAllowedDirections(user); return this.billingService.findAllPaginated({ ...query, tradeDirections: allowed ?? undefined, }); } @Get("invoices/:id") @ApiOperation({ summary: "Get an invoice with its line items" }) findById(@Param("id", ParseUUIDPipe) id: string) { return this.billingService.findById(id); } @Get("offline-usd") @ApiOperation({ summary: "Finance worklist: USD invoices settled offline by bank transfer, with booking pay-window context", }) findOfflineUsd(@Query() query: FilterInvoiceDto) { return this.billingService.findOfflineUsdPaginated(query); } @Post("invoices/:id/confirm-offline") @BookingStaff(FREIGHT_PERMS.invoices.confirmOffline) @UseInterceptors(FileInterceptor("file")) @ApiConsumes("multipart/form-data") @ApiOperation({ summary: "Finance confirms a USD invoice paid by bank transfer — slip file required, settles the full balance", }) confirmOffline( @Param("id", ParseUUIDPipe) id: string, @UploadedFile() file: Express.Multer.File | undefined, @Body("reference") reference: string | undefined, @CurrentUser() user: TCurrentUser, ) { return this.billingService.confirmOfflinePayment(id, file, { reference: reference?.trim() || null, userId: resolveAuthUserId(user), userName: actorLabel(user) ?? null, }); } @Get("invoices/:id/document") @BookingStaff(FREIGHT_PERMS.invoices.export) @ApiOperation({ summary: "Download the sealed invoice PDF" }) async document(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) { const { filename, buffer } = await this.billingService.document(id); sendPdf(res, filename, buffer); } @Get("invoices/:id/receipt") @BookingStaff(FREIGHT_PERMS.invoices.export) @ApiOperation({ summary: "Download the sealed payment receipt PDF" }) async receipt(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) { const { filename, buffer } = await this.billingService.receipt(id); sendPdf(res, filename, buffer); } } /** Stream a generated PDF as a file download. */ export function sendPdf(res: Response, filename: string, buffer: Buffer): void { res.setHeader("Content-Type", "application/pdf"); res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); res.setHeader("Content-Length", buffer.length); res.send(buffer); }