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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
try {
|
||||
await this.client.post(
|
||||
`${BASE_URL}/EditCartSubmit.action`,
|
||||
body.toString(),
|
||||
{
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
timeout: DEFAULT_TIMEOUT_MS,
|
||||
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");
|
||||
|
||||
@@ -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<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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user