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;
}
}