Compare commits

..

7 Commits

Author SHA1 Message Date
Claude VM aa246c2d91 fix(epay-ui): show localitate + judet on intern extracts; hide cancelled rows
The intern (and gis-api-sourced) rows showed an empty "jud." with no UAT name
or county, and a few dead cancelled/test rows cluttered the list.

- gis-api returns siruta + uatName but judetName is null there, and the
  CfExtractRow type didn't even declare those fields so adaptCfRow blanked
  them. Added the fields to the type; adaptCfRow now surfaces uatName + siruta.
- New enrichCfLocations(rows) fills missing uatName/judetName from SIRUTA via
  the local GisUat table (batched, one query). Applied in both list proxies
  (/api/cf/orders for gis rows, /api/ancpi/orders for old legacy intern rows
  whose judetName was stored empty). So intern rows now read "LOCALITATE,
  jud. X".
- Hide status='cancelled' rows from the Extrase CF list (dead — payment
  refused / cleaned-up bad orders, e.g. the old 354686 test). failed/review
  stay (actionable via Reincearca).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 21:25:23 +03:00
Claude VM 9b66dd6452 fix(epay-ui): intern status pill 'Disponibil' (not 'Intern') — avoid duplicating the source badge
The source badge next to the status already says 'intern'; a second 'Intern'
status pill read as a duplicate. The status now states the document is
available ('Disponibil', neutral) — no validity term, no duplication.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 20:49:18 +03:00
Claude VM ffad5bb96d fix(epay-ui): intern CF extracts show a neutral 'Intern' pill, not 'Valid'
cf-intern (copycf) extracts have no validity term (expiresAt is null) — the
30-day 'Valid'/'Expirat' labels only make sense for paid ePay extracts.
statusBadge is now type-aware: a completed intern row renders a neutral 'Intern'
pill instead of 'Valid'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 20:23:17 +03:00
Claude VM 50165d2369 feat(epay): auto-delete ePay CF extracts 45 days after issuance
An ePay extract is valid 30 days after issuance; at 45 days it's worthless, so
delete the DB row + its MinIO object to declutter the list and free storage.
Only type='epay' rows are touched — the free cf-intern extracts are kept.

- cleanupExpiredEpayExtracts({olderThanDays=45, dryRun}): COALESCE(documentDate,
  createdAt) < cutoff; deletes MinIO objects (batched, best-effort) then the
  rows. Idempotent.
- Self-contained scheduler (epay-cleanup.ts, same pattern as
  auto-refresh-scheduler): boot run (+90s) then every 24h, started from
  instrumentation.ts. Works with zero external config; idempotent so a
  redeploy/interrupt is harmless.
- GET/POST /api/ancpi/cleanup for manual preview (dry-run) / on-demand run —
  staff session OR cron Bearer (EPAY_CLEANUP_CRON_SECRET /
  NOTIFICATION_CRON_SECRET); excluded from the auth middleware (fail-closed
  in-route). ?days overrides the window.
- deleteCfExtractObjects() helper in epay-storage.

Verified on prod: 0 epay rows currently qualify (all recent); the 8 old intern
rows are correctly left untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 19:16:01 +03:00
Claude VM c9f1219eaa feat(epay): three layers of download/poll resilience
After 327649 hit a transient ANCPI 500 on download (succeeded immediately on
manual retry), make the pipeline self-heal instead of marking the row failed:

1. downloadDocument retries transient failures (5xx, network/timeout, empty
   body, non-PDF error page) up to 4 attempts with linear backoff (3/6/9s);
   a 4xx is permanent and stops immediately. The %PDF guard stays — a bad
   body is now retried rather than thrown on the first try.

2. pollUntilComplete tolerates a transient error on a single poll: it logs and
   continues to the next cycle instead of throwing out of the whole batch (one
   ANCPI blip during polling no longer fails a paid order).

3. finalizeOrder runs a final retry sweep: any row still failed after the
   parallel pass is re-attempted once more after a short pause (covers a longer
   ANCPI blip or a MinIO hiccup). No new charge — the order is already paid.

Same downloadDocument + pollUntilComplete hardening ported to eterra-live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 16:42:23 +03:00
Claude VM 1c8d7ea59c fix(epay): CRITICAL multi-item batch regressions — wrong basketRowId + stale order match
Found via a real 2-item batch (280067 + 327649) on 2026-06-05 that produced a
wrong PDF (correctly caught as "De verificat" by the R4 safety net) and a
failed download:

1. addToCartDetailed took items[items.length-1], but ePay returns the cart
   NEWEST-FIRST, so the just-added row is items[0]. On a 2+ item batch every
   add reported the OLDEST row's id → two rows collapsed onto one basketRowId
   → metadata saved to the wrong row → broken cart. Single-item orders were
   unaffected (one element). Reverted to items[0].

2. findNewOrderId accepted any id != previousOrderId, so when our submit
   created nothing it adopted an unrelated OLDER order (yesterday's 10009605)
   and attached its 15 Feleacu PDFs to today's parcels. ePay order numbers are
   sequential, so a genuinely-new order must be numerically GREATER than the
   latest pre-submit order; otherwise fail (recoverable) instead of matching a
   stale order. Take the highest genuinely-new id. Removed the now-dead
   latest-id fallback.

The R4 "review" flag did its job — the wrong PDF was flagged for verification,
never shown as valid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:50:11 +03:00
Claude VM 5ad8870dc5 fix(epay-ui): stuck connect spinner + order button shows processing not instant-valid
Two UX issues reported from the field:

1. ePay pill spun forever on an already-connected (green) pill. Two causes:
   the icon put connecting before connected (so a stuck connecting state
   showed the spinner even when connected), and the auto-connect effect leaked
   the connecting state — a cancelled early-return skipped clearing it, and
   having connecting in the dep array made setConnecting(true) cancel its own
   in-flight attempt. Fix: connected takes icon priority; a finally{} always
   clears connecting unless retrying; drop connecting from deps.

2. The per-parcel CF button flipped straight to green "Extras CF valid" the
   instant the order was queued, while it actually kept processing ~1-2 min in
   the background (cart, submit, poll, download). Now it shows a pulsing
   "Se proceseaza..." and polls until a completed extract truly exists before
   flipping to valid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 00:36:10 +03:00
15 changed files with 519 additions and 79 deletions
+64
View File
@@ -0,0 +1,64 @@
// GET /api/ancpi/cleanup?dryRun=1 — preview what would be deleted
// POST /api/ancpi/cleanup — run the cleanup now
//
// On-demand control over the 45-day ePay extract auto-cleanup (the scheduler
// in epay-cleanup.ts runs it automatically on boot + every 24h). Useful to
// preview (dryRun) before trusting the automatic run, or to trigger it now.
//
// Auth: a staff session (requireCfAccess), OR a cron Bearer token
// (EPAY_CLEANUP_CRON_SECRET / NOTIFICATION_CRON_SECRET) so an external
// scheduler can call it. ?days overrides the retention window.
import { NextResponse } from "next/server";
import {
cleanupExpiredEpayExtracts,
EPAY_RETENTION_DAYS,
} from "@/modules/parcel-sync/services/epay-cleanup";
import { requireCfAccess } from "@/core/auth/cf-access";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
function cronAuthorized(req: Request): boolean {
const secret =
process.env.EPAY_CLEANUP_CRON_SECRET ?? process.env.NOTIFICATION_CRON_SECRET;
if (!secret) return false;
const auth = req.headers.get("authorization") ?? "";
return auth === `Bearer ${secret}`;
}
async function authorize(req: Request): Promise<boolean> {
if (cronAuthorized(req)) return true;
const access = await requireCfAccess();
return access.ok;
}
function parseDays(req: Request): number {
const raw = new URL(req.url).searchParams.get("days");
const n = raw ? parseInt(raw, 10) : NaN;
return Number.isFinite(n) && n > 0 ? n : EPAY_RETENTION_DAYS;
}
export async function GET(req: Request) {
if (!(await authorize(req))) {
return NextResponse.json({ error: "Neautorizat." }, { status: 401 });
}
// GET is always a dry-run (no side effects) — safe to preview from a browser.
const result = await cleanupExpiredEpayExtracts({
olderThanDays: parseDays(req),
dryRun: true,
});
return NextResponse.json(result);
}
export async function POST(req: Request) {
if (!(await authorize(req))) {
return NextResponse.json({ error: "Neautorizat." }, { status: 401 });
}
const dryRun = new URL(req.url).searchParams.get("dryRun") === "1";
const result = await cleanupExpiredEpayExtracts({
olderThanDays: parseDays(req),
dryRun,
});
return NextResponse.json(result);
}
+5
View File
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma"; import { prisma } from "@/core/storage/prisma";
import { requireCfAccess } from "@/core/auth/cf-access"; import { requireCfAccess } from "@/core/auth/cf-access";
import { enrichCfLocations } from "@/modules/parcel-sync/services/cf-enrich-location";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -57,6 +58,10 @@ export async function GET(req: Request) {
prisma.cfExtract.count({ where }), prisma.cfExtract.count({ where }),
]); ]);
// Fill missing uatName/judetName from SIRUTA (old intern rows stored an
// empty judetName) so the list shows localitate + judet for them too.
await enrichCfLocations(orders);
// Build statusMap for multi-cadastral queries (or single if requested) // Build statusMap for multi-cadastral queries (or single if requested)
if (cadastralNumbers.length > 0) { if (cadastralNumbers.length > 0) {
const now = new Date(); const now = new Date();
+9 -3
View File
@@ -10,6 +10,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getAuthSession } from "@/core/auth/require-auth"; import { getAuthSession } from "@/core/auth/require-auth";
import { gisApi, GisApiError } from "@/lib/gis-api-client"; import { gisApi, GisApiError } from "@/lib/gis-api-client";
import { enrichCfLocations } from "@/modules/parcel-sync/services/cf-enrich-location";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -30,9 +31,14 @@ export async function GET(request: Request) {
const offset = Number.isFinite(offsetRaw) && offsetRaw >= 0 ? offsetRaw : 0; const offset = Number.isFinite(offsetRaw) && offsetRaw >= 0 ? offsetRaw : 0;
try { try {
return NextResponse.json( const data = await gisApi.enrichment.cf.list({ limit, offset, status });
await gisApi.enrichment.cf.list({ limit, offset, status }), // gis-api returns uatName + siruta but judetName is null there — fill the
); // county (and any missing UAT name) from the local GisUat table so the UI
// can show "localitate, jud. X" on intern rows too.
if (Array.isArray(data?.rows)) {
await enrichCfLocations(data.rows);
}
return NextResponse.json(data);
} catch (err) { } catch (err) {
if (err instanceof GisApiError) { if (err instanceof GisApiError) {
return NextResponse.json( return NextResponse.json(
+4
View File
@@ -8,5 +8,9 @@ export async function register() {
// ParcelSync auto-refresh scheduler DISABLED during GIS DB overhaul. // ParcelSync auto-refresh scheduler DISABLED during GIS DB overhaul.
// Re-enable by uncommenting the import below once the new schema is stable. // Re-enable by uncommenting the import below once the new schema is stable.
// await import("@/modules/parcel-sync/services/auto-refresh-scheduler"); // await import("@/modules/parcel-sync/services/auto-refresh-scheduler");
// ePay CF extract auto-cleanup (deletes rows + MinIO objects 45 days
// after issuance). Self-contained scheduler; safe to run every deploy.
await import("@/modules/parcel-sync/services/epay-cleanup");
} }
} }
+4
View File
@@ -55,6 +55,10 @@ export interface CfExtractRow {
userId: string; userId: string;
nrCadastral: string; nrCadastral: string;
nrCF?: string; nrCF?: string;
type?: string;
siruta?: string | null;
uatName?: string | null;
judetName?: string | null;
status: status:
| "pending" | "pending"
| "queued" | "queued"
+1 -1
View File
@@ -58,6 +58,6 @@ export const config = {
* - /favicon.ico, /robots.txt, /sitemap.xml * - /favicon.ico, /robots.txt, /sitemap.xml
* - Files with extensions (images, fonts, etc.) * - Files with extensions (images, fonts, etc.)
*/ */
"/((?!api/auth|api/version|api/basemap-|api/notifications/digest|api/eterra/auto-refresh|api/compress-pdf|api/address-book|api/projects|auth/signin|_next|favicon\\.ico|robots\\.txt|sitemap\\.xml|.*\\..*).*)", "/((?!api/auth|api/version|api/basemap-|api/notifications/digest|api/eterra/auto-refresh|api/ancpi/cleanup|api/compress-pdf|api/address-book|api/projects|auth/signin|_next|favicon\\.ico|robots\\.txt|sitemap\\.xml|.*\\..*).*)",
], ],
}; };
@@ -99,10 +99,10 @@ function adaptLegacyRow(row: LegacyCfExtract): CfExtractRecord {
} }
// Convert a gisApi CfExtractRow → the UI-side CfExtractRecord shape. // Convert a gisApi CfExtractRow → the UI-side CfExtractRecord shape.
// gis-api currently doesn't surface uatName/siruta/judetName on the list // gis-api returns siruta + uatName (judetName is null there, but the
// endpoint, so we leave them blank; the row type defaults to "intern" // /api/cf/orders proxy fills it from the local GisUat by SIRUTA — see
// because gis_core's CfExtract is the cf-intern store (the cutover plan // enrichCfLocations). The row type defaults to "intern" because gis's
// hasn't yet moved ePay writes here). // CfExtract is primarily the cf-intern store.
export function adaptCfRow(row: CfExtractRow & { type?: string }): CfExtractRecord { export function adaptCfRow(row: CfExtractRow & { type?: string }): CfExtractRecord {
return { return {
id: row.id, id: row.id,
@@ -110,9 +110,9 @@ export function adaptCfRow(row: CfExtractRow & { type?: string }): CfExtractReco
orderId: row.orderId ?? null, orderId: row.orderId ?? null,
nrCadastral: row.nrCadastral, nrCadastral: row.nrCadastral,
nrCF: row.nrCF ?? null, nrCF: row.nrCF ?? null,
siruta: null, siruta: row.siruta ?? null,
judetName: "", judetName: row.judetName ?? "",
uatName: "", uatName: row.uatName ?? "",
status: row.status, status: row.status,
epayStatus: row.epayStatus ?? null, epayStatus: row.epayStatus ?? null,
documentName: row.documentName ?? null, documentName: row.documentName ?? null,
@@ -167,12 +167,18 @@ async function fetchGisAc(
// single timeline shows ePay + intern history together. Sort newest- // single timeline shows ePay + intern history together. Sort newest-
// first; dedupe by id (in case the same record ever lands in both // first; dedupe by id (in case the same record ever lands in both
// stores during the cutover migration). // stores during the cutover migration).
// Cancelled rows are dead (payment refused / cleaned-up bad orders) and just
// clutter the list — hide them. failed/review stay (they're actionable).
const isListable = (r: CfExtractRecord): boolean => r.status !== "cancelled";
export async function fetchCfOrdersList( export async function fetchCfOrdersList(
useGisAc: boolean, useGisAc: boolean,
params: { limit?: number; nrCadastral?: string; status?: string } = {}, params: { limit?: number; nrCadastral?: string; status?: string } = {},
): Promise<{ orders: CfExtractRecord[]; total: number }> { ): Promise<{ orders: CfExtractRecord[]; total: number }> {
if (!useGisAc) { if (!useGisAc) {
return fetchLegacy(params); const r = await fetchLegacy(params);
const orders = r.orders.filter(isListable);
return { orders, total: orders.length };
} }
// Pull more rows from each side than the caller asked for so that the // Pull more rows from each side than the caller asked for so that the
@@ -191,6 +197,7 @@ export async function fetchCfOrdersList(
const seen = new Set<string>(); const seen = new Set<string>();
const dedup = merged.filter((r) => { const dedup = merged.filter((r) => {
if (!isListable(r)) return false;
if (seen.has(r.id)) return false; if (seen.has(r.id)) return false;
seen.add(r.id); seen.add(r.id);
return true; return true;
@@ -198,14 +205,8 @@ export async function fetchCfOrdersList(
dedup.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)); dedup.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
const total = const orders = params.limit ? dedup.slice(0, params.limit) : dedup;
(legacy.status === "fulfilled" ? legacy.value.total : 0) + return { orders, total: orders.length };
(gisac.status === "fulfilled" ? gisac.value.total : 0);
return {
orders: params.limit ? dedup.slice(0, params.limit) : dedup,
total,
};
} }
// Existence check used by the per-parcel order button. We check both // Existence check used by the per-parcel order button. We check both
@@ -141,17 +141,21 @@ export function EpayConnect({
if (cancelled) return; if (cancelled) return;
setError("Eroare retea"); setError("Eroare retea");
shouldRetry = attempt < maxRetries; shouldRetry = attempt < maxRetries;
} finally {
// ALWAYS clear the connecting spinner unless we're about to retry —
// including the `cancelled` early-returns above. Otherwise a re-run
// of this effect (e.g. when status.connected flips true) cancels the
// in-flight attempt and leaves connecting stuck true → a perpetual
// spinner on an already-connected (green) pill.
if (!shouldRetry) setConnecting(false);
} }
if (cancelled) return; if (cancelled) return;
if (shouldRetry) { if (shouldRetry) {
// Keep connecting state true during retry wait
autoConnectTimerRef.current = setTimeout(() => { autoConnectTimerRef.current = setTimeout(() => {
void attemptConnect(attempt + 1); void attemptConnect(attempt + 1);
}, 3000); }, 3000);
} else {
setConnecting(false);
} }
}; };
@@ -164,7 +168,11 @@ export function EpayConnect({
autoConnectTimerRef.current = null; autoConnectTimerRef.current = null;
} }
}; };
}, [triggerConnect, status.connected, connecting, fetchStatus]); // `connecting` intentionally excluded: setConnecting(true) inside this
// effect would otherwise re-trigger it and cancel its own in-flight
// attempt. autoConnectAttempted (a ref) already prevents double-starts.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [triggerConnect, status.connected, fetchStatus]);
const disconnect = async () => { const disconnect = async () => {
try { try {
@@ -202,10 +210,10 @@ export function EpayConnect({
: "border-muted-foreground/20 bg-muted/50 text-muted-foreground", : "border-muted-foreground/20 bg-muted/50 text-muted-foreground",
)} )}
> >
{connecting ? ( {status.connected ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : status.connected ? (
<span className="inline-flex h-2 w-2 rounded-full bg-emerald-500" /> <span className="inline-flex h-2 w-2 rounded-full bg-emerald-500" />
) : connecting ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : null} ) : null}
<span className="hidden sm:inline">ePay</span> <span className="hidden sm:inline">ePay</span>
@@ -57,6 +57,10 @@ export function EpayOrderButton({
const [ordering, setOrdering] = useState(false); const [ordering, setOrdering] = useState(false);
const [ordered, setOrdered] = useState(false); const [ordered, setOrdered] = useState(false);
// After enqueue the order keeps processing in the background (~12 min on
// the legacy queue): cart → submit → poll → download. Show that instead of
// flipping straight to a misleading "valid" the instant it's queued.
const [processing, setProcessing] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [epayStatus, setEpayStatus] = useState<EpaySessionStatus>({ const [epayStatus, setEpayStatus] = useState<EpaySessionStatus>({
connected: false, connected: false,
@@ -111,7 +115,10 @@ export function EpayOrderButton({
}); });
if (mountedRef.current) { if (mountedRef.current) {
if (result.ok) { if (result.ok) {
setOrdered(true); // Queued, not done — enter the processing state and let the poll
// effect below flip to "valid" only once the extract is actually
// ready (or surface a failure).
setProcessing(true);
} else { } else {
setError(result.error ?? "Eroare comanda"); setError(result.error ?? "Eroare comanda");
} }
@@ -119,6 +126,36 @@ export function EpayOrderButton({
} }
}, [nrCadastral, siruta, judetName, uatName, useGisAc]); }, [nrCadastral, siruta, judetName, uatName, useGisAc]);
// Poll while processing: flip to "valid" only when a completed extract
// actually exists. Caps at ~3 min, then stops (the parent list refresh
// will reflect the final state).
useEffect(() => {
if (!processing) return;
let cancelled = false;
let attempts = 0;
const tick = async () => {
attempts += 1;
try {
const has = await fetchCfHasCompletedForCadastral(useGisAc, nrCadastral);
if (cancelled) return;
if (has) {
setOrdered(true);
setProcessing(false);
return;
}
} catch {
/* keep polling */
}
if (!cancelled && attempts >= 36) setProcessing(false); // ~3 min
};
const id = setInterval(() => void tick(), 5000);
void tick();
return () => {
cancelled = true;
clearInterval(id);
};
}, [processing, useGisAc, nrCadastral]);
// On the (future) gis.ac path, the orchestrator dispatches ePay calls // On the (future) gis.ac path, the orchestrator dispatches ePay calls
// through a shared account pool — no personally-connected ePay session // through a shared account pool — no personally-connected ePay session
// needed. The legacy queue (current route while the guard is on) // needed. The legacy queue (current route while the guard is on)
@@ -143,6 +180,29 @@ export function EpayOrderButton({
return tooltipText ?? "Comanda extras CF (1 credit)"; return tooltipText ?? "Comanda extras CF (1 credit)";
}; };
if (processing) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-7 gap-1 px-1.5 text-yellow-600 dark:text-yellow-400"
disabled
>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<span className="text-[10px]">Se procesează...</span>
</Button>
</TooltipTrigger>
<TooltipContent>
Comanda CF este în curs (coș plată descărcare, ~12 min)
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
if (ordered) { if (ordered) {
return ( return (
<TooltipProvider> <TooltipProvider>
@@ -74,7 +74,23 @@ function isActiveStatus(status: string): boolean {
type StatusStyle = { label: string; className: string; pulse?: boolean }; type StatusStyle = { label: string; className: string; pulse?: boolean };
function statusBadge(status: string, expiresAt: string | null): StatusStyle { function statusBadge(
status: string,
expiresAt: string | null,
type?: "epay" | "intern",
): StatusStyle {
// Intern (copycf) extracts have NO validity term — never label them "Valid"
// or "Expirat" (which imply an ePay-style 30-day validity). The source
// badge next to this already says "intern", so the STATUS just states the
// document is available (no term).
if (type === "intern" && status === "completed") {
return {
label: "Disponibil",
className:
"bg-muted text-foreground/70 border-muted-foreground/20",
};
}
if (status === "completed" && isExpired(expiresAt)) { if (status === "completed" && isExpired(expiresAt)) {
return { return {
label: "Expirat", label: "Expirat",
@@ -641,7 +657,7 @@ export function EpayTab() {
</thead> </thead>
<tbody> <tbody>
{filteredOrders.map((order, idx) => { {filteredOrders.map((order, idx) => {
const badge = statusBadge(order.status, order.expiresAt); const badge = statusBadge(order.status, order.expiresAt, order.type);
const expired = const expired =
order.status === "completed" && isExpired(order.expiresAt); order.status === "completed" && isExpired(order.expiresAt);
@@ -0,0 +1,52 @@
// Fill in uatName + judetName on CF extract rows from their SIRUTA.
//
// Intern (cf-intern) extracts — and ePay rows on the gis-api side — often
// arrive without a judetName (it's null in gis_enrichment) and sometimes
// without a uatName. Both are derivable from `siruta` via the local GisUat
// table. This batches one query for the whole page instead of N lookups.
import { prisma } from "@/core/storage/prisma";
type LocatableRow = {
siruta?: string | null;
uatName?: string | null;
judetName?: string | null;
};
/**
* Mutates rows in place: for any row with a SIRUTA whose uatName/judetName is
* blank, fill it from GisUat. Best-effort — a missing SIRUTA or a DB error
* leaves the row unchanged. Returns the same array for convenience.
*/
export async function enrichCfLocations<T extends LocatableRow>(
rows: T[],
): Promise<T[]> {
const sirutas = Array.from(
new Set(
rows
.map((r) => (r.siruta ? String(r.siruta).trim() : ""))
.filter(Boolean),
),
);
if (sirutas.length === 0) return rows;
try {
const uats = await prisma.gisUat.findMany({
where: { siruta: { in: sirutas } },
select: { siruta: true, name: true, county: true },
});
const bySiruta = new Map(uats.map((u) => [u.siruta, u]));
for (const row of rows) {
const s = row.siruta ? String(row.siruta).trim() : "";
if (!s) continue;
const uat = bySiruta.get(s);
if (!uat) continue;
if (!row.uatName && uat.name) row.uatName = uat.name;
if (!row.judetName && uat.county) row.judetName = uat.county;
}
} catch (error) {
console.warn("[cf-enrich-location] lookup failed:", error);
}
return rows;
}
@@ -0,0 +1,114 @@
/**
* Auto-cleanup of old ePay CF extracts.
*
* An ePay extract is valid 30 days after issuance (documentDate); after that
* it's worthless (you'd re-order). At 45 days we delete the row + its MinIO
* object to declutter the list and free storage. Only `type='epay'` rows are
* touched — the free `cf-intern` (copycf) extracts are kept.
*
* Self-contained scheduler (same pattern as auto-refresh-scheduler): started
* by importing this module from instrumentation.ts. Runs once shortly after
* boot (so it happens at least once per deploy, since redeploys reset the
* interval) and then every 24h. The cleanup is idempotent — a partial run
* (e.g. interrupted by a restart) is simply finished by the next run.
*/
import { PrismaClient } from "@prisma/client";
import { deleteCfExtractObjects } from "./epay-storage";
const prisma = new PrismaClient();
/** Delete ePay extracts this many days after issuance. */
export const EPAY_RETENTION_DAYS = 45;
const g = globalThis as {
__epayCleanupTimer?: ReturnType<typeof setInterval>;
__epayCleanupRunning?: boolean;
};
export type CleanupResult = {
candidates: number;
rowsDeleted: number;
objectsDeleted: number;
cutoff: string;
dryRun: boolean;
};
/**
* Find and (unless dryRun) delete ePay extracts older than `olderThanDays`
* from issuance. Issuance = documentDate, falling back to createdAt for rows
* that never got a document (old failed/cancelled). Deletes the MinIO object
* first, then the DB rows.
*/
export async function cleanupExpiredEpayExtracts(opts?: {
olderThanDays?: number;
dryRun?: boolean;
}): Promise<CleanupResult> {
const olderThanDays = opts?.olderThanDays ?? EPAY_RETENTION_DAYS;
const dryRun = opts?.dryRun ?? false;
const cutoff = new Date(Date.now() - olderThanDays * 24 * 60 * 60 * 1000);
// COALESCE(documentDate, createdAt) < cutoff — raw query so the date logic
// runs in Postgres and uses the createdAt index where possible.
const rows = (await prisma.$queryRaw`
SELECT id, "minioPath"
FROM "CfExtract"
WHERE type = 'epay'
AND COALESCE("documentDate", "createdAt") < ${cutoff}
`) as Array<{ id: string; minioPath: string | null }>;
const result: CleanupResult = {
candidates: rows.length,
rowsDeleted: 0,
objectsDeleted: 0,
cutoff: cutoff.toISOString(),
dryRun,
};
if (rows.length === 0 || dryRun) {
console.log(
`[epay-cleanup] ${dryRun ? "(dry-run) " : ""}${rows.length} ePay extract(s) older than ${olderThanDays}d (cutoff ${cutoff.toISOString().slice(0, 10)})`,
);
return result;
}
// Delete MinIO objects first (best-effort) so a deleted DB row never leaves
// an orphan file.
const paths = rows.map((r) => r.minioPath).filter((p): p is string => !!p);
result.objectsDeleted = await deleteCfExtractObjects(paths);
const del = await prisma.cfExtract.deleteMany({
where: { id: { in: rows.map((r) => r.id) } },
});
result.rowsDeleted = del.count;
console.log(
`[epay-cleanup] deleted ${result.rowsDeleted} row(s) + ${result.objectsDeleted} object(s) older than ${olderThanDays}d`,
);
return result;
}
/** Run the cleanup once, guarded against overlap. Never throws. */
async function runCleanupSafely(): Promise<void> {
if (g.__epayCleanupRunning) return;
g.__epayCleanupRunning = true;
try {
await cleanupExpiredEpayExtracts();
} catch (error) {
console.error("[epay-cleanup] run failed:", error);
} finally {
g.__epayCleanupRunning = false;
}
}
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
const BOOT_DELAY_MS = 90_000; // let the app finish starting first
// Start the scheduler (idempotent — one per process).
if (!g.__epayCleanupTimer) {
setTimeout(() => void runCleanupSafely(), BOOT_DELAY_MS);
g.__epayCleanupTimer = setInterval(() => void runCleanupSafely(), ONE_DAY_MS);
console.log(
`[epay-cleanup] scheduler armed (retention ${EPAY_RETENTION_DAYS}d, boot run in ${BOOT_DELAY_MS / 1000}s, then every 24h)`,
);
}
+94 -45
View File
@@ -51,6 +51,9 @@ const POLL_MAX_ATTEMPTS = 40;
// ShowOrderDetails page size — large enough to fetch any realistic batch in // ShowOrderDetails page size — large enough to fetch any realistic batch in
// one request (see getOrderStatus / QW4). // one request (see getOrderStatus / QW4).
const ORDER_PAGE_SIZE = 50; const ORDER_PAGE_SIZE = 50;
// Document download retry (transient ANCPI 5xx / timeout / error-page).
const DOWNLOAD_MAX_ATTEMPTS = 4;
const DOWNLOAD_RETRY_DELAY_MS = 3_000; // linear backoff: 3s, 6s, 9s
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Session cache */ /* Session cache */
@@ -233,9 +236,11 @@ export class EpayClient {
const data = response.data as EpayCartResponse; const data = response.data as EpayCartResponse;
const items = Array.isArray(data?.items) ? data.items : []; const items = Array.isArray(data?.items) ? data.items : [];
// The freshly added row is the one we didn't know about; ePay returns // ePay returns the full cart NEWEST-FIRST, so the just-added row is
// the full cart in `items`, newest typically last. Be defensive. // items[0]. (Taking items[last] broke 2+ item batches: every add
const added = items[items.length - 1] ?? items[0]; // reported the OLDEST row's id, so two rows collapsed onto one
// basketRowId and metadata was saved to the wrong row — 2026-06-05.)
const added = items[0];
if (!added?.id) { if (!added?.id) {
throw new Error(`ePay addToCart failed: ${JSON.stringify(data).slice(0, 200)}`); throw new Error(`ePay addToCart failed: ${JSON.stringify(data).slice(0, 200)}`);
} }
@@ -604,28 +609,34 @@ export class EpayClient {
}); });
const html = String(response.data ?? ""); const html = String(response.data ?? "");
// Find ALL orderIds on the page // ePay order numbers are sequential, so a genuinely NEW order is always
// numerically GREATER than the latest order that existed before submit.
// Requiring oid > previousOrderId is what stops us from adopting an
// unrelated OLD order when our submit didn't actually create one — the
// "!= previousOrderId" check alone let an older id through (2026-06-05:
// a new batch grabbed yesterday's order 10009605 and attached its PDFs).
const prevNum = previousOrderId ? Number(previousOrderId) : 0;
const isGenuinelyNew = (oid: string): boolean =>
!!oid &&
oid !== previousOrderId &&
!knownOrderIds?.has(oid) &&
(!Number.isFinite(prevNum) || prevNum === 0 || Number(oid) > prevNum);
// Find ALL orderIds on the page; take the highest genuinely-new one.
const allMatches = html.matchAll(/ShowOrderDetails\.action\?orderId=(\d+)/g); const allMatches = html.matchAll(/ShowOrderDetails\.action\?orderId=(\d+)/g);
let best = "";
for (const m of allMatches) { for (const m of allMatches) {
const oid = m[1] ?? ""; const oid = m[1] ?? "";
if (!oid) continue; if (!isGenuinelyNew(oid)) continue;
if (oid === previousOrderId) continue; if (!best || Number(oid) > Number(best)) best = oid;
if (knownOrderIds?.has(oid)) continue; }
console.log(`[epay] New orderId: ${oid}`); if (best) {
return oid; console.log(`[epay] New orderId: ${best}`);
} return best;
// 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 latestId = latest?.[1];
if (latestId && latestId !== previousOrderId && !knownOrderIds?.has(latestId)) {
console.log(`[epay] Using latest orderId: ${latestId}`);
return latestId;
} }
// No genuinely-new order on the dashboard → the submit created nothing.
// Fail (recoverable) rather than adopting a stale/previous/known order.
throw new Error("Could not determine orderId after checkout"); throw new Error("Could not determine orderId after checkout");
} }
@@ -771,10 +782,20 @@ export class EpayClient {
onProgress?: (attempt: number, status: string) => void, onProgress?: (attempt: number, status: string) => void,
): Promise<EpayOrderStatus> { ): Promise<EpayOrderStatus> {
for (let attempt = 1; attempt <= POLL_MAX_ATTEMPTS; attempt++) { for (let attempt = 1; attempt <= POLL_MAX_ATTEMPTS; attempt++) {
const status = await this.getOrderStatus(orderId); try {
if (onProgress) onProgress(attempt, status.status); const status = await this.getOrderStatus(orderId);
if (["Finalizata", "Anulata", "Plata refuzata"].includes(status.status)) { if (onProgress) onProgress(attempt, status.status);
return status; if (["Finalizata", "Anulata", "Plata refuzata"].includes(status.status)) {
return status;
}
} catch (err) {
// A transient ANCPI error (5xx, timeout) on ONE poll must not abort
// the whole batch — the order is paid and still being processed.
// Log and try again on the next cycle.
const msg = err instanceof Error ? err.message : String(err);
console.warn(
`[epay] poll ${attempt}/${POLL_MAX_ATTEMPTS} for order ${orderId} errored (${msg}); continuing`,
);
} }
await sleep(POLL_INTERVAL_MS); await sleep(POLL_INTERVAL_MS);
} }
@@ -785,29 +806,57 @@ export class EpayClient {
async downloadDocument(idDocument: number, typeD = 4): Promise<Buffer> { async downloadDocument(idDocument: number, typeD = 4): Promise<Buffer> {
const url = `${BASE_URL}/DownloadFile.action?typeD=${typeD}&id=${idDocument}&source=&browser=chrome`; const url = `${BASE_URL}/DownloadFile.action?typeD=${typeD}&id=${idDocument}&source=&browser=chrome`;
// Angular sends Content-Type: application/pdf in the REQUEST let lastErr = "unknown";
const response = await this.client.post(url, null, {
headers: { "Content-Type": "application/pdf" },
timeout: DEFAULT_TIMEOUT_MS,
responseType: "arraybuffer",
});
const data = response.data; // ANCPI's DownloadFile occasionally returns a transient 5xx / times out /
if (!data || data.length < 100) { // hands back an error page even when the order is finalized (2026-06-05:
throw new Error(`ePay download empty (${data?.length ?? 0} bytes)`); // 327649 got one 500, then succeeded on the very next attempt). The
// download is idempotent, so retry transient failures with backoff before
// giving up. A 4xx is treated as permanent (stop immediately).
for (let attempt = 1; attempt <= DOWNLOAD_MAX_ATTEMPTS; attempt++) {
try {
const response = await this.client.post(url, null, {
headers: { "Content-Type": "application/pdf" },
timeout: DEFAULT_TIMEOUT_MS,
responseType: "arraybuffer",
validateStatus: () => true, // inspect status ourselves for retry
});
if (response.status >= 400) {
lastErr = `HTTP ${response.status}`;
if (response.status < 500) break; // client error — won't fix on retry
} else {
const buf = Buffer.from(response.data ?? Buffer.alloc(0));
if (buf.length < 100) {
lastErr = `empty (${buf.length} bytes)`;
} else if (buf.subarray(0, 5).toString("latin1") !== "%PDF-") {
// Not a PDF — usually a transient ANCPI error page or an expired
// session. Retry; a fresh attempt often returns the real PDF.
const head = buf.subarray(0, 48).toString("latin1").replace(/\s+/g, " ");
lastErr = `not a PDF (head="${head.slice(0, 40)}")`;
} else {
if (attempt > 1) {
console.log(`[epay] download ${idDocument} recovered on attempt ${attempt}`);
}
console.log(`[epay] Downloaded document ${idDocument}: ${buf.length} bytes`);
return buf;
}
}
} catch (err) {
// Network error / timeout — retryable.
lastErr = err instanceof Error ? err.message : String(err);
}
if (attempt < DOWNLOAD_MAX_ATTEMPTS) {
console.warn(
`[epay] download ${idDocument} attempt ${attempt} failed (${lastErr}); retrying in ${DOWNLOAD_RETRY_DELAY_MS * attempt}ms`,
);
await sleep(DOWNLOAD_RETRY_DELAY_MS * attempt);
}
} }
const buf = Buffer.from(data); throw new Error(
// R2: if the ePay session expired mid-batch, DownloadFile returns the `ePay download failed after ${DOWNLOAD_MAX_ATTEMPTS} attempts (idDocument=${idDocument}): ${lastErr}`,
// login/error HTML page (200 OK) instead of the PDF. Storing that as a );
// ".pdf" silently corrupts the extract. Assert the PDF magic bytes.
if (buf.subarray(0, 5).toString("latin1") !== "%PDF-") {
const head = buf.subarray(0, 64).toString("latin1");
throw new Error(
`ePay download not a PDF (idDocument=${idDocument}, ${buf.length} bytes, head="${head.replace(/\s+/g, " ").slice(0, 40)}") — session may have expired`,
);
}
console.log(`[epay] Downloaded document ${idDocument}: ${buf.length} bytes`);
return buf;
} }
} }
+32 -4
View File
@@ -574,10 +574,10 @@ async function finalizeOrder(
plans.push({ item, doc, matchedByIndex, index: next }); plans.push({ item, doc, matchedByIndex, index: next });
} }
// Step 6: download + store in parallel (bounded). Each task is fully // One plan's download + store. Returns true on success. On failure it
// self-contained so a failure on one row doesn't abort the others. The // marks the row failed and returns false so the caller can retry it.
// file index is pre-allocated above, so parallel stores never overwrite. const downloadAndStore = async (plan: Plan): Promise<boolean> => {
await runWithConcurrency(plans, DOWNLOAD_CONCURRENCY, async ({ item, doc, matchedByIndex, index: fileIndex }) => { const { item, doc, matchedByIndex, index: fileIndex } = plan;
try { try {
await updateStatus(item.extractId, "downloading", { await updateStatus(item.extractId, "downloading", {
idDocument: doc.idDocument, idDocument: doc.idDocument,
@@ -629,15 +629,43 @@ async function finalizeOrder(
console.log( console.log(
`[epay-queue] ${matchedByIndex ? "Review" : "Completed"}: ${item.input.nrCadastral}${path}`, `[epay-queue] ${matchedByIndex ? "Review" : "Completed"}: ${item.input.nrCadastral}${path}`,
); );
return true;
} catch (error) { } catch (error) {
const message = const message =
error instanceof Error ? error.message : "Eroare download/stocare"; error instanceof Error ? error.message : "Eroare download/stocare";
await updateStatus(item.extractId, "failed", { await updateStatus(item.extractId, "failed", {
errorMessage: message, errorMessage: message,
}); });
return false;
} }
};
// Step 6: download + store in parallel (bounded). Each task is fully
// self-contained so a failure on one row doesn't abort the others. The
// file index is pre-allocated above, so parallel stores never overwrite.
// downloadDocument already retries transient ANCPI errors per call; this
// adds a SECOND layer — a final sweep that re-attempts any row still
// failed (covers a longer ANCPI blip or a MinIO hiccup) with no new
// charge, since the order is already paid.
const failed: Plan[] = [];
await runWithConcurrency(plans, DOWNLOAD_CONCURRENCY, async (plan) => {
const ok = await downloadAndStore(plan);
if (!ok) failed.push(plan);
}); });
if (failed.length > 0) {
console.warn(
`[epay-queue] ${failed.length}/${plans.length} downloads failed for order ${orderId} — retry sweep in 5s...`,
);
await new Promise((r) => setTimeout(r, 5000));
for (const plan of failed) {
const ok = await downloadAndStore(plan);
console.log(
`[epay-queue] retry sweep ${plan.item.input.nrCadastral}: ${ok ? "recovered" : "still failed"}`,
);
}
}
// Update credits after successful order // Update credits after successful order
const newCredits = await client.getCredits(); const newCredits = await client.getCredits();
updateEpayCredits(newCredits); updateEpayCredits(newCredits);
@@ -134,6 +134,35 @@ export async function getCfExtractStream(
return minioClient.getObject(BUCKET, minioPath); return minioClient.getObject(BUCKET, minioPath);
} }
/**
* Delete stored CF extract objects from MinIO (best-effort, batched).
* Returns how many were removed. Used by the 45-day auto-cleanup.
*/
export async function deleteCfExtractObjects(
minioPaths: string[],
): Promise<number> {
const paths = minioPaths.filter(Boolean);
if (paths.length === 0) return 0;
try {
await minioClient.removeObjects(BUCKET, paths);
return paths.length;
} catch (error) {
// removeObjects can fail wholesale on a transport error — fall back to
// per-object deletes so one bad key doesn't block the rest.
console.warn("[epay-storage] batch delete failed, retrying per-object:", error);
let removed = 0;
for (const p of paths) {
try {
await minioClient.removeObject(BUCKET, p);
removed++;
} catch (err) {
console.warn(`[epay-storage] could not delete ${p}:`, err);
}
}
return removed;
}
}
/** /**
* List all stored CF extracts for a cadastral number. * List all stored CF extracts for a cadastral number.
*/ */