/** * ANCPI ePay client — server-side only. * * Authenticates via oassl.ancpi.ro, keeps cookie jar (iPlanetDirectoryPro + JSESSIONID), * and implements the full CF extract ordering workflow: * login → check credits → add to cart → search estate → * configure & submit → poll status → download PDF * * Modeled after eterra-client.ts: cookie jar, session cache, retry, auto-relogin. */ import axios, { type AxiosError, type AxiosInstance } from "axios"; import crypto from "crypto"; import { wrapper } from "axios-cookiejar-support"; import { CookieJar } from "tough-cookie"; import type { EpayCartResponse, EpaySearchResult, EpayUatEntry, EpayOrderStatus, EpaySolutionDoc, OrderMetadata, } 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; // ePay is slow const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour (ePay sessions last longer) const MAX_RETRIES = 3; const POLL_INTERVAL_MS = 15_000; const POLL_MAX_ATTEMPTS = 40; // 10 minutes /* ------------------------------------------------------------------ */ /* Session cache (global singleton) */ /* ------------------------------------------------------------------ */ 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"); /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const isRedirectToLogin = (html: string) => html.includes("IDToken1") || html.includes("/openam/UI/Login") || html.includes("j_security_check"); /* ------------------------------------------------------------------ */ /* Client */ /* ------------------------------------------------------------------ */ export class EpayClient { private client: AxiosInstance; private jar: CookieJar; private username: string; private password: string; private reloginAttempted = false; 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: 5, 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 { // Full login URL with module + goto params (required by OpenAM) const gotoUrl = "http://epay.ancpi.ro:80/epay/LogIn.action"; const loginUrlFull = `${LOGIN_URL}?module=SelfRegistration&goto=${encodeURIComponent(gotoUrl)}`; // Step 1: GET the login page first (sets initial cookies + form tokens) await this.client.get(loginUrlFull, { timeout: DEFAULT_TIMEOUT_MS, maxRedirects: 5, 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, maxRedirects: 10, validateStatus: () => true, }); const finalUrl = response.request?.res?.responseUrl ?? response.request?.responseURL ?? ""; const html = typeof response.data === "string" ? response.data : ""; console.log( `[epay] Login POST: status=${response.status}, finalUrl=${finalUrl.slice(0, 120)}`, ); // Auth failure check 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 // Try the goto URL (HTTP) and HTTPS variants const epayUrls = [gotoUrl, `${BASE_URL}/LogIn.action`]; let loggedIn = false; for (const epayUrl of epayUrls) { try { const epayResponse = await this.client.get(epayUrl, { timeout: DEFAULT_TIMEOUT_MS, maxRedirects: 10, validateStatus: () => true, }); const epayHtml = String(epayResponse.data ?? ""); if ( epayHtml.includes("puncte de credit") || epayHtml.includes("LogOut") || epayHtml.includes("Istoric Comenzi") ) { loggedIn = true; console.log(`[epay] Session established via ${epayUrl}`); break; } } catch { // Try next } } if (!loggedIn) { // Log cookies for debugging const cookieKeys: string[] = []; for (const domain of [ "https://epay.ancpi.ro", "https://oassl.ancpi.ro", "http://epay.ancpi.ro", ]) { try { const cookies = await this.jar.getCookies(domain); for (const c of cookies) cookieKeys.push(`${c.key}@${domain}`); } catch { /* skip */ } } console.error(`[epay] Login failed. Cookies: ${cookieKeys.join(", ")}`); throw new Error("ePay login failed (could not establish session)"); } console.log("[epay] Login successful."); } private async ensureSession(): Promise { if (this.reloginAttempted) return; // Re-login if needed (lazy check — will be triggered on redirects) } private async retryOnAuthFail(fn: () => Promise): Promise { try { return await fn(); } catch (error) { const err = error as AxiosError; const html = typeof err?.response?.data === "string" ? err.response.data : ""; if ( (err?.response?.status === 302 || isRedirectToLogin(html)) && !this.reloginAttempted ) { this.reloginAttempted = true; await this.login(); this.reloginAttempted = false; return await fn(); } throw error; } } /* ── Raw HTML fetch (for page scraping) ─────────────────── */ async getRawHtml(url: string): Promise { const response = await this.client.get(url, { timeout: DEFAULT_TIMEOUT_MS, maxRedirects: 5, validateStatus: () => true, }); return String(response.data ?? ""); } /* ── Raw POST (for endpoint discovery) ──────────────────── */ // 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, maxRedirects: 5, validateStatus: () => true, }); return response.data; } /* ── Credits ───────────────────────────────────────────────── */ async getCredits(): Promise { return this.retryOnAuthFail(async () => { const response = await this.client.get(`${BASE_URL}/LogIn.action`, { timeout: DEFAULT_TIMEOUT_MS, }); const html = String(response.data ?? ""); // Parse "Ai {N} puncte de credit" const match = html.match(/Ai\s+(\d+)\s+puncte?\s+de\s+credit/i); if (match) return parseInt(match[1] ?? "0", 10); // Fallback: look for credit count in other formats const match2 = html.match(/(\d+)\s+puncte?\s+de\s+credit/i); if (match2) return parseInt(match2[1] ?? "0", 10); // Broader search: any number near "credit" const match3 = html.match(/credit[^<]{0,30}?(\d+)|(\d+)[^<]{0,30}?credit/i); if (match3) { const n = parseInt(match3[1] ?? match3[2] ?? "0", 10); if (n > 0) return n; } // Log context around "credit" or "punct" for debugging const creditIdx = html.toLowerCase().indexOf("credit"); if (creditIdx >= 0) { console.log( "[epay] Credit context:", html.slice(Math.max(0, creditIdx - 80), creditIdx + 80), ); } else { // Maybe the page didn't load — check if we're on the login page const isLoginPage = html.includes("IDToken1"); console.warn( `[epay] No 'credit' found in HTML (${html.length} chars, isLogin=${isLoginPage})`, ); } return 0; }); } /* ── Cart ───────────────────────────────────────────────────── */ async addToCart(prodId = 14200): Promise { return this.retryOnAuthFail(async () => { 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; }); } /* ── Estate Search ─────────────────────────────────────────── */ /** * Search estate on ePay. Requires basketRowId in context. * countyIdx = ePay county index (0-41), uatId = real ePay UAT ID (NOT index). */ async searchEstate( identifier: string, countyIdx: number, uatId: number, basketRowId: number, ): Promise { return this.retryOnAuthFail(async () => { // Must include basketId for SearchEstate to return JSON const body = new URLSearchParams(); body.set("identifier", identifier); body.set("countyId", String(countyIdx)); body.set("uatId", String(uatId)); body.set("basketId", String(basketRowId)); const response = await this.client.post( `${BASE_URL}/SearchEstate.action`, body.toString(), { headers: { "Content-Type": "application/x-www-form-urlencoded", "X-Requested-With": "XMLHttpRequest", }, timeout: DEFAULT_TIMEOUT_MS, }, ); const data = response.data; if (Array.isArray(data)) return data as EpaySearchResult[]; if (typeof data === "string") { const trimmed = data.trim(); if (trimmed.startsWith("[")) { try { return JSON.parse(trimmed) as EpaySearchResult[]; } catch { /* not JSON */ } } } console.warn( `[epay] SearchEstate(${identifier}): unexpected response`, String(typeof data === "string" ? data : JSON.stringify(data)).slice(0, 200), ); return []; }); } /* ── UAT Lookup ────────────────────────────────────────────── */ /** * Get UAT list for a county. Uses JSON Content-Type (not form-urlencoded). */ async getUatList(countyIdx: number): Promise { return this.retryOnAuthFail(async () => { // EpayJsonInterceptor requires JSON body, not form-urlencoded const response = await this.client.post( `${BASE_URL}/EpayJsonInterceptor.action`, JSON.stringify({ judet: countyIdx }), { headers: { "Content-Type": "application/json", Accept: "application/json", }, timeout: DEFAULT_TIMEOUT_MS, }, ); const data = response.data; // Response: { jsonResult: "[{\"id\":55473,\"value\":\"Aghiresu\"}, ...]" } if (data?.jsonResult && typeof data.jsonResult === "string") { try { const parsed = JSON.parse(data.jsonResult); if (Array.isArray(parsed)) return parsed as EpayUatEntry[]; } catch { /* parse failed */ } } if (Array.isArray(data)) return data as EpayUatEntry[]; console.warn( `[epay] getUatList(${countyIdx}):`, JSON.stringify(data).slice(0, 200), ); return []; }); } /* ── Configure Cart Item (save metadata via JSON) ──────────── */ /** * Save cart item metadata via EditCartItemJson. Uses JSON body * with bigDecimalValue/stringValue structure. */ async configureCartItem( basketRowId: number, countyIdx: number, uatId: number, nrCF: string, nrCadastral: string, solicitantId: string, ): Promise { await this.retryOnAuthFail(async () => { const payload = { basketId: basketRowId, metadate: { judet: { bigDecimalValue: countyIdx }, uat: { bigDecimalValue: uatId }, CF: { stringValue: nrCF }, CAD: { stringValue: nrCadastral }, metodeLivrare: { stringValue: "Electronic", stringValues: ["Electronic"], }, differentSolicitant: { stringValue: solicitantId }, }, }; const response = await this.client.post( `${BASE_URL}/EditCartItemJson.action`, JSON.stringify(payload), { headers: { "Content-Type": "application/json", Accept: "application/json", }, timeout: DEFAULT_TIMEOUT_MS, }, ); console.log( `[epay] ConfigureCartItem(${basketRowId}): status=${response.status}`, ); }); } /* ── Order Submission ──────────────────────────────────────── */ /** * Submit order after configuring cart item via EditCartItemJson. */ async submitOrder(metadata: OrderMetadata): Promise { return this.retryOnAuthFail(async () => { // First configure the cart item via JSON API await this.configureCartItem( metadata.basketRowId, metadata.judetIndex, metadata.uatId, metadata.nrCF, metadata.nrCadastral, metadata.solicitantId, ); // Then submit checkout via form (EditCartSubmit still uses form encoding) const body = new URLSearchParams(); body.set("goToCheckout", "true"); const response = 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, }, ); // After submit, follow to CheckoutConfirmationSubmit const finalUrl = response.request?.res?.responseUrl ?? response.config?.url ?? ""; const html = String(response.data ?? ""); // Try to get the order ID from the confirmation page or redirect // If we're on the confirmation page, parse orderId from the recent orders if ( finalUrl.includes("CheckoutConfirmation") || html.includes("Vă mulțumim") ) { return this.getLatestOrderId(); } // If redirected back to cart, the submission may have failed if (finalUrl.includes("ShowCartItems") || finalUrl.includes("ViewCart")) { throw new Error("ePay order submission failed — redirected to cart"); } // Try to get orderId anyway return this.getLatestOrderId(); }); } /** Get the most recent order ID from the order history page */ private async getLatestOrderId(): Promise { const response = await this.client.get(`${BASE_URL}/LogIn.action`, { timeout: DEFAULT_TIMEOUT_MS, }); const html = String(response.data ?? ""); // Parse orderId from the orders table // Look for ShowOrderDetails.action?orderId=XXXXXXX const match = html.match( /ShowOrderDetails\.action\?orderId=(\d+)/, ); if (match) return match[1] ?? ""; // Fallback: look for order ID patterns const match2 = html.match(/orderId[=:][\s"]*(\d{5,})/); if (match2) return match2[1] ?? ""; throw new Error("Could not determine orderId after checkout"); } /* ── Order Status & Polling ────────────────────────────────── */ async getOrderStatus(orderId: string): Promise { return this.retryOnAuthFail(async () => { const response = await this.client.get( `${BASE_URL}/ShowOrderDetails.action?orderId=${orderId}`, { timeout: DEFAULT_TIMEOUT_MS }, ); const html = String(response.data ?? ""); // Parse status 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"; // Parse documents — look for JSON-like solutie data in Angular scope const documents: EpaySolutionDoc[] = []; // Pattern: idDocument, nume, dataDocument, linkDownload 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 }; }); } /** * Poll order status until completed or timeout. * Returns the final status with document info. */ 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 ( status.status === "Finalizata" || status.status === "Anulata" || status.status === "Plata refuzata" ) { 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 { return this.retryOnAuthFail(async () => { const url = `${BASE_URL}/DownloadFile.action?typeD=${typeD}&id=${idDocument}&source=&browser=chrome`; const response = await this.client.post(url, null, { timeout: DEFAULT_TIMEOUT_MS, responseType: "arraybuffer", }); const data = response.data; if (!data || data.length < 100) { throw new Error( `ePay download returned empty/small response (${data?.length ?? 0} bytes)`, ); } console.log( `[epay] Downloaded document ${idDocument}: ${data.length} bytes`, ); return Buffer.from(data); }); } }