feat(parcel-sync): add ANCPI ePay CF extract ordering backend

Foundation (Phase 1):
- CfExtract Prisma model with version tracking, expiry, MinIO path
- epay-types.ts: all ePay API response types
- epay-counties.ts: WORKSPACE_ID → ePay county index mapping (42 counties)
- epay-storage.ts: MinIO helpers (bucket, naming, upload, download)
- docker-compose.yml: ANCPI env vars

ePay Client (Phase 2):
- epay-client.ts: full HTTP client (login, credits, cart, search estate,
  submit order, poll status, download PDF) with cookie jar + auto-relogin
- epay-session-store.ts: separate session from eTerra

Queue + API (Phase 3):
- epay-queue.ts: sequential FIFO queue (global cart constraint),
  10-step workflow per order with DB status updates at each step
- POST /api/ancpi/session: connect/disconnect
- POST /api/ancpi/order: create single or bulk orders
- GET /api/ancpi/orders: list all extracts
- GET /api/ancpi/credits: live credit balance
- GET /api/ancpi/download: stream PDF from MinIO

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-23 00:09:52 +02:00
parent f6781ab851
commit 3921852eb5
13 changed files with 1618 additions and 0 deletions
@@ -0,0 +1,505 @@
/**
* ANCPI ePay client — server-side only.
*
* Authenticates via oassl.ancpi.ro, keeps cookie jar (iPlanetDirectoryPro + JSESSIONID),
* and implements the full CF extract ordering workflow:
* login → check credits → add to cart → search estate →
* configure & submit → poll status → download PDF
*
* Modeled after eterra-client.ts: cookie jar, session cache, retry, auto-relogin.
*/
import axios, { type AxiosError, type AxiosInstance } from "axios";
import crypto from "crypto";
import { wrapper } from "axios-cookiejar-support";
import { CookieJar } from "tough-cookie";
import type {
EpayCartResponse,
EpaySearchResult,
EpayUatEntry,
EpayOrderStatus,
EpaySolutionDoc,
OrderMetadata,
} from "./epay-types";
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
const BASE_URL = process.env.ANCPI_BASE_URL || "https://epay.ancpi.ro/epay";
const LOGIN_URL =
process.env.ANCPI_LOGIN_URL ||
"https://oassl.ancpi.ro/openam/UI/Login";
const DEFAULT_TIMEOUT_MS = 60_000; // ePay is slow
const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour (ePay sessions last longer)
const MAX_RETRIES = 3;
const POLL_INTERVAL_MS = 15_000;
const POLL_MAX_ATTEMPTS = 40; // 10 minutes
/* ------------------------------------------------------------------ */
/* Session cache (global singleton) */
/* ------------------------------------------------------------------ */
type SessionEntry = {
jar: CookieJar;
client: AxiosInstance;
createdAt: number;
lastUsed: number;
};
const globalStore = globalThis as {
__epaySessionCache?: Map<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");
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const isRedirectToLogin = (html: string) =>
html.includes("IDToken1") ||
html.includes("/openam/UI/Login") ||
html.includes("j_security_check");
/* ------------------------------------------------------------------ */
/* Client */
/* ------------------------------------------------------------------ */
export class EpayClient {
private client: AxiosInstance;
private jar: CookieJar;
private username: string;
private password: string;
private reloginAttempted = false;
private constructor(
client: AxiosInstance,
jar: CookieJar,
username: string,
password: string,
) {
this.client = client;
this.jar = jar;
this.username = username;
this.password = password;
}
/* ── Factory ───────────────────────────────────────────────── */
static async create(username: string, password: string): Promise<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: 5,
headers: {
Accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
},
}),
);
const instance = new EpayClient(client, jar, username, password);
await instance.login();
sessionCache.set(cacheKey, { jar, client, createdAt: now, lastUsed: now });
return instance;
}
/* ── Auth ───────────────────────────────────────────────────── */
private async login(): Promise<void> {
const body = `IDToken1=${encodeURIComponent(this.username)}&IDToken2=${encodeURIComponent(this.password)}`;
const response = await this.client.post(LOGIN_URL, body, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
timeout: DEFAULT_TIMEOUT_MS,
maxRedirects: 10,
validateStatus: () => true, // don't throw on redirects
});
// Check if login succeeded — after redirects we should land on ePay
const finalUrl = response.request?.res?.responseUrl ?? response.config?.url ?? "";
const html = typeof response.data === "string" ? response.data : "";
if (
finalUrl.includes("/openam/UI/Login") ||
html.includes("Authentication Failed") ||
html.includes("IDToken1")
) {
throw new Error("ePay login failed (invalid credentials)");
}
// Verify we have session cookies
const cookies = await this.jar.getCookies(BASE_URL);
const hasCookies =
cookies.some((c) => c.key === "JSESSIONID") ||
cookies.some((c) => c.key === "iPlanetDirectoryPro");
if (!hasCookies) {
// Check oassl domain too
const oasslCookies = await this.jar.getCookies(LOGIN_URL);
if (!oasslCookies.some((c) => c.key === "iPlanetDirectoryPro")) {
throw new Error("ePay login failed (no session cookie)");
}
}
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;
}
}
/* ── Credits ───────────────────────────────────────────────── */
async getCredits(): Promise<number> {
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);
console.warn("[epay] Could not parse credits from HTML");
return 0;
});
}
/* ── Cart ───────────────────────────────────────────────────── */
async addToCart(prodId = 14200): Promise<number> {
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 ─────────────────────────────────────────── */
async searchEstate(
identifier: string,
countyIdx: number,
uatId: number,
): Promise<EpaySearchResult[]> {
return this.retryOnAuthFail(async () => {
const body = new URLSearchParams();
body.set("identifier", identifier);
body.set("countyId", String(countyIdx));
body.set("uatId", String(uatId));
const response = await this.client.post(
`${BASE_URL}/SearchEstate.action`,
body.toString(),
{
headers: { "Content-Type": "application/x-www-form-urlencoded" },
timeout: DEFAULT_TIMEOUT_MS,
},
);
const data = response.data;
if (Array.isArray(data)) return data as EpaySearchResult[];
// Sometimes wrapped in a string
if (typeof data === "string") {
try {
const parsed = JSON.parse(data);
if (Array.isArray(parsed)) return parsed as EpaySearchResult[];
} catch {
// Not JSON
}
}
return [];
});
}
/* ── UAT Lookup ────────────────────────────────────────────── */
async getUatList(countyIdx: number): Promise<EpayUatEntry[]> {
return this.retryOnAuthFail(async () => {
// ePay uses EpayJsonInterceptor for dynamic dropdowns
// Try the interceptor first
const body = new URLSearchParams();
body.set("actionType", "getUAT");
body.set("countyIndex", String(countyIdx));
const response = await this.client.post(
`${BASE_URL}/EpayJsonInterceptor.action`,
body.toString(),
{
headers: { "Content-Type": "application/x-www-form-urlencoded" },
timeout: DEFAULT_TIMEOUT_MS,
},
);
const data = response.data;
// Response: { jsonResult: "[{\"id\":155546,\"value\":\"Balint\"}, ...]" }
if (data?.jsonResult) {
try {
const parsed = JSON.parse(data.jsonResult);
if (Array.isArray(parsed)) return parsed as EpayUatEntry[];
} catch {
// Parse failed
}
}
// Direct array response
if (Array.isArray(data)) return data as EpayUatEntry[];
console.warn(
`[epay] getUatList(${countyIdx}) returned unexpected:`,
JSON.stringify(data).slice(0, 200),
);
return [];
});
}
/* ── Order Submission ──────────────────────────────────────── */
async submitOrder(metadata: OrderMetadata): Promise<string> {
return this.retryOnAuthFail(async () => {
const body = new URLSearchParams();
body.set("goToCheckout", "true");
body.set("basketItems[0].basketId", String(metadata.basketRowId));
body.set("basketItems[0].metadate.judet", String(metadata.judetIndex));
body.set("basketItems[0].metadate.uat", String(metadata.uatId));
body.set("basketItems[0].metadate.CF", metadata.nrCF);
body.set("basketItems[0].metadate.CAD", metadata.nrCadastral);
body.set("basketItems[0].metadate.metodeLivrare", "Electronic");
body.set("basketItems[0].solicitant", metadata.solicitantId);
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<string> {
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<EpayOrderStatus> {
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<EpayOrderStatus> {
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<Buffer> {
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);
});
}
}