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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user