import { BadGatewayException, BadRequestException, ConflictException, Injectable, Logger, } from "@nestjs/common"; import { HttpService } from "@nestjs/axios"; import { AxiosError } from "axios"; import { firstValueFrom } from "rxjs"; import { InitiatePaymentRequest, PaymentIntentSnapshot, PaymentReferenceType, PaymentService, ProviderStatus, } from "@edr/types"; /** Side-by-side DB row + live provider status from the payment service diagnostic endpoints. */ export interface PaymentDiagnostic { db: Record | null; provider: ProviderStatus | null; } /** Settlement check from POST /payments/reconcile (verify-before-cancel). */ export interface SettlementResult { /** At least one intent for the order is paid (incl. a late capture just registered). */ paid: boolean; /** The paying intent when `paid`. */ intent?: PaymentIntentSnapshot; /** Settlement could not be confirmed — a provider query errored, a payment is in flight, OR the * payment service was unreachable. The caller MUST NOT cancel the order. */ unverifiable: boolean; } /** * Thin HTTP client for the payment microservice (apps/edr-payment-api) — the passenger app's * side of the Phase 6 cutover (docs/payment-service §10). Domain validation stays here; * 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 ?? "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); } /** 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.PASSENGER, 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; } } /** * GET /payments/diagnostic?… — DB intent row + live provider status for a domain reference, * side by side. Returns { db: null, provider: null } when the payment service has no intent. */ async getDiagnosticByReference( referenceType: PaymentReferenceType, referenceId: string, ): Promise { const query = new URLSearchParams({ service: PaymentService.PASSENGER, referenceType, referenceId, }); try { return await this.call("GET", `/payments/diagnostic?${query.toString()}`); } catch (err) { if (err instanceof AxiosError && err.response?.status === 404) return { db: null, provider: null }; throw err; } } /** * POST /payments/reconcile — settlement check before cancelling an order. Live-queries every * intent at the provider and registers any late capture found. A transport failure (payment * service unreachable) is caught and returned as `unverifiable: true` — NEVER as "not paid" — so * the caller does not cancel a booking whose payment simply could not be verified. */ async reconcileByReference( referenceType: PaymentReferenceType, referenceId: string, ): Promise { try { return await this.call("POST", "/payments/reconcile", { service: PaymentService.PASSENGER, referenceType, referenceId, }); } catch (err) { const message = err instanceof Error ? err.message : String(err); this.logger.warn( `reconcile ${referenceType}/${referenceId} failed: ${message}; treating as unverifiable (will not cancel)`, ); return { paid: false, unverifiable: true }; } } /** * 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 { const url = `${this.baseUrl}/payments/intents/${intentId}/confirm`; try { const response = await firstValueFrom( this.http.post( url, { otp }, { headers: this.serviceToken ? { "x-service-token": this.serviceToken } : {}, }, ), ); return response.data; } catch (err) { if (err instanceof AxiosError && err.response) { const detail = (err.response.data as { message?: string | string[] })?.message ?? err.message; // 400 = wrong/expired OTP, 404 = unknown intent → both are client-fixable. if (err.response.status === 400 || err.response.status === 404) { throw new BadRequestException(detail); } this.logger.error( `payment service confirm ${intentId} → ${err.response.status}: ${detail}`, ); throw new BadGatewayException(`Payment service error: ${detail}`); } this.logger.error( `payment service unreachable (confirm ${intentId}): ${ err instanceof Error ? err.message : String(err) }`, ); throw new BadGatewayException("Payment service unreachable"); } } private async call( method: "GET" | "POST", path: string, body?: unknown, ): Promise { const url = `${this.baseUrl}${path}`; this.logger.log("====================================================================="); this.logger.log(`URL ${url}`); this.logger.log("====================================================================="); try { const response = await firstValueFrom( this.http.request({ method, url, data: body, headers: this.serviceToken ? { "x-service-token": this.serviceToken } : {}, }), ); return response.data; } catch (err) { if (err instanceof AxiosError && err.response) { // 4xx/5xx from the payment service: propagate 404 to callers that handle it; // 409 = a legitimate conflict (e.g. another provider's payment is already in // flight for this booking) — surface its message as-is rather than masking it as // a gateway failure; everything else is a genuine gateway-level failure. if (err.response.status === 404) throw err; const detail = (err.response.data as { message?: string | string[] })?.message ?? err.message; if (err.response.status === 409) { throw new ConflictException(detail); } 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); this.logger.error( `payment service unreachable (${method} ${path}): ${message}`, ); throw new BadGatewayException("Payment service unreachable"); } } }