c452bd9fb7
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>
561 lines
19 KiB
TypeScript
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;
|
|
}
|
|
}
|