mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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>
116 lines
3.2 KiB
TypeScript
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,
|
|
);
|
|
}
|
|
}
|