Files
ArchiTools/src/modules/parcel-sync/services/epay-client.ts
T
AI Assistant c452bd9fb7 fix(ancpi): use form-data multipart for saveProductMetadataForBasketItem
Angular uses doPostAsFormMultipart — the save endpoint requires
multipart/form-data, not application/x-www-form-urlencoded.
Install form-data package and restore multipart upload.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 02:33:30 +02:00

561 lines
19 KiB
TypeScript

/**
* ANCPI ePay client — server-side only.
*
* Reverse-engineered from epaymentAngularApp.js (123KB).
* All endpoints and payloads verified against actual Angular source.
*
* Flow:
* 1. Login (OpenAM) → AMAuthCookie
* 2. GET ShowCartItems → Angular session context
* 3. EpayJsonInterceptor(shoppingCartCompleteInformation) → judeteNom IDs
* 4. EpayJsonInterceptor(nomenclatorUAT) → UAT IDs
* 5. EpayJsonInterceptor(saveProductMetadataForBasketItem) → save (multipart)
* 6. EditCartSubmit(goToCheckout=true) → order
* 7. Poll ShowOrderDetails → idDocument
* 8. DownloadFile(Content-Type: application/pdf) → PDF
*/
import axios, { type AxiosInstance } from "axios";
import crypto from "crypto";
import { wrapper } from "axios-cookiejar-support";
import { CookieJar } from "tough-cookie";
import FormData from "form-data";
import type {
EpayCartResponse,
EpaySearchResult,
EpayUatEntry,
EpayOrderStatus,
EpaySolutionDoc,
} from "./epay-types";
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
const BASE_URL = process.env.ANCPI_BASE_URL || "https://epay.ancpi.ro/epay";
const LOGIN_URL =
process.env.ANCPI_LOGIN_URL ||
"https://oassl.ancpi.ro/openam/UI/Login";
const DEFAULT_TIMEOUT_MS = 60_000;
const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour
const POLL_INTERVAL_MS = 15_000;
const POLL_MAX_ATTEMPTS = 40;
/* ------------------------------------------------------------------ */
/* Session cache */
/* ------------------------------------------------------------------ */
type SessionEntry = {
jar: CookieJar;
client: AxiosInstance;
createdAt: number;
lastUsed: number;
};
const globalStore = globalThis as {
__epaySessionCache?: Map<string, SessionEntry>;
};
const sessionCache =
globalStore.__epaySessionCache ?? new Map<string, SessionEntry>();
globalStore.__epaySessionCache = sessionCache;
const makeCacheKey = (u: string, p: string) =>
crypto.createHash("sha256").update(`epay:${u}:${p}`).digest("hex");
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/* ------------------------------------------------------------------ */
/* Client */
/* ------------------------------------------------------------------ */
export class EpayClient {
private client: AxiosInstance;
private jar: CookieJar;
private username: string;
private password: string;
private constructor(
client: AxiosInstance,
jar: CookieJar,
username: string,
password: string,
) {
this.client = client;
this.jar = jar;
this.username = username;
this.password = password;
}
/* ── Factory ───────────────────────────────────────────────── */
static async create(username: string, password: string): Promise<EpayClient> {
const cacheKey = makeCacheKey(username, password);
const cached = sessionCache.get(cacheKey);
const now = Date.now();
if (cached && now - cached.lastUsed < SESSION_TTL_MS) {
cached.lastUsed = now;
return new EpayClient(cached.client, cached.jar, username, password);
}
const jar = new CookieJar();
const client = wrapper(
axios.create({
jar,
withCredentials: true,
maxRedirects: 10,
headers: {
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
},
}),
);
const instance = new EpayClient(client, jar, username, password);
await instance.login();
sessionCache.set(cacheKey, { jar, client, createdAt: now, lastUsed: now });
return instance;
}
/* ── Auth ───────────────────────────────────────────────────── */
private async login(): Promise<void> {
const gotoUrl = "http://epay.ancpi.ro:80/epay/LogIn.action";
const loginUrlFull = `${LOGIN_URL}?module=SelfRegistration&goto=${encodeURIComponent(gotoUrl)}`;
// Step 1: GET login page (sets initial cookies)
await this.client.get(loginUrlFull, {
timeout: DEFAULT_TIMEOUT_MS,
validateStatus: () => true,
});
// Step 2: POST credentials
const body = `IDToken1=${encodeURIComponent(this.username)}&IDToken2=${encodeURIComponent(this.password)}`;
const response = await this.client.post(loginUrlFull, body, {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Referer: loginUrlFull,
},
timeout: DEFAULT_TIMEOUT_MS,
validateStatus: () => true,
});
const html = typeof response.data === "string" ? response.data : "";
if (html.includes("Authentication Failed") || html.includes("Autentificare esuata")) {
throw new Error("ePay login failed (invalid credentials)");
}
// Step 3: Navigate to ePay to establish JSESSIONID
const epayResponse = await this.client.get(gotoUrl, {
timeout: DEFAULT_TIMEOUT_MS,
validateStatus: () => true,
});
const epayHtml = String(epayResponse.data ?? "");
if (!epayHtml.includes("puncte de credit") && !epayHtml.includes("LogOut")) {
// Try HTTPS
await this.client.get(`${BASE_URL}/LogIn.action`, {
timeout: DEFAULT_TIMEOUT_MS,
validateStatus: () => true,
});
}
console.log("[epay] Login successful.");
}
/* ── Credits ───────────────────────────────────────────────── */
async getCredits(): Promise<number> {
const response = await this.client.get(`${BASE_URL}/LogIn.action`, {
timeout: DEFAULT_TIMEOUT_MS,
});
const html = String(response.data ?? "");
const match = html.match(/(\d+)\s+puncte?\s+de\s+credit/i);
return match ? parseInt(match[1] ?? "0", 10) : 0;
}
/* ── Cart ───────────────────────────────────────────────────── */
async addToCart(prodId = 14200): Promise<number> {
const body = new URLSearchParams();
body.set("prodId", String(prodId));
body.set("productQtyModif", "1");
body.set("urgencyValue", "5000");
body.set("wishListId", "-1");
body.set("basketRowId", "");
body.set("addToWishList", "");
body.set("random", String(Date.now()));
body.set("secondaryProductsJson", "");
const response = await this.client.post(
`${BASE_URL}/AddToCartOrWishListFromPost.action`,
body.toString(),
{
headers: { "Content-Type": "application/x-www-form-urlencoded" },
timeout: DEFAULT_TIMEOUT_MS,
},
);
const data = response.data as EpayCartResponse;
const item = data?.items?.[0];
if (!item?.id) {
throw new Error(`ePay addToCart failed: ${JSON.stringify(data).slice(0, 200)}`);
}
console.log(`[epay] Added to cart: basketRowId=${item.id}`);
return item.id;
}
/* ── EpayJsonInterceptor (form-urlencoded) ─────────────────── */
/**
* Call EpayJsonInterceptor with form-urlencoded body.
* This is how Angular's doPostAsForm works.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private async interceptor(params: Record<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(
identifier: string,
countyInternalId: number,
uatId: number,
): Promise<EpaySearchResult[]> {
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<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",
};
// Must use multipart/form-data (Angular uses doPostAsFormMultipart)
const form = new FormData();
form.append("reqType", "saveProductMetadataForBasketItem");
form.append("productMetadataJSON", JSON.stringify(metadata));
const response = await this.client.post(
`${BASE_URL}/EpayJsonInterceptor.action`,
form,
{
headers: {
...form.getHeaders(),
"X-Requested-With": "XMLHttpRequest",
},
timeout: DEFAULT_TIMEOUT_MS,
},
);
const data = response.data;
const result =
typeof data?.jsonResult === "string"
? JSON.parse(data.jsonResult)
: data;
if (result?.code === "SUCCESS" || result?.code === "SAVE_OK") {
console.log(`[epay] Metadata saved for basket ${basketRowId}.`);
return true;
}
console.error("[epay] saveMetadata failed:", JSON.stringify(result).slice(0, 300));
return false;
}
/* ── Order Submission ──────────────────────────────────────── */
async submitOrder(): Promise<string> {
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<string> {
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<EpayOrderStatus> {
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<EpayOrderStatus> {
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<Buffer> {
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<string> {
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;
}
}