feat(crewing): EPFO/UAN assisted verification (GstService pattern, flagged)

Scaffolds EPFO/UAN verification the same way GST works — a standalone Playwright
proxy microservice + an /api proxy + an assisted affordance that records the
result. Aadhaar stays manual (UIDAI-restricted). Stacks on the follow-ups branch.
Behind NEXT_PUBLIC_CREWING_ENABLED.

What's in
- EpfoService/ (new microservice, GstService pattern): Express + Playwright.
  POST /otp {uan} → session + OTP request; POST /verify {sessionId,uan,otp} →
  member record; GET /health. EPFO is OTP-gated (no anonymous captcha lookup like
  GST), so the handshake is two steps. Live portal navigation is gated behind
  EPFO_LIVE (default STUB: OTP 000000 → matched) until real selectors/OTP are
  validated. README documents the differences + that Aadhaar is out of scope.
- App: /api/epfo/otp + /api/epfo proxies (gated by verify_bank_epf) to
  EPFO_SERVICE_URL. EpfDetail += epfoMemberName + epfoCheckedAt (migration
  crewing_epfo_check). recordEpfoCheck action persists the EPFO result + audit.
- UI: an "EPFO check" affordance on the verification EPF rows — request OTP →
  enter OTP → matched member → record. Aadhaar noted as manual-only.

Tests & docs
- Integration: verification.test.ts gains recordEpfoCheck (records name+timestamp,
  Accounts-only gating). type-check clean; full unit (245) + integration (213)
  green (RESEND_API_KEY unset).
- .env.example (EPFO_SERVICE_URL/EPFO_LIVE), CLAUDE.md, EpfoService/README.

Note: the EpfoService live portal selectors/OTP are stubbed and must be validated
against a real EPFO session before enabling EPFO_LIVE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hardik 2026-06-22 22:43:24 +05:30
parent df3b4bdc97
commit e193e26368
13 changed files with 433 additions and 3 deletions

View file

@ -49,6 +49,13 @@ EMAIL_FROM_NAME="Pelagia Portal"
# Start the service with: cd GstService && npm run dev
GST_SERVICE_URL=http://localhost:3003
# ── EPFO / UAN lookup microservice (crewing) ──────────────────
# Run the EpfoService/ microservice alongside the app (default localhost:3004).
# Start with: cd EpfoService && npm run dev
# Runs in STUB mode unless EPFO_LIVE=true (the live portal selectors/OTP must be
# validated against a real session first). Aadhaar is NOT handled here (manual).
EPFO_SERVICE_URL=http://localhost:3004
# ── Forgejo issue reporting (Report Issue button) ─────────────
# Token needs write:issue scope on the repo below.
FORGEJO_URL=https://git.pelagiamarine.com

View file

@ -204,6 +204,7 @@ A crew-management module built incrementally per the **wiki `Crewing-Implementat
- **SITE_STAFF login on onboard/placement**`lib/crew-login.ts` `maybeCreateSiteStaffLogin` creates a passwordless `SITE_STAFF` `User` (sharing the `CRW-` employee no.) when a `grantsLogin` rank is onboarded (`onboardCandidate`) or placed (`placeCrew`) and the crew member has an email; the login's `siteId` is set to the assignment's site.
- **Own-site scoping (§8.7)**`User.siteId` added; the Crew directory filters a `SITE_STAFF` user with a home site to crew whose active assignment is at that site (graceful: no `siteId` → unscoped). The link is set at login creation above.
- **PPE / next-of-kin verify gates**`PpeIssue` / `NextOfKin` gained `verificationStatus` + `verifiedById`; `verifyPpe` / `verifyNextOfKin` (`verify_site_records` — MPO) and queue sections in `/crewing/verification`.
- **EPFO / UAN assisted verification (A3):** `EpfoService/` is a standalone Express + Playwright proxy (the **GstService pattern**) that does an OTP-handshake UAN lookup against the EPFO member portal — `POST /otp` then `POST /verify`. The app proxies via `/api/epfo/otp` + `/api/epfo` (gated by `verify_bank_epf`), and the **EPFO check** affordance in the verification queue records the returned member name onto `EpfDetail.epfoMemberName` (`recordEpfoCheck`). The live portal navigation is **stubbed behind `EPFO_LIVE`** (deterministic in dev/CI: OTP `000000` → matched) until the real selectors/OTP are validated. **Aadhaar is intentionally not handled** (UIDAI-restricted — stays assisted-manual; only `aadhaarLast4` stored, masked).
- Still deferred (not self-contained): the public careers intake API (A2, external) and the Pay-status pay rows (Phase 6 payroll).
### GST Calculation
@ -229,6 +230,7 @@ RESEND_API_KEY, EMAIL_FROM, EMAIL_FROM_NAME
FORGEJO_URL, FORGEJO_REPO, FORGEJO_TOKEN
GST_SERVICE_URL # GstService microservice (defaults to localhost:3003)
EPFO_SERVICE_URL # EpfoService microservice for UAN lookup (defaults to localhost:3004)
NEXT_PUBLIC_INVENTORY_ENABLED # Inventory feature flag
NEXT_PUBLIC_CREWING_ENABLED # Crewing module feature flag (opt-in "true"; off by default)
NEXT_PUBLIC_ENV_LABEL # When set, shows a non-prod banner (EnvBanner). Leave unset in prod.

View file

@ -132,3 +132,34 @@ export async function verifyNextOfKin(id: string, approve: boolean, remarks?: st
g.userId
);
}
// ── EPFO assisted lookup (Accounts) ────────────────────────────────────────────
// Records the result of an EpfoService UAN check on the crew member's EpfDetail
// (A3 "record the result"). The actual lookup runs in the browser via /api/epfo;
// this just persists the returned member name + a timestamp for the audit trail.
export async function recordEpfoCheck(crewMemberId: string, memberName: string | null): Promise<ActionResult> {
const g = await guard("verify_bank_epf");
if ("error" in g) return g;
const rec = await db.epfDetail.findUnique({ where: { crewMemberId }, select: { id: true } });
if (!rec) return { error: "EPF details not found" };
await db.epfDetail.update({
where: { crewMemberId },
data: { epfoMemberName: memberName, epfoCheckedAt: new Date() },
});
await db.crewAction.create({
data: {
actionType: "RECORD_UPDATED",
actorId: g.userId,
crewMemberId,
note: memberName ? `EPFO check matched: ${memberName}` : "EPFO check: no match",
metadata: { record: "epfo_check" },
},
});
revalidatePath(PATH);
revalidatePath(`/crewing/crew/${crewMemberId}`);
return { ok: true };
}

View file

@ -4,7 +4,7 @@ import { useState } from "react";
import { useRouter } from "next/navigation";
import type { SeafarerDocType } from "@prisma/client";
import { AdminDialog } from "@/components/ui/admin-dialog";
import { verifyDocument, verifyBankEpf, verifyPpe, verifyNextOfKin } from "./actions";
import { verifyDocument, verifyBankEpf, verifyPpe, verifyNextOfKin, recordEpfoCheck } from "./actions";
import { verifyAppraisal } from "../appraisals/actions";
import type { PpeItem } from "@prisma/client";
@ -60,6 +60,85 @@ function Actions({ onVerify, onReject }: { onVerify: () => Promise<{ ok: true }
);
}
// EPFO assisted lookup (Accounts): OTP handshake against EpfoService via /api/epfo,
// then record the returned member name onto the EpfDetail (A3). Aadhaar is not
// checked here (UIDAI-restricted — stays manual).
function EpfoAssist({ crewMemberId, uan }: { crewMemberId: string; uan: string | null }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [step, setStep] = useState<"start" | "otp" | "result">("start");
const [sessionId, setSessionId] = useState("");
const [mobileHint, setMobileHint] = useState("");
const [otp, setOtp] = useState("");
const [result, setResult] = useState<{ matched: boolean; name: string | null } | null>(null);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
if (!uan) return null;
function reset() { setStep("start"); setSessionId(""); setOtp(""); setResult(null); setError(""); setMobileHint(""); }
async function requestOtp() {
setPending(true); setError("");
try {
const r = await fetch("/api/epfo/otp", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ uan }) });
const d = await r.json();
if (!r.ok) throw new Error(d.error || "Failed to request OTP");
setSessionId(d.sessionId); setMobileHint(d.mobileHint || ""); setStep("otp");
} catch (e) { setError(String(e instanceof Error ? e.message : e)); }
setPending(false);
}
async function verify() {
setPending(true); setError("");
try {
const r = await fetch("/api/epfo", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId, uan, otp }) });
const d = await r.json();
if (!r.ok) throw new Error(d.error || "Lookup failed");
setResult({ matched: Boolean(d.matched), name: d.name ?? null }); setStep("result");
} catch (e) { setError(String(e instanceof Error ? e.message : e)); }
setPending(false);
}
async function record() {
setPending(true);
await recordEpfoCheck(crewMemberId, result?.name ?? null);
setPending(false); setOpen(false); reset(); router.refresh();
}
return (
<>
<button onClick={() => { reset(); setOpen(true); }} className="rounded-md border border-primary-300 px-3 py-1.5 text-xs font-medium text-primary-700 hover:bg-primary-50">EPFO check</button>
<AdminDialog title="EPFO / UAN check" open={open} onClose={() => setOpen(false)}>
<div className="space-y-4 text-left">
<p className="text-sm text-neutral-600">Assisted UAN lookup via the EPFO portal. An OTP is sent to the member's registered mobile. <span className="text-neutral-400">(Aadhaar is verified manually not via this check.)</span></p>
<p className="text-xs text-neutral-500">UAN: <span className="font-mono">{uan}</span></p>
{step === "start" && (
<button onClick={requestOtp} disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">{pending ? "Requesting…" : "Request OTP"}</button>
)}
{step === "otp" && (
<div className="space-y-2">
<p className="text-xs text-neutral-500">OTP sent to {mobileHint || "the registered mobile"}.</p>
<input className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm" placeholder="Enter OTP" value={otp} onChange={(e) => setOtp(e.target.value)} />
<button onClick={verify} disabled={pending || !otp} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">{pending ? "Checking…" : "Submit OTP"}</button>
</div>
)}
{step === "result" && (
<div className="space-y-2">
{result?.matched ? (
<p className="text-sm text-success-700 bg-success-50 rounded-lg px-3 py-2">Matched EPFO member: <strong>{result.name}</strong></p>
) : (
<p className="text-sm text-warning-700 bg-warning-50 rounded-lg px-3 py-2">No matching EPFO member for this UAN.</p>
)}
<button onClick={record} disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">{pending ? "Recording…" : "Record result"}</button>
</div>
)}
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
</div>
</AdminDialog>
</>
);
}
function Card({ title, sub, empty, children }: { title: string; sub: string; empty: boolean; children: React.ReactNode }) {
return (
<div className="mb-8">
@ -169,7 +248,12 @@ export function VerificationManager({ docs, bank, epf, appraisals, ppe, nok, can
<td className="px-4 py-3 font-mono text-xs text-neutral-700">{e.uan ?? "—"}</td>
<td className="px-4 py-3 font-mono text-xs text-neutral-700">{e.aadhaarLast4 ?? "—"}</td>
<td className="px-4 py-3 text-neutral-600">{e.pfNumber ?? "—"}</td>
<td className="px-4 py-3"><Actions onVerify={() => verifyBankEpf(e.crewMemberId, "epf", true)} onReject={(r) => verifyBankEpf(e.crewMemberId, "epf", false, r)} /></td>
<td className="px-4 py-3">
<div className="flex flex-col items-end gap-1.5">
<EpfoAssist crewMemberId={e.crewMemberId} uan={e.uan} />
<Actions onVerify={() => verifyBankEpf(e.crewMemberId, "epf", true)} onReject={(r) => verifyBankEpf(e.crewMemberId, "epf", false, r)} />
</div>
</td>
</tr>
))}
</tbody>

View file

@ -0,0 +1,30 @@
import { auth } from "@/auth";
import { hasPermission } from "@/lib/permissions";
import { NextRequest, NextResponse } from "next/server";
const EPFO_SERVICE = process.env.EPFO_SERVICE_URL ?? "http://localhost:3004";
/** POST /api/epfo/otp { uan } → { sessionId, mobileHint } — request an EPFO OTP. */
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!hasPermission(session.user.role, "verify_bank_epf")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await req.json().catch(() => ({}));
if (!body.uan) return NextResponse.json({ error: "uan is required" }, { status: 400 });
try {
const res = await fetch(`${EPFO_SERVICE}/otp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ uan: body.uan }),
cache: "no-store",
});
const data = await res.json();
return NextResponse.json(data, { status: res.ok ? 200 : res.status });
} catch (e) {
return NextResponse.json({ error: `EPFO service unavailable: ${String(e)}` }, { status: 502 });
}
}

32
App/app/api/epfo/route.ts Normal file
View file

@ -0,0 +1,32 @@
import { auth } from "@/auth";
import { hasPermission } from "@/lib/permissions";
import { NextRequest, NextResponse } from "next/server";
const EPFO_SERVICE = process.env.EPFO_SERVICE_URL ?? "http://localhost:3004";
/** POST /api/epfo { sessionId, uan, otp } → { matched, name, status } — submit the OTP. */
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!hasPermission(session.user.role, "verify_bank_epf")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await req.json().catch(() => ({}));
if (!body.sessionId || !body.uan || !body.otp) {
return NextResponse.json({ error: "sessionId, uan and otp are required" }, { status: 400 });
}
try {
const res = await fetch(`${EPFO_SERVICE}/verify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId: body.sessionId, uan: body.uan, otp: body.otp }),
cache: "no-store",
});
const data = await res.json();
return NextResponse.json(data, { status: res.ok ? 200 : res.status });
} catch (e) {
return NextResponse.json({ error: `EPFO service unavailable: ${String(e)}` }, { status: 502 });
}
}

View file

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "EpfDetail" ADD COLUMN "epfoCheckedAt" TIMESTAMP(3),
ADD COLUMN "epfoMemberName" TEXT;

View file

@ -908,6 +908,9 @@ model EpfDetail {
pfNumber String?
verificationStatus GateResult @default(PENDING)
verifiedById String?
// EPFO assisted-lookup result (recorded from the EpfoService check, A3).
epfoMemberName String?
epfoCheckedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

View file

@ -10,7 +10,7 @@ vi.mock("@/lib/feature-flags", () => ({ CREWING_ENABLED: true, INVENTORY_ENABLED
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { verifyDocument, verifyBankEpf } from "@/app/(portal)/crewing/verification/actions";
import { verifyDocument, verifyBankEpf, recordEpfoCheck } from "@/app/(portal)/crewing/verification/actions";
import { makeSession, getSeedUser } from "./helpers";
import type { Role } from "@prisma/client";
@ -101,3 +101,20 @@ describe("bank/EPF verification (Accounts)", () => {
expect(await verifyBankEpf(crewId, "bank", true)).toEqual({ error: "Unauthorized" });
});
});
describe("EPFO assisted check (recordEpfoCheck)", () => {
it("records the EPFO member name + timestamp (Accounts)", async () => {
const { crewId } = await crewWithRecords();
as(accountsId, "ACCOUNTS");
expect("ok" in (await recordEpfoCheck(crewId, "EPFO Member (stub)"))).toBe(true);
const epf = await db.epfDetail.findUniqueOrThrow({ where: { crewMemberId: crewId } });
expect(epf.epfoMemberName).toBe("EPFO Member (stub)");
expect(epf.epfoCheckedAt).not.toBeNull();
});
it("is rejected for the MPO (no verify_bank_epf)", async () => {
const { crewId } = await crewWithRecords();
as(manningId, "MANNING");
expect(await recordEpfoCheck(crewId, "x")).toEqual({ error: "Unauthorized" });
});
});

51
EpfoService/README.md Normal file
View file

@ -0,0 +1,51 @@
# EpfoService
EPFO / UAN **assisted-lookup** proxy for PPMS crewing — mirrors `GstService`.
Drives the EPFO member portal headlessly (Playwright) to fetch a member record
for a UAN, so Accounts can confirm a crew member's EPF details against the source.
## Why it differs from GstService
- The GST portal has an anonymous **captcha** lookup. The EPFO member portal does
not — "Know your UAN" is gated by an **OTP to the member's registered mobile**.
So the handshake is two steps (`/otp` then `/verify`).
- **Aadhaar is out of scope.** UIDAI restricts Aadhaar verification to licensed
AUA/KUA via consented e-KYC; it cannot be portal-scraped. PPMS keeps Aadhaar
**assisted-manual** (stores only the last 4 digits, masked).
## Endpoints
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | `/health` | — | `{ status, mode, sessionCount }` |
| POST | `/otp` | `{ uan }` | `{ sessionId, mobileHint }` |
| POST | `/verify` | `{ sessionId, uan, otp }` | `{ matched, name, status }` |
## Modes
- **Stub (default):** `EPFO_LIVE` unset/`false`. Deterministic responses — OTP
`000000` → matched member, anything else → not matched. Lets the app
integration run end-to-end in dev/CI without the live portal.
- **Live:** `EPFO_LIVE=true`. Drives the real portal. **The page selectors and the
OTP/captcha flow are marked `TODO(live)` and must be validated against a real
session before enabling** — the portal layout is the source of truth.
## Env
```
PORT=3004
SESSION_TTL_MS=300000
EPFO_LIVE=false
EPFO_PORTAL_URL=https://unifiedportal-mem.epfindia.gov.in/memberinterface/
```
## Run
```
pnpm install
pnpm dev # tsx watch
# or
pnpm build && pnpm start
```
The PPMS app reaches it via `EPFO_SERVICE_URL` (proxied through `/api/epfo`).

21
EpfoService/package.json Normal file
View file

@ -0,0 +1,21 @@
{
"name": "epfo-service",
"version": "0.1.0",
"description": "EPFO/UAN proxy — assisted UAN lookup from the EPFO member portal via Playwright (OTP handshake). Mirrors GstService. Aadhaar is NOT handled here (UIDAI-restricted).",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"express": "^4.18.2",
"playwright": "^1.49.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.0.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

137
EpfoService/src/index.ts Normal file
View file

@ -0,0 +1,137 @@
/**
* EpfoService EPFO / UAN assisted-lookup proxy (mirrors GstService).
*
* The EPFO member portal does not offer an anonymous lookup like the GST portal:
* the "Know your UAN" / member flow is gated by an **OTP to the member's
* registered mobile**. So the handshake is two steps:
* POST /otp { uan } opens a session, requests the OTP
* POST /verify { sessionId, uan, otp } submits the OTP, returns the member
* record (name, DOB, status, )
*
* The real portal navigation is gated behind EPFO_LIVE=true. Until the live
* selectors/OTP are validated against a real session, the service runs in STUB
* mode (deterministic responses) so the app integration is exercisable in dev.
*
* Aadhaar verification is intentionally OUT OF SCOPE here UIDAI restricts it to
* licensed AUA/KUA via consented e-KYC; it cannot be portal-scraped. Aadhaar
* stays assisted-manual in PPMS.
*/
import express from "express";
import type { Browser, BrowserContext, Page } from "playwright";
const PORT = Number(process.env.PORT ?? 3004);
const SESSION_TTL_MS = Number(process.env.SESSION_TTL_MS ?? 5 * 60 * 1000); // 5 min
const LIVE = process.env.EPFO_LIVE === "true";
const PORTAL_URL = process.env.EPFO_PORTAL_URL ?? "https://unifiedportal-mem.epfindia.gov.in/memberinterface/";
function log(level: string, msg: string, ctx?: Record<string, unknown>) {
const line = JSON.stringify({ ts: new Date().toISOString(), level, msg, ...ctx });
(level === "ERROR" || level === "WARN" ? process.stderr : process.stdout).write(line + "\n");
}
// ── Sessions ───────────────────────────────────────────────────────────────────
interface Session {
uan: string;
createdAt: number;
context?: BrowserContext;
page?: Page;
}
const sessions = new Map<string, Session>();
let seq = 0;
const newSessionId = () => `epfo_${Date.now().toString(36)}_${(seq++).toString(36)}`;
setInterval(() => {
const now = Date.now();
let pruned = 0;
for (const [id, s] of sessions) {
if (now - s.createdAt > SESSION_TTL_MS) {
s.context?.close().catch(() => {});
sessions.delete(id);
pruned++;
}
}
if (pruned) log("INFO", "Pruned expired sessions", { pruned, remaining: sessions.size });
}, 60_000).unref();
// ── Browser (only launched in LIVE mode) ───────────────────────────────────────
let _browser: Browser | null = null;
async function getBrowser(): Promise<Browser> {
if (_browser?.isConnected()) return _browser;
const { chromium } = await import("playwright");
_browser = await chromium.launch({ headless: true, args: ["--no-sandbox", "--disable-setuid-sandbox"] });
_browser.on("disconnected", () => { _browser = null; });
return _browser;
}
const isUan = (s: unknown): s is string => typeof s === "string" && /^\d{12}$/.test(s);
const mobileHint = (m?: string) => (m && m.length >= 4 ? `••••••${m.slice(-4)}` : "••••••••");
// ── App ────────────────────────────────────────────────────────────────────────
const app = express();
app.use(express.json());
app.get("/health", (_req, res) => {
res.json({ status: "ok", mode: LIVE ? "live" : "stub", sessionCount: sessions.size });
});
/** POST /otp { uan } → { sessionId, mobileHint } — request an OTP to the member's mobile. */
app.post("/otp", async (req, res) => {
const { uan } = req.body ?? {};
if (!isUan(uan)) return res.status(400).json({ error: "A 12-digit UAN is required" });
const sessionId = newSessionId();
if (!LIVE) {
sessions.set(sessionId, { uan, createdAt: Date.now() });
log("INFO", "OTP requested (stub)", { sessionId });
return res.json({ sessionId, mobileHint: mobileHint(), stub: true });
}
try {
const browser = await getBrowser();
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(PORTAL_URL, { waitUntil: "domcontentloaded", timeout: 30_000 });
// TODO(live): drive the member portal's "Know your UAN" OTP request:
// fill UAN, solve the on-page captcha, click "Get OTP", read the masked mobile.
// Selectors must be validated against a real session before enabling EPFO_LIVE.
sessions.set(sessionId, { uan, createdAt: Date.now(), context, page });
return res.json({ sessionId, mobileHint: mobileHint() });
} catch (e) {
log("ERROR", "POST /otp failed", { err: String(e) });
return res.status(502).json({ error: `EPFO portal error: ${String(e)}` });
}
});
/** POST /verify { sessionId, uan, otp } → { matched, name, status } — submit the OTP. */
app.post("/verify", async (req, res) => {
const { sessionId, uan, otp } = req.body ?? {};
const s = sessionId && sessions.get(sessionId);
if (!s) return res.status(410).json({ error: "Session expired — request a new OTP" });
if (!isUan(uan) || s.uan !== uan) return res.status(400).json({ error: "UAN mismatch" });
if (typeof otp !== "string" || !/^\d{4,8}$/.test(otp)) return res.status(400).json({ error: "A valid OTP is required" });
if (!LIVE) {
sessions.delete(sessionId);
// Deterministic stub: OTP 000000 → matched member; anything else → not matched.
const matched = otp === "000000";
log("INFO", "Verify (stub)", { sessionId, matched });
return res.json({ matched, name: matched ? "EPFO Member (stub)" : null, status: matched ? "ACTIVE" : null, stub: true });
}
try {
// TODO(live): submit the OTP and scrape the member record (name/DOB/status).
const result = { matched: false, name: null as string | null, status: null as string | null };
s.context?.close().catch(() => {});
sessions.delete(sessionId);
return res.json(result);
} catch (e) {
log("ERROR", "POST /verify failed", { err: String(e) });
return res.status(502).json({ error: `EPFO portal error: ${String(e)}` });
}
});
app.listen(PORT, () => log("INFO", `EpfoService listening`, { port: PORT, mode: LIVE ? "live" : "stub" }));

12
EpfoService/tsconfig.json Normal file
View file

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}