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 ||
|
process.env.ANCPI_LOGIN_URL ||
|
||||||
"https://oassl.ancpi.ro/openam/UI/Login";
|
"https://oassl.ancpi.ro/openam/UI/Login";
|
||||||
const DEFAULT_TIMEOUT_MS = 60_000;
|
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 SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||||
const POLL_INTERVAL_MS = 15_000;
|
const POLL_INTERVAL_MS = 15_000;
|
||||||
const POLL_MAX_ATTEMPTS = 40;
|
const POLL_MAX_ATTEMPTS = 40;
|
||||||
@@ -463,21 +468,32 @@ export class EpayClient {
|
|||||||
const body = new URLSearchParams();
|
const body = new URLSearchParams();
|
||||||
body.set("goToCheckout", "true");
|
body.set("goToCheckout", "true");
|
||||||
|
|
||||||
await this.client.post(
|
try {
|
||||||
`${BASE_URL}/EditCartSubmit.action`,
|
await this.client.post(
|
||||||
body.toString(),
|
`${BASE_URL}/EditCartSubmit.action`,
|
||||||
{
|
body.toString(),
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
{
|
||||||
timeout: DEFAULT_TIMEOUT_MS,
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
validateStatus: () => true,
|
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)
|
// Step 2: GET CheckoutConfirmationSubmit (Angular redirects here client-side)
|
||||||
const confirmResponse = await this.client.get(
|
const confirmResponse = await this.client.get(
|
||||||
`${BASE_URL}/CheckoutConfirmationSubmit.action`,
|
`${BASE_URL}/CheckoutConfirmationSubmit.action`,
|
||||||
{
|
{
|
||||||
timeout: DEFAULT_TIMEOUT_MS,
|
timeout: SUBMIT_TIMEOUT_MS,
|
||||||
maxRedirects: 5,
|
maxRedirects: 5,
|
||||||
validateStatus: () => true,
|
validateStatus: () => true,
|
||||||
},
|
},
|
||||||
@@ -527,11 +543,15 @@ export class EpayClient {
|
|||||||
return oid;
|
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+)/);
|
const latest = html.match(/ShowOrderDetails\.action\?orderId=(\d+)/);
|
||||||
if (latest?.[1]) {
|
const latestId = latest?.[1];
|
||||||
console.log(`[epay] Using latest orderId: ${latest[1]}`);
|
if (latestId && latestId !== previousOrderId && !knownOrderIds?.has(latestId)) {
|
||||||
return latest[1];
|
console.log(`[epay] Using latest orderId: ${latestId}`);
|
||||||
|
return latestId;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("Could not determine orderId after checkout");
|
throw new Error("Could not determine orderId after checkout");
|
||||||
|
|||||||
@@ -322,6 +322,33 @@ async function processBatch(
|
|||||||
);
|
);
|
||||||
const orderId = await client.submitOrder(knownOrderIds);
|
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
|
// Update all valid items with the shared orderId
|
||||||
for (const item of validItems) {
|
for (const item of validItems) {
|
||||||
await updateStatus(item.extractId, "polling", { orderId });
|
await updateStatus(item.extractId, "polling", { orderId });
|
||||||
@@ -468,10 +495,52 @@ async function processBatch(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : "Eroare necunoscută";
|
error instanceof Error ? error.message : "Eroare necunoscută";
|
||||||
console.error(`[epay-queue] Batch failed:`, message);
|
console.error(`[epay-queue] Finalize failed for order ${orderId}:`, message);
|
||||||
for (const id of extractIds) {
|
for (const item of validItems) {
|
||||||
await updateStatus(id, "failed", { errorMessage: message });
|
await updateStatus(item.extractId, "failed", { errorMessage: message });
|
||||||
}
|
}
|
||||||
return null;
|
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