fix(epay): submit timeout 60s→180s + order recovery for timed-out submits

2026-06-04 incident: a 15-item EditCartSubmit exceeded the 60s axios
timeout — ANCPI completed order 10009605 and spent 15 credits, but the
rows were marked failed with no orderId and no downloads.

- SUBMIT_TIMEOUT_MS=180s for EditCartSubmit + CheckoutConfirmationSubmit
- EditCartSubmit errors no longer abort the batch: fall through to
  order-id detection, which now refuses to adopt the previous/known
  order (stale-id guard in findNewOrderId)
- extract steps 4-6 of processBatch into finalizeOrder, shared with new
  recoverBatch()
- GET /api/ancpi/recover?orderId=N re-attaches recent failed rows to the
  ANCPI order and runs poll → download → MinIO (single-flight,
  idempotent)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude VM
2026-06-04 18:08:33 +03:00
parent f7468b23c2
commit 2fed59dad6
3 changed files with 209 additions and 17 deletions
+103
View File
@@ -0,0 +1,103 @@
// GET /api/ancpi/recover?orderId=<id>
//
// 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=<id ePay>." },
{ 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;
}
}
+34 -14
View File
@@ -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");
+72 -3
View File
@@ -322,6 +322,33 @@ async function processBatch(
);
const orderId = await client.submitOrder(knownOrderIds);
// Steps 46 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 46 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<string | null> {
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<string | null> {
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);
}