fix(ancpi): complete rewrite based on Angular source code analysis

All endpoints and payloads verified against epaymentAngularApp.js:
- EpayJsonInterceptor: form-urlencoded (not JSON), uses reqType param
- County IDs: internal ANCPI IDs from judeteNom (NOT 0-41 indices)
- UAT lookup: reqType=nomenclatorUAT&countyId=<internal_ID>
- Save metadata: reqType=saveProductMetadataForBasketItem (multipart)
  with productMetadataJSON using stringValues[] arrays
- SearchEstate: field names are identificator/judet/uat (not identifier/countyId/uatId)
- Download PDF: Content-Type: application/pdf in request header
- Queue resolves county+UAT IDs dynamically via getCountyList+getUatList

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-23 02:01:39 +02:00
parent eb8cd18210
commit e13a9351be
3 changed files with 490 additions and 629 deletions
+49 -108
View File
@@ -1,6 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { EpayClient } from "@/modules/parcel-sync/services/epay-client"; import { EpayClient } from "@/modules/parcel-sync/services/epay-client";
import { resolveEpayCountyIndex } from "@/modules/parcel-sync/services/epay-counties";
import { import {
createEpaySession, createEpaySession,
getEpayCredentials, getEpayCredentials,
@@ -11,15 +10,10 @@ export const runtime = "nodejs";
export const dynamic = "force-dynamic"; 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. * Temporary diagnostic endpoint. Uses the CORRECT ePay flow:
* * shoppingCartCompleteInformation → judeteNom → nomenclatorUAT → saveMetadata
* 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!)
*/ */
export async function GET(req: Request) { export async function GET(req: Request) {
const url = new URL(req.url); const url = new URL(req.url);
@@ -29,97 +23,90 @@ export async function GET(req: Request) {
const password = process.env.ANCPI_PASSWORD ?? ""; const password = process.env.ANCPI_PASSWORD ?? "";
if (!username || !password) { if (!username || !password) {
return NextResponse.json({ return NextResponse.json({ error: "ANCPI credentials not configured" });
error: "ANCPI_USERNAME / ANCPI_PASSWORD not configured",
});
} }
const normalize = (s: string) =>
s.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toUpperCase().trim();
try { try {
// ── Step: login ── // ── login ──
if (step === "login") { if (step === "login") {
const client = await EpayClient.create(username, password); const client = await EpayClient.create(username, password);
const credits = await client.getCredits(); const credits = await client.getCredits();
createEpaySession(username, password, credits); createEpaySession(username, password, credits);
return NextResponse.json({ return NextResponse.json({ step: "login", success: true, credits });
step: "login",
success: true,
credits,
countyIdxCluj: resolveEpayCountyIndex("Cluj"),
});
} }
// ── Step: uats ── // ── uats ── Get internal county IDs + UAT list for Cluj
if (step === "uats") { if (step === "uats") {
const client = await EpayClient.create(username, password); const client = await EpayClient.create(username, password);
const countyIdx = resolveEpayCountyIndex("Cluj"); await client.addToCart(14200); // need cart for ShowCartItems context
if (countyIdx === null) {
return NextResponse.json({ error: "Could not resolve Cluj county index" }); 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); const clujNapoca = uatList.find((u) => normalize(u.value).includes("CLUJ-NAPOCA"));
// 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 feleacu = uatList.find((u) => normalize(u.value).includes("FELEACU")); const feleacu = uatList.find((u) => normalize(u.value).includes("FELEACU"));
const floresti = uatList.find((u) => normalize(u.value).includes("FLORESTI")); const floresti = uatList.find((u) => normalize(u.value).includes("FLORESTI"));
return NextResponse.json({ return NextResponse.json({
step: "uats", step: "uats",
countyIdx, totalCounties: counties.length,
countiesFirst5: counties.slice(0, 5),
clujCounty,
totalUats: uatList.length, totalUats: uatList.length,
first5: uatList.slice(0, 5), matches: { clujNapoca, feleacu, floresti },
clujNapoca,
feleacu,
floresti,
}); });
} }
// ── Step: search ── // ── search ── SearchEstate with correct params
// Test UAT lookup (JSON) + SearchEstate (with basketId)
if (step === "search") { if (step === "search") {
const client = await EpayClient.create(username, password); const client = await EpayClient.create(username, password);
const countyIdx = resolveEpayCountyIndex("Cluj")!; await client.addToCart(14200);
const results: Record<string, unknown> = { countyIdx };
// 1. Get UAT list (JSON body now) const counties = await client.getCountyList();
const uatList = await client.getUatList(countyIdx); const clujCounty = counties.find((c) => normalize(c.value) === "CLUJ");
results["uatCount"] = uatList.length; if (!clujCounty) {
results["uatFirst5"] = uatList.slice(0, 5); return NextResponse.json({ error: "CLUJ not found", counties: counties.slice(0, 5) });
}
// Find our test UATs const uatList = await client.getUatList(clujCounty.id);
const normalize = (s: string) => const clujNapoca = uatList.find((u) => normalize(u.value).includes("CLUJ-NAPOCA"));
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 feleacu = uatList.find((u) => normalize(u.value).includes("FELEACU")); const feleacu = uatList.find((u) => normalize(u.value).includes("FELEACU"));
const floresti = uatList.find((u) => normalize(u.value).includes("FLORESTI")); const floresti = uatList.find((u) => normalize(u.value).includes("FLORESTI"));
results["uatMatches"] = { clujNapoca, feleacu, floresti };
// 2. Add to cart + SearchEstate with basketId const results: Record<string, unknown> = {
const basketRowId = await client.addToCart(14200); clujCountyId: clujCounty.id,
results["basketRowId"] = basketRowId; uats: { clujNapoca, feleacu, floresti },
};
// SearchEstate with correct field names: identificator, judet (internal ID), uat (real ID)
if (clujNapoca) { if (clujNapoca) {
results["search_345295"] = await client results["search_345295"] = await client
.searchEstate("345295", countyIdx, clujNapoca.id, basketRowId) .searchEstate("345295", clujCounty.id, clujNapoca.id)
.catch((e: Error) => ({ error: e.message })); .catch((e: Error) => ({ error: e.message }));
} }
if (feleacu) { if (feleacu) {
results["search_63565"] = await client results["search_63565"] = await client
.searchEstate("63565", countyIdx, feleacu.id, basketRowId) .searchEstate("63565", clujCounty.id, feleacu.id)
.catch((e: Error) => ({ error: e.message })); .catch((e: Error) => ({ error: e.message }));
} }
if (floresti) { if (floresti) {
results["search_88089"] = await client results["search_88089"] = await client
.searchEstate("88089", countyIdx, floresti.id, basketRowId) .searchEstate("88089", clujCounty.id, floresti.id)
.catch((e: Error) => ({ error: e.message })); .catch((e: Error) => ({ error: e.message }));
} }
return NextResponse.json({ step: "search", results }); return NextResponse.json({ step: "search", results });
} }
// ── Step: order ── (USES 3 CREDITS!) // ── order ── (USES 3 CREDITS!)
if (step === "order") { if (step === "order") {
if (!getEpayCredentials()) { if (!getEpayCredentials()) {
createEpaySession(username, password, 0); createEpaySession(username, password, 0);
@@ -130,53 +117,15 @@ export async function GET(req: Request) {
createEpaySession(username, password, credits); createEpaySession(username, password, credits);
if (credits < 3) { if (credits < 3) {
return NextResponse.json({ return NextResponse.json({ error: `Doar ${credits} credite, trebuie 3.` });
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,
});
} }
// Resolve county + UAT IDs through the queue
// Queue handles getCountyList + getUatList internally
const parcels = [ const parcels = [
{ { nrCadastral: "345295", judetName: "CLUJ", uatName: "Cluj-Napoca", judetIndex: 0, uatId: 0 },
nrCadastral: "345295", { nrCadastral: "63565", judetName: "CLUJ", uatName: "Feleacu", judetIndex: 0, uatId: 0 },
judetIndex: countyIdx, { nrCadastral: "88089", judetName: "CLUJ", uatName: "Floresti", judetIndex: 0, uatId: 0 },
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,
},
]; ];
const ids: string[] = []; const ids: string[] = [];
@@ -188,20 +137,12 @@ export async function GET(req: Request) {
return NextResponse.json({ return NextResponse.json({
step: "order", step: "order",
credits, credits,
message: `Enqueued ${ids.length} orders. Processing sequentially...`, message: `Enqueued ${ids.length} orders. Queue resolves county/UAT IDs automatically.`,
orderIds: ids, orderIds: ids,
parcels: parcels.map((p, i) => ({
nrCadastral: p.nrCadastral,
uatName: p.uatName,
uatId: p.uatId,
extractId: ids[i],
})),
}); });
} }
return NextResponse.json({ return NextResponse.json({ error: `Unknown step: ${step}` });
error: `Unknown step: ${step}. Use: login, uats, search, order`,
});
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
console.error(`[ancpi-test] Step ${step} failed:`, message); console.error(`[ancpi-test] Step ${step} failed:`, message);
+245 -359
View File
@@ -1,25 +1,31 @@
/** /**
* ANCPI ePay client — server-side only. * ANCPI ePay client — server-side only.
* *
* Authenticates via oassl.ancpi.ro, keeps cookie jar (iPlanetDirectoryPro + JSESSIONID), * Reverse-engineered from epaymentAngularApp.js (123KB).
* and implements the full CF extract ordering workflow: * All endpoints and payloads verified against actual Angular source.
* 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. * 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 crypto from "crypto";
import { wrapper } from "axios-cookiejar-support"; import { wrapper } from "axios-cookiejar-support";
import { CookieJar } from "tough-cookie"; import { CookieJar } from "tough-cookie";
import FormData from "form-data";
import type { import type {
EpayCartResponse, EpayCartResponse,
EpaySearchResult, EpaySearchResult,
EpayUatEntry, EpayUatEntry,
EpayOrderStatus, EpayOrderStatus,
EpaySolutionDoc, EpaySolutionDoc,
OrderMetadata,
} from "./epay-types"; } from "./epay-types";
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -30,14 +36,13 @@ const BASE_URL = process.env.ANCPI_BASE_URL || "https://epay.ancpi.ro/epay";
const LOGIN_URL = const LOGIN_URL =
process.env.ANCPI_LOGIN_URL || process.env.ANCPI_LOGIN_URL ||
"https://oassl.ancpi.ro/openam/UI/Login"; "https://oassl.ancpi.ro/openam/UI/Login";
const DEFAULT_TIMEOUT_MS = 60_000; // ePay is slow const DEFAULT_TIMEOUT_MS = 60_000;
const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour (ePay sessions last longer) const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour
const MAX_RETRIES = 3;
const POLL_INTERVAL_MS = 15_000; 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 = { type SessionEntry = {
@@ -57,17 +62,8 @@ globalStore.__epaySessionCache = sessionCache;
const makeCacheKey = (u: string, p: string) => const makeCacheKey = (u: string, p: string) =>
crypto.createHash("sha256").update(`epay:${u}:${p}`).digest("hex"); crypto.createHash("sha256").update(`epay:${u}:${p}`).digest("hex");
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); 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 */ /* Client */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -77,7 +73,6 @@ export class EpayClient {
private jar: CookieJar; private jar: CookieJar;
private username: string; private username: string;
private password: string; private password: string;
private reloginAttempted = false;
private constructor( private constructor(
client: AxiosInstance, client: AxiosInstance,
@@ -108,10 +103,9 @@ export class EpayClient {
axios.create({ axios.create({
jar, jar,
withCredentials: true, withCredentials: true,
maxRedirects: 5, maxRedirects: 10,
headers: { headers: {
Accept: Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"User-Agent": "User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
}, },
@@ -127,198 +121,63 @@ export class EpayClient {
/* ── Auth ───────────────────────────────────────────────────── */ /* ── Auth ───────────────────────────────────────────────────── */
private async login(): Promise<void> { private async login(): Promise<void> {
// Full login URL with module + goto params (required by OpenAM)
const gotoUrl = "http://epay.ancpi.ro:80/epay/LogIn.action"; const gotoUrl = "http://epay.ancpi.ro:80/epay/LogIn.action";
const loginUrlFull = `${LOGIN_URL}?module=SelfRegistration&goto=${encodeURIComponent(gotoUrl)}`; 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, { await this.client.get(loginUrlFull, {
timeout: DEFAULT_TIMEOUT_MS, timeout: DEFAULT_TIMEOUT_MS,
maxRedirects: 5,
validateStatus: () => true, validateStatus: () => true,
}); });
// Step 2: POST credentials // Step 2: POST credentials
const body = `IDToken1=${encodeURIComponent(this.username)}&IDToken2=${encodeURIComponent(this.password)}`; const body = `IDToken1=${encodeURIComponent(this.username)}&IDToken2=${encodeURIComponent(this.password)}`;
const response = await this.client.post(loginUrlFull, body, { const response = await this.client.post(loginUrlFull, body, {
headers: { headers: {
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
Referer: loginUrlFull, Referer: loginUrlFull,
}, },
timeout: DEFAULT_TIMEOUT_MS, timeout: DEFAULT_TIMEOUT_MS,
maxRedirects: 10,
validateStatus: () => true, validateStatus: () => true,
}); });
const finalUrl =
response.request?.res?.responseUrl ??
response.request?.responseURL ??
"";
const html = typeof response.data === "string" ? response.data : ""; const html = typeof response.data === "string" ? response.data : "";
if (html.includes("Authentication Failed") || html.includes("Autentificare esuata")) {
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)"); throw new Error("ePay login failed (invalid credentials)");
} }
// Step 3: Navigate to ePay to establish JSESSIONID // Step 3: Navigate to ePay to establish JSESSIONID
// Try the goto URL (HTTP) and HTTPS variants const epayResponse = await this.client.get(gotoUrl, {
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, timeout: DEFAULT_TIMEOUT_MS,
maxRedirects: 10,
validateStatus: () => true, validateStatus: () => true,
}); });
const epayHtml = String(epayResponse.data ?? ""); const epayHtml = String(epayResponse.data ?? "");
if ( if (!epayHtml.includes("puncte de credit") && !epayHtml.includes("LogOut")) {
epayHtml.includes("puncte de credit") || // Try HTTPS
epayHtml.includes("LogOut") || await this.client.get(`${BASE_URL}/LogIn.action`, {
epayHtml.includes("Istoric Comenzi") timeout: DEFAULT_TIMEOUT_MS,
) { validateStatus: () => true,
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."); console.log("[epay] Login successful.");
} }
private async ensureSession(): Promise<void> {
if (this.reloginAttempted) return;
// Re-login if needed (lazy check — will be triggered on redirects)
}
private async retryOnAuthFail<T>(fn: () => Promise<T>): Promise<T> {
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<string> {
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<string, string>): Promise<any> {
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 ───────────────────────────────────────────────── */ /* ── Credits ───────────────────────────────────────────────── */
async getCredits(): Promise<number> { async getCredits(): Promise<number> {
return this.retryOnAuthFail(async () => {
const response = await this.client.get(`${BASE_URL}/LogIn.action`, { const response = await this.client.get(`${BASE_URL}/LogIn.action`, {
timeout: DEFAULT_TIMEOUT_MS, timeout: DEFAULT_TIMEOUT_MS,
}); });
const html = String(response.data ?? ""); const html = String(response.data ?? "");
const match = html.match(/(\d+)\s+puncte?\s+de\s+credit/i);
// Parse "Ai {N} puncte de credit" return match ? parseInt(match[1] ?? "0", 10) : 0;
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 ───────────────────────────────────────────────────── */ /* ── Cart ───────────────────────────────────────────────────── */
async addToCart(prodId = 14200): Promise<number> { async addToCart(prodId = 14200): Promise<number> {
return this.retryOnAuthFail(async () => {
const body = new URLSearchParams(); const body = new URLSearchParams();
body.set("prodId", String(prodId)); body.set("prodId", String(prodId));
body.set("productQtyModif", "1"); body.set("productQtyModif", "1");
@@ -341,42 +200,102 @@ export class EpayClient {
const data = response.data as EpayCartResponse; const data = response.data as EpayCartResponse;
const item = data?.items?.[0]; const item = data?.items?.[0];
if (!item?.id) { if (!item?.id) {
throw new Error( throw new Error(`ePay addToCart failed: ${JSON.stringify(data).slice(0, 200)}`);
`ePay addToCart failed: ${JSON.stringify(data).slice(0, 200)}`,
);
} }
console.log(`[epay] Added to cart: basketRowId=${item.id}`); console.log(`[epay] Added to cart: basketRowId=${item.id}`);
return item.id; return item.id;
});
} }
/* ── Estate Search ─────────────────────────────────────────── */ /* ── EpayJsonInterceptor (form-urlencoded) ─────────────────── */
/** /**
* Search estate on ePay. Requires basketRowId in context. * Call EpayJsonInterceptor with form-urlencoded body.
* countyIdx = ePay county index (0-41), uatId = real ePay UAT ID (NOT index). * This is how Angular's doPostAsForm works.
*/ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private async interceptor(params: Record<string, string>): Promise<any> {
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<EpayUatEntry[]> {
// 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<EpayUatEntry[]> {
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( async searchEstate(
identifier: string, identifier: string,
countyIdx: number, countyInternalId: number,
uatId: number, uatId: number,
basketRowId: number,
): Promise<EpaySearchResult[]> { ): Promise<EpaySearchResult[]> {
return this.retryOnAuthFail(async () => {
// Must include basketId for SearchEstate to return JSON
const body = new URLSearchParams(); const body = new URLSearchParams();
body.set("identifier", identifier); body.set("identificator", identifier); // NOT "identifier"!
body.set("countyId", String(countyIdx)); body.set("judet", String(countyInternalId)); // NOT "countyId"!
body.set("uatId", String(uatId)); body.set("uat", String(uatId)); // real UAT ID
body.set("basketId", String(basketRowId));
const response = await this.client.post( const response = await this.client.post(
`${BASE_URL}/SearchEstate.action`, `${BASE_URL}/SearchEstate.action`,
body.toString(), body.toString(),
{ {
headers: { headers: {
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"X-Requested-With": "XMLHttpRequest", "X-Requested-With": "XMLHttpRequest",
}, },
timeout: DEFAULT_TIMEOUT_MS, timeout: DEFAULT_TIMEOUT_MS,
@@ -385,134 +304,140 @@ export class EpayClient {
const data = response.data; const data = response.data;
if (Array.isArray(data)) return data as EpaySearchResult[]; if (Array.isArray(data)) return data as EpaySearchResult[];
if (typeof data === "string" && data.trim().startsWith("[")) {
if (typeof data === "string") { try { return JSON.parse(data) as EpaySearchResult[]; } catch { /* */ }
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 []; return [];
});
} }
/* ── UAT Lookup ────────────────────────────────────────────── */ /* ── Save Metadata (multipart/form-data) ───────────────────── */
/** /**
* Get UAT list for a county. Uses JSON Content-Type (not form-urlencoded). * Save cart item metadata via EpayJsonInterceptor with
* reqType=saveProductMetadataForBasketItem.
* Uses multipart/form-data with productMetadataJSON field.
*/ */
async getUatList(countyIdx: number): Promise<EpayUatEntry[]> { async saveMetadata(
return this.retryOnAuthFail(async () => { basketRowId: number,
// EpayJsonInterceptor requires JSON body, not form-urlencoded countyInternalId: number,
countyName: string,
uatId: number,
uatName: string,
nrCF: string,
nrCadastral: string,
solicitantId: string,
): Promise<boolean> {
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( const response = await this.client.post(
`${BASE_URL}/EpayJsonInterceptor.action`, `${BASE_URL}/EpayJsonInterceptor.action`,
JSON.stringify({ judet: countyIdx }), form,
{ {
headers: { headers: {
"Content-Type": "application/json", ...form.getHeaders(),
Accept: "application/json", "X-Requested-With": "XMLHttpRequest",
}, },
timeout: DEFAULT_TIMEOUT_MS, timeout: DEFAULT_TIMEOUT_MS,
}, },
); );
const data = response.data; const data = response.data;
const result =
typeof data?.jsonResult === "string"
? JSON.parse(data.jsonResult)
: data;
// Response: { jsonResult: "[{\"id\":55473,\"value\":\"Aghiresu\"}, ...]" } if (result?.code === "SUCCESS") {
if (data?.jsonResult && typeof data.jsonResult === "string") { console.log(`[epay] Metadata saved for basket ${basketRowId}.`);
try { return true;
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.error("[epay] saveMetadata failed:", JSON.stringify(result).slice(0, 300));
return false;
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<void> {
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 ──────────────────────────────────────── */ /* ── Order Submission ──────────────────────────────────────── */
/** async submitOrder(): Promise<string> {
* Submit order after configuring cart item via EditCartItemJson.
*/
async submitOrder(metadata: OrderMetadata): Promise<string> {
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(); const body = new URLSearchParams();
body.set("goToCheckout", "true"); body.set("goToCheckout", "true");
const response = await this.client.post( await this.client.post(
`${BASE_URL}/EditCartSubmit.action`, `${BASE_URL}/EditCartSubmit.action`,
body.toString(), body.toString(),
{ {
@@ -523,73 +448,35 @@ export class EpayClient {
}, },
); );
// 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(); 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<string> { private async getLatestOrderId(): Promise<string> {
const response = await this.client.get(`${BASE_URL}/LogIn.action`, { const response = await this.client.get(`${BASE_URL}/LogIn.action`, {
timeout: DEFAULT_TIMEOUT_MS, timeout: DEFAULT_TIMEOUT_MS,
}); });
const html = String(response.data ?? ""); const html = String(response.data ?? "");
const match = html.match(/ShowOrderDetails\.action\?orderId=(\d+)/);
// 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] ?? ""; 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"); throw new Error("Could not determine orderId after checkout");
} }
/* ── Order Status & Polling ────────────────────────────────── */ /* ── Order Status & Polling ────────────────────────────────── */
async getOrderStatus(orderId: string): Promise<EpayOrderStatus> { async getOrderStatus(orderId: string): Promise<EpayOrderStatus> {
return this.retryOnAuthFail(async () => {
const response = await this.client.get( const response = await this.client.get(
`${BASE_URL}/ShowOrderDetails.action?orderId=${orderId}`, `${BASE_URL}/ShowOrderDetails.action?orderId=${orderId}`,
{ timeout: DEFAULT_TIMEOUT_MS }, { timeout: DEFAULT_TIMEOUT_MS },
); );
const html = String(response.data ?? ""); const html = String(response.data ?? "");
// Parse status
let status = "Receptionata"; let status = "Receptionata";
if (html.includes("Finalizata")) status = "Finalizata"; if (html.includes("Finalizata")) status = "Finalizata";
else if (html.includes("In curs de procesare")) else if (html.includes("In curs de procesare")) status = "In curs de procesare";
status = "In curs de procesare";
else if (html.includes("Anulata")) status = "Anulata"; else if (html.includes("Anulata")) status = "Anulata";
else if (html.includes("Plata refuzata")) status = "Plata refuzata"; else if (html.includes("Plata refuzata")) status = "Plata refuzata";
// Parse documents — look for JSON-like solutie data in Angular scope
const documents: EpaySolutionDoc[] = []; const documents: EpaySolutionDoc[] = [];
// Pattern: idDocument, nume, dataDocument, linkDownload
const docPattern = const docPattern =
/"idDocument"\s*:\s*(\d+)[\s\S]*?"nume"\s*:\s*"([^"]+)"[\s\S]*?"dataDocument"\s*:\s*"([^"]+)"[\s\S]*?"linkDownload"\s*:\s*"([^"]*)"/g; /"idDocument"\s*:\s*(\d+)[\s\S]*?"nume"\s*:\s*"([^"]+)"[\s\S]*?"dataDocument"\s*:\s*"([^"]+)"[\s\S]*?"linkDownload"\s*:\s*"([^"]*)"/g;
let docMatch; let docMatch;
@@ -611,63 +498,62 @@ export class EpayClient {
} }
return { orderId, status, documents }; return { orderId, status, documents };
});
} }
/**
* Poll order status until completed or timeout.
* Returns the final status with document info.
*/
async pollUntilComplete( async pollUntilComplete(
orderId: string, orderId: string,
onProgress?: (attempt: number, status: string) => void, onProgress?: (attempt: number, status: string) => void,
): Promise<EpayOrderStatus> { ): Promise<EpayOrderStatus> {
for (let attempt = 1; attempt <= POLL_MAX_ATTEMPTS; attempt++) { for (let attempt = 1; attempt <= POLL_MAX_ATTEMPTS; attempt++) {
const status = await this.getOrderStatus(orderId); const status = await this.getOrderStatus(orderId);
if (onProgress) onProgress(attempt, status.status); if (onProgress) onProgress(attempt, status.status);
if (["Finalizata", "Anulata", "Plata refuzata"].includes(status.status)) {
if (
status.status === "Finalizata" ||
status.status === "Anulata" ||
status.status === "Plata refuzata"
) {
return status; return status;
} }
await sleep(POLL_INTERVAL_MS); await sleep(POLL_INTERVAL_MS);
} }
throw new Error(`ePay order ${orderId} timed out after ${POLL_MAX_ATTEMPTS} poll attempts`);
throw new Error(
`ePay order ${orderId} timed out after ${POLL_MAX_ATTEMPTS} poll attempts`,
);
} }
/* ── Document Download ─────────────────────────────────────── */ /* ── Document Download ─────────────────────────────────────── */
async downloadDocument( async downloadDocument(idDocument: number, typeD = 4): Promise<Buffer> {
idDocument: number,
typeD = 4,
): Promise<Buffer> {
return this.retryOnAuthFail(async () => {
const url = `${BASE_URL}/DownloadFile.action?typeD=${typeD}&id=${idDocument}&source=&browser=chrome`; 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, { const response = await this.client.post(url, null, {
headers: { "Content-Type": "application/pdf" },
timeout: DEFAULT_TIMEOUT_MS, timeout: DEFAULT_TIMEOUT_MS,
responseType: "arraybuffer", responseType: "arraybuffer",
}); });
const data = response.data; const data = response.data;
if (!data || data.length < 100) { if (!data || data.length < 100) {
throw new Error( throw new Error(`ePay download empty (${data?.length ?? 0} bytes)`);
`ePay download returned empty/small response (${data?.length ?? 0} bytes)`, }
); console.log(`[epay] Downloaded document ${idDocument}: ${data.length} bytes`);
return Buffer.from(data);
} }
console.log( /* ── Utility ───────────────────────────────────────────────── */
`[epay] Downloaded document ${idDocument}: ${data.length} bytes`,
); async getRawHtml(url: string): Promise<string> {
return Buffer.from(data); 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<string, string>): Promise<any> {
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;
} }
} }
+42 -8
View File
@@ -175,20 +175,54 @@ async function processItem(item: QueueItem): Promise<void> {
const basketRowId = await client.addToCart(input.prodId ?? 14200); const basketRowId = await client.addToCart(input.prodId ?? 14200);
await updateStatus(extractId, "cart", { basketRowId }); await updateStatus(extractId, "cart", { basketRowId });
// Step 3: Configure + submit order // Step 3: Resolve internal county + UAT IDs
// Skip SearchEstate — we already have the data from eTerra. await updateStatus(extractId, "searching");
// configureCartItem (via EditCartItemJson) + submitOrder (via EditCartSubmit) 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"); await updateStatus(extractId, "ordering");
const nrCF = input.nrCF ?? input.nrCadastral; const nrCF = input.nrCF ?? input.nrCadastral;
const orderId = await client.submitOrder({ const saved = await client.saveMetadata(
basketRowId, basketRowId,
judetIndex: input.judetIndex, countyMatch.id,
uatId: input.uatId, countyMatch.value,
uatMatch.id,
uatMatch.value,
nrCF, nrCF,
nrCadastral: input.nrCadastral, input.nrCadastral,
solicitantId:
process.env.ANCPI_DEFAULT_SOLICITANT_ID || "14452", 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 }); await updateStatus(extractId, "polling", { orderId });