Files
edr-platform/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts
Nathnael 97bfe95ec3 feat(payment): integrate CAC Bank OTP payments into freight flows
CAC Bank is an OTP debit with no redirect and no webhook: initiate SMSes a
code to the payer's mobile, and the charge only settles when that code is
confirmed. The payment service already spoke it (passenger uses it); the
freight side had the enum values but none of the flow.

API:
- PaymentClientService.confirmOtp forwards the code to
  POST /payments/intents/:id/confirm, mapping 400/404 to BadRequest so a
  mistyped code stays retryable instead of surfacing as a gateway failure.
- PaymentService.confirmOtp is keyed by the LOCAL intent id (the invoice's
  paymentId) rather than the domain reference, so the right invoice settles
  when several share a booking. On success billing settles the invoice.
- payInvoice rejects CAC_BANK without payerAccount before calling the
  gateway, and no longer runs the demo auto-settle for a COLLECT_OTP intent
  (it is not paid until the payer confirms).
- POST /billing/my-invoices/:id/confirm — ownership-checked, and since
  warehouse fee invoices are central invoices it covers those too.

Portal:
- useInvoicePayment owns the whole flow (initiate, redirect-or-OTP, confirm)
  and replaces the five near-identical pay mutations at the call sites.
- PaymentMethodModal gains the CAC Bank option, the payer mobile field, and
  the OTP step. Click-outside is disabled there so a stray click cannot drop
  the payer out of a live OTP window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:11:23 +00:00

116 lines
3.2 KiB
TypeScript

import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Query,
Res,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import type { Response } from "express";
import { CurrentUser } from "@edr/api-common";
import {
type AuthUserPayload,
resolveAuthUserId,
} from "../../common/resolve-auth-user-id";
import { sendPdf } from "./billing.controller";
import { BillingService } from "./billing.service";
import { ConfirmOtpDto, PayInvoiceDto } from "./dto/pay-invoice.dto";
/**
* Customer-facing billing endpoints. Unlike {@link BillingController} (admin,
* org-wide), every route here is force-scoped to the signed-in customer's
* company — they only ever see and pay their own invoices.
*/
@ApiTags("billing")
@ApiBearerAuth()
@Controller("billing")
export class PortalBillingController {
constructor(private readonly billingService: BillingService) {}
@Get("my-invoices")
@ApiOperation({ summary: "List the signed-in customer's invoices" })
findMine(
@CurrentUser() user: AuthUserPayload,
@Query("source") source?: string,
@Query("sourceId") sourceId?: string,
) {
return this.billingService.findForUser(resolveAuthUserId(user), {
source,
sourceId,
});
}
@Get("my-invoices/:id")
@ApiOperation({ summary: "Get one of the customer's invoices (+ line items)" })
findMineById(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.billingService.findByIdForUser(id, resolveAuthUserId(user));
}
@Get("my-invoices/:id/document")
@ApiOperation({ summary: "Download one of the customer's invoice PDFs" })
async document(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
@Res() res: Response,
) {
const { filename, buffer } = await this.billingService.documentForUser(
id,
resolveAuthUserId(user),
);
sendPdf(res, filename, buffer);
}
@Get("my-invoices/:id/receipt")
@ApiOperation({ summary: "Download one of the customer's payment receipt PDFs" })
async receipt(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
@Res() res: Response,
) {
const { filename, buffer } = await this.billingService.receiptForUser(
id,
resolveAuthUserId(user),
);
sendPdf(res, filename, buffer);
}
@Post("my-invoices/:id/pay")
@ApiOperation({ summary: "Initiate payment for one of the customer's invoices" })
pay(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
@Body() dto: PayInvoiceDto,
) {
return this.billingService.payInvoiceForUser(id, resolveAuthUserId(user), {
method: dto.method,
platform: dto.platform ?? "web",
payerAccount: dto.payerAccount,
returnUrl: dto.returnUrl,
failureUrl: dto.failureUrl,
});
}
@Post("my-invoices/:id/confirm")
@ApiOperation({
summary: "Confirm an OTP-debit payment (CAC Bank) for one of the customer's invoices",
})
confirmOtp(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
@Body() dto: ConfirmOtpDto,
) {
return this.billingService.confirmInvoiceOtpForUser(
id,
resolveAuthUserId(user),
dto.otp,
);
}
}