diff --git a/src/app/api/ancpi/recover/route.ts b/src/app/api/ancpi/recover/route.ts new file mode 100644 index 0000000..4c56339 --- /dev/null +++ b/src/app/api/ancpi/recover/route.ts @@ -0,0 +1,103 @@ +// GET /api/ancpi/recover?orderId= +// +// Recovery for ePay orders that ANCPI processed even though our +// EditCartSubmit request timed out: the CfExtract rows sit on +// status "failed" / "timeout..." with no orderId while the credits are +// already spent (2026-06-04: order 10009605, 15 extracts, Feleacu). +// +// Picks up the recent failed-without-orderId rows, attaches the given +// orderId and runs the shared poll → download → MinIO pipeline +// (epay-queue.finalizeOrder via recoverBatch). Requires an active ePay +// session (connect in the ePay tab first). Idempotent: already-completed +// rows are not selected; re-running after a partial failure only retries +// the still-failed rows. +// +// GET (not POST) on purpose — it's an operator action triggered from the +// browser URL bar with the NextAuth session cookie. + +import { NextResponse } from "next/server"; +import { prisma } from "@/core/storage/prisma"; +import { recoverBatch } from "@/modules/parcel-sync/services/epay-queue"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +// Single-flight: poll+download for a batch takes minutes; a second click +// must not start a parallel run over the same rows. +const g = globalThis as { __epayRecoverRunning?: boolean }; + +export async function GET(req: Request) { + const url = new URL(req.url); + const orderId = url.searchParams.get("orderId")?.trim() ?? ""; + + if (!/^\d+$/.test(orderId)) { + return NextResponse.json( + { error: "orderId lipsă sau invalid. Folosește ?orderId=." }, + { status: 400 }, + ); + } + + if (g.__epayRecoverRunning) { + return NextResponse.json( + { error: "O recuperare rulează deja. Așteaptă să se termine." }, + { status: 409 }, + ); + } + + // Candidate rows: recent paid-flow failures that never got an orderId, + // plus rows already attached to this order by a previous partial run. + const rows = await prisma.cfExtract.findMany({ + where: { + type: "epay", + OR: [ + { + status: "failed", + orderId: null, + createdAt: { gte: new Date(Date.now() - 48 * 60 * 60 * 1000) }, + }, + { orderId, status: { notIn: ["completed", "cancelled"] } }, + ], + }, + select: { + id: true, + nrCadastral: true, + nrCF: true, + siruta: true, + judetName: true, + uatName: true, + status: true, + }, + orderBy: { createdAt: "asc" }, + }); + + if (rows.length === 0) { + return NextResponse.json({ + recovered: 0, + message: "Niciun extras failed (ultimele 48h) de recuperat.", + }); + } + + g.__epayRecoverRunning = true; + try { + const result = await recoverBatch(orderId, rows); + + const after = await prisma.cfExtract.findMany({ + where: { id: { in: rows.map((r) => r.id) } }, + select: { nrCadastral: true, status: true, errorMessage: true }, + orderBy: { nrCadastral: "asc" }, + }); + const completed = after.filter((r) => r.status === "completed").length; + + return NextResponse.json({ + orderId: result, + attempted: rows.length, + completed, + rows: after, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Eroare server"; + return NextResponse.json({ error: message }, { status: 500 }); + } finally { + g.__epayRecoverRunning = false; + } +} diff --git a/src/modules/parcel-sync/services/epay-client.ts b/src/modules/parcel-sync/services/epay-client.ts index 9d83489..560d9ec 100644 --- a/src/modules/parcel-sync/services/epay-client.ts +++ b/src/modules/parcel-sync/services/epay-client.ts @@ -37,6 +37,11 @@ const LOGIN_URL = process.env.ANCPI_LOGIN_URL || "https://oassl.ancpi.ro/openam/UI/Login"; const DEFAULT_TIMEOUT_MS = 60_000; +// EditCartSubmit processes the WHOLE cart server-side before responding — +// a 15-item batch exceeded 60s on 2026-06-04 (order 10009605): our request +// timed out, the rows were marked failed, but ANCPI completed the order +// and spent the credits. Submit/confirmation steps get a generous budget. +const SUBMIT_TIMEOUT_MS = 180_000; const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour const POLL_INTERVAL_MS = 15_000; const POLL_MAX_ATTEMPTS = 40; @@ -463,21 +468,32 @@ export class EpayClient { 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, - validateStatus: () => true, - }, - ); + try { + await this.client.post( + `${BASE_URL}/EditCartSubmit.action`, + body.toString(), + { + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + timeout: SUBMIT_TIMEOUT_MS, + validateStatus: () => true, + }, + ); + } catch (error) { + // A timeout here does NOT mean the order failed — ANCPI keeps + // processing the cart server-side (2026-06-04 incident). Fall + // through to confirmation + order-id detection; findNewOrderId + // throws if no order actually materialized. + const message = error instanceof Error ? error.message : String(error); + console.warn( + `[epay] EditCartSubmit request failed (${message}) — checking whether the order was created anyway...`, + ); + } // Step 2: GET CheckoutConfirmationSubmit (Angular redirects here client-side) const confirmResponse = await this.client.get( `${BASE_URL}/CheckoutConfirmationSubmit.action`, { - timeout: DEFAULT_TIMEOUT_MS, + timeout: SUBMIT_TIMEOUT_MS, maxRedirects: 5, validateStatus: () => true, }, @@ -527,11 +543,15 @@ export class EpayClient { return oid; } - // If no new orderId found, the latest one might be it (first order) + // If no new orderId found, the latest one might be it (first order) — + // but NEVER adopt the previous/known order: after a submit that timed + // out without creating anything, returning the stale id would attach + // the wrong order and download its old documents. const latest = html.match(/ShowOrderDetails\.action\?orderId=(\d+)/); - if (latest?.[1]) { - console.log(`[epay] Using latest orderId: ${latest[1]}`); - return latest[1]; + const latestId = latest?.[1]; + if (latestId && latestId !== previousOrderId && !knownOrderIds?.has(latestId)) { + console.log(`[epay] Using latest orderId: ${latestId}`); + return latestId; } throw new Error("Could not determine orderId after checkout"); diff --git a/src/modules/parcel-sync/services/epay-queue.ts b/src/modules/parcel-sync/services/epay-queue.ts index 92156bc..2145596 100644 --- a/src/modules/parcel-sync/services/epay-queue.ts +++ b/src/modules/parcel-sync/services/epay-queue.ts @@ -322,6 +322,33 @@ async function processBatch( ); const orderId = await client.submitOrder(knownOrderIds); + // Steps 4–6 are shared with order recovery — see finalizeOrder. + return await finalizeOrder(client, validItems, orderId); + } catch (error) { + const message = + error instanceof Error ? error.message : "Eroare necunoscută"; + console.error(`[epay-queue] Batch failed:`, message); + for (const id of extractIds) { + await updateStatus(id, "failed", { errorMessage: message }); + } + return null; + } +} + +/** + * Steps 4–6 of the batch flow: attach the orderId, poll the ePay order + * until final, download all documents, store them in MinIO and mark the + * rows completed. Shared by the normal batch path and by recovery + * (/api/ancpi/recover) for orders that ANCPI processed even though our + * submit request timed out (2026-06-04: order 10009605, 15 credits spent, + * rows stuck on "failed: timeout of 60000ms exceeded"). + */ +async function finalizeOrder( + client: EpayClient, + validItems: QueueItem[], + orderId: string, +): Promise { + try { // Update all valid items with the shared orderId for (const item of validItems) { await updateStatus(item.extractId, "polling", { orderId }); @@ -468,10 +495,52 @@ async function processBatch( } catch (error) { const message = error instanceof Error ? error.message : "Eroare necunoscută"; - console.error(`[epay-queue] Batch failed:`, message); - for (const id of extractIds) { - await updateStatus(id, "failed", { errorMessage: message }); + console.error(`[epay-queue] Finalize failed for order ${orderId}:`, message); + for (const item of validItems) { + await updateStatus(item.extractId, "failed", { errorMessage: message }); } return null; } } + +/** + * Recovery for orders that ANCPI created even though our EditCartSubmit + * request timed out: the rows sit on status "failed" with no orderId while + * the credits are already spent. Re-attaches the given orderId and runs + * the shared poll → download → MinIO pipeline. + */ +export async function recoverBatch( + orderId: string, + rows: Array<{ + id: string; + nrCadastral: string; + nrCF: string | null; + siruta: string | null; + judetName: string; + uatName: string; + }>, +): Promise { + const creds = getEpayCredentials(); + if (!creds) { + throw new Error("Nu ești conectat la ePay."); + } + const client = await EpayClient.create(creds.username, creds.password); + + const items: QueueItem[] = rows.map((r) => ({ + extractId: r.id, + input: { + nrCadastral: r.nrCadastral, + nrCF: r.nrCF ?? r.nrCadastral, + siruta: r.siruta ?? undefined, + judetIndex: 0, + judetName: r.judetName, + uatId: 0, + uatName: r.uatName, + }, + })); + + console.log( + `[epay-queue] Recovering order ${orderId} for ${items.length} extracts...`, + ); + return finalizeOrder(client, items, orderId); +}