import { BadGatewayException, BadRequestException, Injectable, Logger, } from "@nestjs/common"; import { HttpService } from "@nestjs/axios"; import { AxiosError } from "axios"; import { firstValueFrom } from "rxjs"; import { logCtx } from "@edr/api-common"; import { InitiatePaymentRequest, PaymentIntentSnapshot, PaymentReferenceType, PaymentService, } from "@edr/types"; /** * Thin HTTP client for the payment microservice (apps/edr-payment-api). * Domain validation stays in the freight API; provider calls, intents, * and webhooks live in the payment service. */ @Injectable() export class PaymentClientService { private readonly logger = new Logger(PaymentClientService.name); private readonly baseUrl = ( process.env.PAYMENT_API_URL ?? "https://paymentcallback.triaplc.com" ) // "http://localhost:3003" .replace(/\/$/, ""); private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? ""; constructor(private readonly http: HttpService) { } /** POST /payments/initiate — idempotent per (service, referenceType, referenceId). */ async initiate( request: InitiatePaymentRequest, ): Promise { return this.call("POST", "/payments/initiate", request); } /** * POST /payments/reconcile — settlement check for a domain order * (reconcile-before-cancel). Live-queries every non-failed intent at the * provider and registers any late capture found (flips it to SUCCEEDED and * emits payment.succeeded). `unverifiable: true` = could not confirm * "not paid" — the caller must NOT cancel/expire the order. */ async reconcileReference( referenceType: PaymentReferenceType, referenceId: string, ): Promise<{ paid: boolean; unverifiable: boolean }> { return this.call("POST", "/payments/reconcile", { service: PaymentService.FREIGHT, referenceType, referenceId, }); } /** GET /payments/intents?… — active intent by domain reference; null when none exists. */ async getIntentByReference( referenceType: PaymentReferenceType, referenceId: string, ): Promise { const query = new URLSearchParams({ service: PaymentService.FREIGHT, referenceType, referenceId, }); try { return await this.call("GET", `/payments/intents?${query.toString()}`); } catch (err) { if (err instanceof AxiosError && err.response?.status === 404) return null; throw err; } } /** * POST /payments/intents/:id/confirm — submit an OTP for a COLLECT_OTP provider * (CAC Bank). A wrong/expired OTP comes back as 400 from the payment service; * surface that as a BadRequest (retryable) rather than a 502, so the payer can * re-enter the code. */ async confirmOtp( intentId: string, otp: string, ): Promise { try { return await this.call( "POST", `/payments/intents/${intentId}/confirm`, { otp }, ); } catch (err) { // `call` re-throws raw 404s and masks every other 4xx as BadGateway; an // unknown intent or a bad OTP is client-fixable, so translate both to 400. if (err instanceof AxiosError && err.response?.status === 404) { throw new BadRequestException("PaymentIntent not found"); } if (err instanceof BadGatewayException) { const detail = err.message.replace(/^Payment service error: /, ""); throw new BadRequestException(detail); } throw err; } } private async call( method: "GET" | "POST", path: string, body?: unknown, ): Promise { const url = `${this.baseUrl}${path}`; // Every hop to the payment service lands on the request log line: which // call, how slow, and what it answered. A settle that never happened is // almost always one of these coming back 4xx/5xx or timing out. const startedAt = Date.now(); const trace = (extra: Record) => logCtx( { method, path, ms: Date.now() - startedAt, ...extra }, { path: "outbound.payment", mode: "push" }, ); try { const response = await firstValueFrom( this.http.request({ method, url, data: body, headers: this.serviceToken ? { "x-service-token": this.serviceToken } : {}, }), ); trace({ status: response.status }); return response.data; } catch (err) { if (err instanceof AxiosError && err.response) { trace({ status: err.response.status }); if (err.response.status === 404) throw err; const detail = (err.response.data as { message?: string | string[] })?.message ?? err.message; this.logger.error( `payment service ${method} ${path} → ${err.response.status}: ${detail}`, ); throw new BadGatewayException(`Payment service error: ${detail}`); } const message = err instanceof Error && err.message ? err.message : String(err); trace({ unreachable: true, error: message }); this.logger.error( `payment service unreachable (${method} ${path}): ${message}`, ); throw new BadGatewayException("Payment service unreachable"); } } }