/** * ANCPI ePay client — server-side only. * * Reverse-engineered from epaymentAngularApp.js (123KB). * All endpoints and payloads verified against actual Angular source. * * Flow: * 1. Login (OpenAM) → AMAuthCookie * 2. GET ShowCartItems → Angular session context * 3. EpayJsonInterceptor(shoppingCartCompleteInformation) → judeteNom IDs * 4. EpayJsonInterceptor(nomenclatorUAT) → UAT IDs * 5. EpayJsonInterceptor(saveProductMetadataForBasketItem) → save (multipart) * 6. EditCartSubmit(goToCheckout=true) → order * 7. Poll ShowOrderDetails → idDocument * 8. DownloadFile(Content-Type: application/pdf) → PDF */ import axios, { type AxiosInstance } from "axios"; import crypto from "crypto"; import { wrapper } from "axios-cookiejar-support"; import { CookieJar } from "tough-cookie"; import FormData from "form-data"; import type { EpayCartResponse, EpaySearchResult, EpayUatEntry, EpayOrderStatus, EpaySolutionDoc, } from "./epay-types"; /* ------------------------------------------------------------------ */ /* Constants */ /* ------------------------------------------------------------------ */ const BASE_URL = process.env.ANCPI_BASE_URL || "https://epay.ancpi.ro/epay"; const LOGIN_URL = process.env.ANCPI_LOGIN_URL || "https://oassl.ancpi.ro/openam/UI/Login"; const DEFAULT_TIMEOUT_MS = 60_000; const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour const POLL_INTERVAL_MS = 15_000; const POLL_MAX_ATTEMPTS = 40; /* ------------------------------------------------------------------ */ /* Session cache */ /* ------------------------------------------------------------------ */ type SessionEntry = { jar: CookieJar; client: AxiosInstance; createdAt: number; lastUsed: number; }; const globalStore = globalThis as { __epaySessionCache?: Map; }; const sessionCache = globalStore.__epaySessionCache ?? new Map(); globalStore.__epaySessionCache = sessionCache; const makeCacheKey = (u: string, p: string) => crypto.createHash("sha256").update(`epay:${u}:${p}`).digest("hex"); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); /* ------------------------------------------------------------------ */ /* Client */ /* ------------------------------------------------------------------ */ export class EpayClient { private client: AxiosInstance; private jar: CookieJar; private username: string; private password: string; private constructor( client: AxiosInstance, jar: CookieJar, username: string, password: string, ) { this.client = client; this.jar = jar; this.username = username; this.password = password; } /* ── Factory ───────────────────────────────────────────────── */ static async create(username: string, password: string): Promise { const cacheKey = makeCacheKey(username, password); const cached = sessionCache.get(cacheKey); const now = Date.now(); if (cached && now - cached.lastUsed < SESSION_TTL_MS) { cached.lastUsed = now; return new EpayClient(cached.client, cached.jar, username, password); } const jar = new CookieJar(); const client = wrapper( axios.create({ jar, withCredentials: true, maxRedirects: 10, headers: { Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", }, }), ); const instance = new EpayClient(client, jar, username, password); await instance.login(); sessionCache.set(cacheKey, { jar, client, createdAt: now, lastUsed: now }); return instance; } /* ── Auth ───────────────────────────────────────────────────── */ private async login(): Promise { const gotoUrl = "http://epay.ancpi.ro:80/epay/LogIn.action"; const loginUrlFull = `${LOGIN_URL}?module=SelfRegistration&goto=${encodeURIComponent(gotoUrl)}`; // Step 1: GET login page (sets initial cookies) await this.client.get(loginUrlFull, { timeout: DEFAULT_TIMEOUT_MS, validateStatus: () => true, }); // Step 2: POST credentials const body = `IDToken1=${encodeURIComponent(this.username)}&IDToken2=${encodeURIComponent(this.password)}`; const response = await this.client.post(loginUrlFull, body, { headers: { "Content-Type": "application/x-www-form-urlencoded", Referer: loginUrlFull, }, timeout: DEFAULT_TIMEOUT_MS, validateStatus: () => true, }); const html = typeof response.data === "string" ? response.data : ""; if (html.includes("Authentication Failed") || html.includes("Autentificare esuata")) { throw new Error("ePay login failed (invalid credentials)"); } // Step 3: Navigate to ePay to establish JSESSIONID const epayResponse = await this.client.get(gotoUrl, { timeout: DEFAULT_TIMEOUT_MS, validateStatus: () => true, }); const epayHtml = String(epayResponse.data ?? ""); if (!epayHtml.includes("puncte de credit") && !epayHtml.includes("LogOut")) { // Try HTTPS await this.client.get(`${BASE_URL}/LogIn.action`, { timeout: DEFAULT_TIMEOUT_MS, validateStatus: () => true, }); } console.log("[epay] Login successful."); } /* ── Credits ───────────────────────────────────────────────── */ async getCredits(): Promise { const response = await this.client.get(`${BASE_URL}/LogIn.action`, { timeout: DEFAULT_TIMEOUT_MS, }); const html = String(response.data ?? ""); const match = html.match(/(\d+)\s+puncte?\s+de\s+credit/i); return match ? parseInt(match[1] ?? "0", 10) : 0; } /* ── Cart ───────────────────────────────────────────────────── */ async addToCart(prodId = 14200): Promise { const body = new URLSearchParams(); body.set("prodId", String(prodId)); body.set("productQtyModif", "1"); body.set("urgencyValue", "5000"); body.set("wishListId", "-1"); body.set("basketRowId", ""); body.set("addToWishList", ""); body.set("random", String(Date.now())); body.set("secondaryProductsJson", ""); const response = await this.client.post( `${BASE_URL}/AddToCartOrWishListFromPost.action`, body.toString(), { headers: { "Content-Type": "application/x-www-form-urlencoded" }, timeout: DEFAULT_TIMEOUT_MS, }, ); const data = response.data as EpayCartResponse; const item = data?.items?.[0]; if (!item?.id) { throw new Error(`ePay addToCart failed: ${JSON.stringify(data).slice(0, 200)}`); } console.log(`[epay] Added to cart: basketRowId=${item.id}`); return item.id; } /* ── EpayJsonInterceptor (form-urlencoded) ─────────────────── */ /** * Call EpayJsonInterceptor with form-urlencoded body. * This is how Angular's doPostAsForm works. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any private async interceptor(params: Record): Promise { const body = new URLSearchParams(params).toString(); const response = await this.client.post( `${BASE_URL}/EpayJsonInterceptor.action`, body, { headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "X-Requested-With": "XMLHttpRequest", }, timeout: DEFAULT_TIMEOUT_MS, }, ); const data = response.data; if (data?.jsonResult && typeof data.jsonResult === "string") { try { return JSON.parse(data.jsonResult); } catch { /* not JSON */ } } return data; } /* ── Get County IDs (internal ANCPI IDs, NOT 0-41 indices) ── */ /** * Fetch shoppingCartCompleteInformation to get judeteNom * (internal county IDs used by all other ePay endpoints). */ async getCountyList(): Promise { // Must load ShowCartItems first for Angular context await this.client.get(`${BASE_URL}/ShowCartItems.action`, { timeout: DEFAULT_TIMEOUT_MS, validateStatus: () => true, }); const data = await this.interceptor({ reqType: "shoppingCartCompleteInformation", }); const judeteNom = data?.pNomenclatoareMap?.judeteNom; if (Array.isArray(judeteNom)) { console.log(`[epay] Got ${judeteNom.length} counties from judeteNom.`); return judeteNom as EpayUatEntry[]; } console.warn("[epay] Could not get judeteNom:", JSON.stringify(data).slice(0, 300)); return []; } /* ── UAT Lookup ────────────────────────────────────────────── */ /** * Get UAT list for a county using its INTERNAL ID (from judeteNom). */ async getUatList(countyInternalId: number): Promise { const data = await this.interceptor({ reqType: "nomenclatorUAT", countyId: String(countyInternalId), }); if (Array.isArray(data)) return data as EpayUatEntry[]; console.warn(`[epay] getUatList(${countyInternalId}):`, JSON.stringify(data).slice(0, 200)); return []; } /* ── SearchEstate (optional) ───────────────────────────────── */ async searchEstate( identifier: string, countyInternalId: number, uatId: number, ): Promise { const body = new URLSearchParams(); body.set("identificator", identifier); // NOT "identifier"! body.set("judet", String(countyInternalId)); // NOT "countyId"! body.set("uat", String(uatId)); // real UAT ID const response = await this.client.post( `${BASE_URL}/SearchEstate.action`, body.toString(), { headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "X-Requested-With": "XMLHttpRequest", }, timeout: DEFAULT_TIMEOUT_MS, }, ); const data = response.data; if (Array.isArray(data)) return data as EpaySearchResult[]; if (typeof data === "string" && data.trim().startsWith("[")) { try { return JSON.parse(data) as EpaySearchResult[]; } catch { /* */ } } return []; } /* ── Save Metadata (multipart/form-data) ───────────────────── */ /** * Save cart item metadata via EpayJsonInterceptor with * reqType=saveProductMetadataForBasketItem. * Uses multipart/form-data with productMetadataJSON field. */ async saveMetadata( basketRowId: number, countyInternalId: number, countyName: string, uatId: number, uatName: string, nrCF: string, nrCadastral: string, solicitantId: string, ): Promise { const metadata = { basketId: basketRowId, productId: 14200, metadate: { judet: { name: "judet", bigDecimalValue: countyInternalId, stringValue: countyName, stringValues: null, validators: { required: "required" }, }, uat: { name: "uat", bigDecimalValue: uatId, stringValue: uatName, stringValues: null, validators: { required: "required" }, }, CF: { name: "CF", stringValue: null, stringValues: [nrCF], validators: { required: "required" }, }, CAD: { name: "CAD", stringValue: null, stringValues: [nrCadastral], validators: {}, }, TOPO: { name: "TOPO", stringValue: null, stringValues: [null], validators: {}, }, metodeLivrare: { name: "metodeLivrare", stringValue: "Electronic", stringValues: ["Electronic"], }, differentSolicitant: { name: "differentSolicitant", stringValue: solicitantId, stringValues: [" "], }, electronicSignedDocs: { name: "electronicSignedDocs", booleanValue: true, }, enableANCPIConversation: { name: "enableANCPIConversation", booleanValue: false, stringValue: "false", }, isMandatoryCADorTOPO: { name: "isMandatoryCADorTOPO", booleanValue: true, }, singleUnit: { name: "singleUnit", booleanValue: false, }, requiresReexaminare: { name: "requiresReexaminare", booleanValue: false, }, }, manufacturer: "ANCPI", isExternalSystem: false, configurationUrl: "http://www.google.com", }; // Must use multipart/form-data (Angular uses doPostAsFormMultipart) const form = new FormData(); form.append("reqType", "saveProductMetadataForBasketItem"); form.append("productMetadataJSON", JSON.stringify(metadata)); const response = await this.client.post( `${BASE_URL}/EpayJsonInterceptor.action`, form, { headers: { ...form.getHeaders(), "X-Requested-With": "XMLHttpRequest", }, timeout: DEFAULT_TIMEOUT_MS, }, ); const data = response.data; const result = typeof data?.jsonResult === "string" ? JSON.parse(data.jsonResult) : data; if (result?.code === "SUCCESS" || result?.code === "SAVE_OK") { console.log(`[epay] Metadata saved for basket ${basketRowId}.`); return true; } console.error("[epay] saveMetadata failed:", JSON.stringify(result).slice(0, 300)); return false; } /* ── Order Submission ──────────────────────────────────────── */ async submitOrder(): Promise { const body = new URLSearchParams(); body.set("goToCheckout", "true"); await this.client.post( `${BASE_URL}/EditCartSubmit.action`, body.toString(), { headers: { "Content-Type": "application/x-www-form-urlencoded" }, timeout: DEFAULT_TIMEOUT_MS, maxRedirects: 10, validateStatus: () => true, }, ); return this.getLatestOrderId(); } private async getLatestOrderId(): Promise { const response = await this.client.get(`${BASE_URL}/LogIn.action`, { timeout: DEFAULT_TIMEOUT_MS, }); const html = String(response.data ?? ""); const match = html.match(/ShowOrderDetails\.action\?orderId=(\d+)/); if (match) return match[1] ?? ""; throw new Error("Could not determine orderId after checkout"); } /* ── Order Status & Polling ────────────────────────────────── */ async getOrderStatus(orderId: string): Promise { const response = await this.client.get( `${BASE_URL}/ShowOrderDetails.action?orderId=${orderId}`, { timeout: DEFAULT_TIMEOUT_MS }, ); const html = String(response.data ?? ""); let status = "Receptionata"; if (html.includes("Finalizata")) status = "Finalizata"; else if (html.includes("In curs de procesare")) status = "In curs de procesare"; else if (html.includes("Anulata")) status = "Anulata"; else if (html.includes("Plata refuzata")) status = "Plata refuzata"; const documents: EpaySolutionDoc[] = []; const docPattern = /"idDocument"\s*:\s*(\d+)[\s\S]*?"nume"\s*:\s*"([^"]+)"[\s\S]*?"dataDocument"\s*:\s*"([^"]+)"[\s\S]*?"linkDownload"\s*:\s*"([^"]*)"/g; let docMatch; while ((docMatch = docPattern.exec(html)) !== null) { documents.push({ idDocument: parseInt(docMatch[1] ?? "0", 10), idTipDocument: null, nume: docMatch[2] ?? "", numar: null, serie: null, dataDocument: docMatch[3] ?? "", contentType: "application/pdf", linkDownload: docMatch[4] ?? "", downloadValabil: true, valabilNelimitat: true, zileValabilitateDownload: -1, transactionId: 0, }); } return { orderId, status, documents }; } async pollUntilComplete( orderId: string, onProgress?: (attempt: number, status: string) => void, ): Promise { for (let attempt = 1; attempt <= POLL_MAX_ATTEMPTS; attempt++) { const status = await this.getOrderStatus(orderId); if (onProgress) onProgress(attempt, status.status); if (["Finalizata", "Anulata", "Plata refuzata"].includes(status.status)) { return status; } await sleep(POLL_INTERVAL_MS); } throw new Error(`ePay order ${orderId} timed out after ${POLL_MAX_ATTEMPTS} poll attempts`); } /* ── Document Download ─────────────────────────────────────── */ async downloadDocument(idDocument: number, typeD = 4): Promise { const url = `${BASE_URL}/DownloadFile.action?typeD=${typeD}&id=${idDocument}&source=&browser=chrome`; // Angular sends Content-Type: application/pdf in the REQUEST const response = await this.client.post(url, null, { headers: { "Content-Type": "application/pdf" }, timeout: DEFAULT_TIMEOUT_MS, responseType: "arraybuffer", }); const data = response.data; if (!data || data.length < 100) { throw new Error(`ePay download empty (${data?.length ?? 0} bytes)`); } console.log(`[epay] Downloaded document ${idDocument}: ${data.length} bytes`); return Buffer.from(data); } /* ── Utility ───────────────────────────────────────────────── */ async getRawHtml(url: string): Promise { const response = await this.client.get(url, { timeout: DEFAULT_TIMEOUT_MS, validateStatus: () => true, }); return String(response.data ?? ""); } // eslint-disable-next-line @typescript-eslint/no-explicit-any async postRaw(url: string, body: string, extraHeaders?: Record): Promise { const response = await this.client.post(url, body, { headers: { "Content-Type": "application/x-www-form-urlencoded", ...extraHeaders, }, timeout: DEFAULT_TIMEOUT_MS, validateStatus: () => true, }); return response.data; } }