diff --git a/src/app/api/ancpi/test/route.ts b/src/app/api/ancpi/test/route.ts index ef5fcf2..dac7cc3 100644 --- a/src/app/api/ancpi/test/route.ts +++ b/src/app/api/ancpi/test/route.ts @@ -1,6 +1,5 @@ import { NextResponse } from "next/server"; import { EpayClient } from "@/modules/parcel-sync/services/epay-client"; -import { resolveEpayCountyIndex } from "@/modules/parcel-sync/services/epay-counties"; import { createEpaySession, getEpayCredentials, @@ -11,15 +10,10 @@ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; /** - * GET /api/ancpi/test?step=login|credits|uats|search|order + * GET /api/ancpi/test?step=login|uats|search|order * - * Temporary diagnostic endpoint to test ePay integration step-by-step. - * - * Steps: - * login — test login + show credits - * uats — resolve ePay UAT IDs for Cluj-Napoca, Feleacu, Florești - * search — search estates for the 3 test parcels - * order — enqueue orders for the 3 test parcels (USES 3 CREDITS!) + * Temporary diagnostic endpoint. Uses the CORRECT ePay flow: + * shoppingCartCompleteInformation → judeteNom → nomenclatorUAT → saveMetadata */ export async function GET(req: Request) { const url = new URL(req.url); @@ -29,97 +23,90 @@ export async function GET(req: Request) { const password = process.env.ANCPI_PASSWORD ?? ""; if (!username || !password) { - return NextResponse.json({ - error: "ANCPI_USERNAME / ANCPI_PASSWORD not configured", - }); + return NextResponse.json({ error: "ANCPI credentials not configured" }); } + const normalize = (s: string) => + s.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toUpperCase().trim(); + try { - // ── Step: login ── + // ── login ── if (step === "login") { const client = await EpayClient.create(username, password); const credits = await client.getCredits(); createEpaySession(username, password, credits); - return NextResponse.json({ - step: "login", - success: true, - credits, - countyIdxCluj: resolveEpayCountyIndex("Cluj"), - }); + return NextResponse.json({ step: "login", success: true, credits }); } - // ── Step: uats ── + // ── uats ── Get internal county IDs + UAT list for Cluj if (step === "uats") { const client = await EpayClient.create(username, password); - const countyIdx = resolveEpayCountyIndex("Cluj"); - if (countyIdx === null) { - return NextResponse.json({ error: "Could not resolve Cluj county index" }); + await client.addToCart(14200); // need cart for ShowCartItems context + + const counties = await client.getCountyList(); + const clujCounty = counties.find((c) => normalize(c.value) === "CLUJ"); + + let uatList: { id: number; value: string }[] = []; + if (clujCounty) { + uatList = await client.getUatList(clujCounty.id); } - const uatList = await client.getUatList(countyIdx); - // Find the 3 UATs - const normalize = (s: string) => - s.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toUpperCase(); - - const clujNapoca = uatList.find((u) => normalize(u.value).includes("CLUJ-NAPOCA") || normalize(u.value).includes("CLUJNAPOCA")); + const clujNapoca = uatList.find((u) => normalize(u.value).includes("CLUJ-NAPOCA")); const feleacu = uatList.find((u) => normalize(u.value).includes("FELEACU")); const floresti = uatList.find((u) => normalize(u.value).includes("FLORESTI")); return NextResponse.json({ step: "uats", - countyIdx, + totalCounties: counties.length, + countiesFirst5: counties.slice(0, 5), + clujCounty, totalUats: uatList.length, - first5: uatList.slice(0, 5), - clujNapoca, - feleacu, - floresti, + matches: { clujNapoca, feleacu, floresti }, }); } - // ── Step: search ── - // Test UAT lookup (JSON) + SearchEstate (with basketId) + // ── search ── SearchEstate with correct params if (step === "search") { const client = await EpayClient.create(username, password); - const countyIdx = resolveEpayCountyIndex("Cluj")!; - const results: Record = { countyIdx }; + await client.addToCart(14200); - // 1. Get UAT list (JSON body now) - const uatList = await client.getUatList(countyIdx); - results["uatCount"] = uatList.length; - results["uatFirst5"] = uatList.slice(0, 5); + const counties = await client.getCountyList(); + const clujCounty = counties.find((c) => normalize(c.value) === "CLUJ"); + if (!clujCounty) { + return NextResponse.json({ error: "CLUJ not found", counties: counties.slice(0, 5) }); + } - // Find our test UATs - const normalize = (s: string) => - s.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toUpperCase(); - const clujNapoca = uatList.find((u) => normalize(u.value).includes("CLUJ-NAPOCA") || normalize(u.value).includes("CLUJNAPOCA")); + const uatList = await client.getUatList(clujCounty.id); + const clujNapoca = uatList.find((u) => normalize(u.value).includes("CLUJ-NAPOCA")); const feleacu = uatList.find((u) => normalize(u.value).includes("FELEACU")); const floresti = uatList.find((u) => normalize(u.value).includes("FLORESTI")); - results["uatMatches"] = { clujNapoca, feleacu, floresti }; - // 2. Add to cart + SearchEstate with basketId - const basketRowId = await client.addToCart(14200); - results["basketRowId"] = basketRowId; + const results: Record = { + clujCountyId: clujCounty.id, + uats: { clujNapoca, feleacu, floresti }, + }; + // SearchEstate with correct field names: identificator, judet (internal ID), uat (real ID) if (clujNapoca) { results["search_345295"] = await client - .searchEstate("345295", countyIdx, clujNapoca.id, basketRowId) + .searchEstate("345295", clujCounty.id, clujNapoca.id) .catch((e: Error) => ({ error: e.message })); } if (feleacu) { results["search_63565"] = await client - .searchEstate("63565", countyIdx, feleacu.id, basketRowId) + .searchEstate("63565", clujCounty.id, feleacu.id) .catch((e: Error) => ({ error: e.message })); } if (floresti) { results["search_88089"] = await client - .searchEstate("88089", countyIdx, floresti.id, basketRowId) + .searchEstate("88089", clujCounty.id, floresti.id) .catch((e: Error) => ({ error: e.message })); } return NextResponse.json({ step: "search", results }); } - // ── Step: order ── (USES 3 CREDITS!) + // ── order ── (USES 3 CREDITS!) if (step === "order") { if (!getEpayCredentials()) { createEpaySession(username, password, 0); @@ -130,53 +117,15 @@ export async function GET(req: Request) { createEpaySession(username, password, credits); if (credits < 3) { - return NextResponse.json({ - error: `Doar ${credits} credite disponibile, trebuie 3.`, - }); - } - - const countyIdx = resolveEpayCountyIndex("Cluj")!; - const uatList = await client.getUatList(countyIdx); - const normalize = (s: string) => - s.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toUpperCase(); - - const findUat = (name: string) => - uatList.find((u) => normalize(u.value).includes(name)); - - const clujNapoca = findUat("CLUJ-NAPOCA") ?? findUat("CLUJNAPOCA"); - const feleacu = findUat("FELEACU"); - const floresti = findUat("FLORESTI"); - - if (!clujNapoca || !feleacu || !floresti) { - return NextResponse.json({ - error: "Nu s-au găsit UAT-urile.", - uatCount: uatList.length, - clujNapoca, feleacu, floresti, - }); + return NextResponse.json({ error: `Doar ${credits} credite, trebuie 3.` }); } + // Resolve county + UAT IDs through the queue + // Queue handles getCountyList + getUatList internally const parcels = [ - { - nrCadastral: "345295", - judetIndex: countyIdx, - judetName: "CLUJ", - uatId: clujNapoca.id, - uatName: clujNapoca.value, - }, - { - nrCadastral: "63565", - judetIndex: countyIdx, - judetName: "CLUJ", - uatId: feleacu.id, - uatName: feleacu.value, - }, - { - nrCadastral: "88089", - judetIndex: countyIdx, - judetName: "CLUJ", - uatId: floresti.id, - uatName: floresti.value, - }, + { nrCadastral: "345295", judetName: "CLUJ", uatName: "Cluj-Napoca", judetIndex: 0, uatId: 0 }, + { nrCadastral: "63565", judetName: "CLUJ", uatName: "Feleacu", judetIndex: 0, uatId: 0 }, + { nrCadastral: "88089", judetName: "CLUJ", uatName: "Floresti", judetIndex: 0, uatId: 0 }, ]; const ids: string[] = []; @@ -188,20 +137,12 @@ export async function GET(req: Request) { return NextResponse.json({ step: "order", credits, - message: `Enqueued ${ids.length} orders. Processing sequentially...`, + message: `Enqueued ${ids.length} orders. Queue resolves county/UAT IDs automatically.`, orderIds: ids, - parcels: parcels.map((p, i) => ({ - nrCadastral: p.nrCadastral, - uatName: p.uatName, - uatId: p.uatId, - extractId: ids[i], - })), }); } - return NextResponse.json({ - error: `Unknown step: ${step}. Use: login, uats, search, order`, - }); + return NextResponse.json({ error: `Unknown step: ${step}` }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error(`[ancpi-test] Step ${step} failed:`, message); diff --git a/src/modules/parcel-sync/services/epay-client.ts b/src/modules/parcel-sync/services/epay-client.ts index eb3a935..39be661 100644 --- a/src/modules/parcel-sync/services/epay-client.ts +++ b/src/modules/parcel-sync/services/epay-client.ts @@ -1,25 +1,31 @@ /** * 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 + * Reverse-engineered from epaymentAngularApp.js (123KB). + * All endpoints and payloads verified against actual Angular source. * - * Modeled after eterra-client.ts: cookie jar, session cache, retry, auto-relogin. + * 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 AxiosError, type AxiosInstance } from "axios"; +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, - OrderMetadata, } from "./epay-types"; /* ------------------------------------------------------------------ */ @@ -30,14 +36,13 @@ 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 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; // 10 minutes +const POLL_MAX_ATTEMPTS = 40; /* ------------------------------------------------------------------ */ -/* Session cache (global singleton) */ +/* Session cache */ /* ------------------------------------------------------------------ */ type SessionEntry = { @@ -57,17 +62,8 @@ 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 */ /* ------------------------------------------------------------------ */ @@ -77,7 +73,6 @@ export class EpayClient { private jar: CookieJar; private username: string; private password: string; - private reloginAttempted = false; private constructor( client: AxiosInstance, @@ -108,10 +103,9 @@ export class EpayClient { axios.create({ jar, withCredentials: true, - maxRedirects: 5, + maxRedirects: 10, headers: { - Accept: - "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + 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", }, @@ -127,137 +121,429 @@ export class EpayClient { /* ── 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) + // Step 1: GET login page (sets initial cookies) 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") - ) { + 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; + const epayResponse = await this.client.get(gotoUrl, { + timeout: DEFAULT_TIMEOUT_MS, + validateStatus: () => true, + }); + const epayHtml = String(epayResponse.data ?? ""); - 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)"); + 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."); } - private async ensureSession(): Promise { - if (this.reloginAttempted) return; - // Re-login if needed (lazy check — will be triggered on redirects) + /* ── 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; } - 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 : ""; + /* ── Cart ───────────────────────────────────────────────────── */ - if ( - (err?.response?.status === 302 || isRedirectToLogin(html)) && - !this.reloginAttempted - ) { - this.reloginAttempted = true; - await this.login(); - this.reloginAttempted = false; - return await fn(); - } - throw error; + 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; } - /* ── Raw HTML fetch (for page scraping) ─────────────────── */ + /* ── 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", + }; + + 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") { + 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, - 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, { @@ -266,408 +552,8 @@ export class EpayClient { ...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); - }); - } } diff --git a/src/modules/parcel-sync/services/epay-queue.ts b/src/modules/parcel-sync/services/epay-queue.ts index c0581f0..de09433 100644 --- a/src/modules/parcel-sync/services/epay-queue.ts +++ b/src/modules/parcel-sync/services/epay-queue.ts @@ -175,20 +175,54 @@ async function processItem(item: QueueItem): Promise { const basketRowId = await client.addToCart(input.prodId ?? 14200); await updateStatus(extractId, "cart", { basketRowId }); - // Step 3: Configure + submit order - // Skip SearchEstate — we already have the data from eTerra. - // configureCartItem (via EditCartItemJson) + submitOrder (via EditCartSubmit) + // Step 3: Resolve internal county + UAT IDs + await updateStatus(extractId, "searching"); + const counties = await client.getCountyList(); + const normalize = (s: string) => + s.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toUpperCase().trim(); + + const countyMatch = counties.find( + (c) => normalize(c.value) === normalize(input.judetName), + ); + if (!countyMatch) { + await updateStatus(extractId, "failed", { + errorMessage: `Județul "${input.judetName}" nu a fost găsit în ePay. Disponibile: ${counties.slice(0, 5).map((c) => c.value).join(", ")}...`, + }); + return; + } + + const uats = await client.getUatList(countyMatch.id); + const uatMatch = uats.find( + (u) => normalize(u.value) === normalize(input.uatName), + ); + if (!uatMatch) { + await updateStatus(extractId, "failed", { + errorMessage: `UAT "${input.uatName}" nu a fost găsit în ePay pentru ${input.judetName}.`, + }); + return; + } + + // Step 4: Save metadata + submit order await updateStatus(extractId, "ordering"); const nrCF = input.nrCF ?? input.nrCadastral; - const orderId = await client.submitOrder({ + const saved = await client.saveMetadata( basketRowId, - judetIndex: input.judetIndex, - uatId: input.uatId, + countyMatch.id, + countyMatch.value, + uatMatch.id, + uatMatch.value, nrCF, - nrCadastral: input.nrCadastral, - solicitantId: - process.env.ANCPI_DEFAULT_SOLICITANT_ID || "14452", - }); + input.nrCadastral, + process.env.ANCPI_DEFAULT_SOLICITANT_ID || "14452", + ); + if (!saved) { + await updateStatus(extractId, "failed", { + errorMessage: "Salvarea metadatelor în ePay a eșuat.", + }); + return; + } + + const orderId = await client.submitOrder(); await updateStatus(extractId, "polling", { orderId });