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:
parent
df3b4bdc97
commit
e193e26368
13 changed files with 433 additions and 3 deletions
|
|
@ -49,6 +49,13 @@ EMAIL_FROM_NAME="Pelagia Portal"
|
||||||
# Start the service with: cd GstService && npm run dev
|
# Start the service with: cd GstService && npm run dev
|
||||||
GST_SERVICE_URL=http://localhost:3003
|
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) ─────────────
|
# ── Forgejo issue reporting (Report Issue button) ─────────────
|
||||||
# Token needs write:issue scope on the repo below.
|
# Token needs write:issue scope on the repo below.
|
||||||
FORGEJO_URL=https://git.pelagiamarine.com
|
FORGEJO_URL=https://git.pelagiamarine.com
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- **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.
|
- **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`.
|
- **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).
|
- Still deferred (not self-contained): the public careers intake API (A2, external) and the Pay-status pay rows (Phase 6 payroll).
|
||||||
|
|
||||||
### GST Calculation
|
### GST Calculation
|
||||||
|
|
@ -229,6 +230,7 @@ RESEND_API_KEY, EMAIL_FROM, EMAIL_FROM_NAME
|
||||||
FORGEJO_URL, FORGEJO_REPO, FORGEJO_TOKEN
|
FORGEJO_URL, FORGEJO_REPO, FORGEJO_TOKEN
|
||||||
|
|
||||||
GST_SERVICE_URL # GstService microservice (defaults to localhost:3003)
|
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_INVENTORY_ENABLED # Inventory feature flag
|
||||||
NEXT_PUBLIC_CREWING_ENABLED # Crewing module feature flag (opt-in "true"; off by default)
|
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.
|
NEXT_PUBLIC_ENV_LABEL # When set, shows a non-prod banner (EnvBanner). Leave unset in prod.
|
||||||
|
|
|
||||||
|
|
@ -132,3 +132,34 @@ export async function verifyNextOfKin(id: string, approve: boolean, remarks?: st
|
||||||
g.userId
|
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 };
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { SeafarerDocType } from "@prisma/client";
|
import type { SeafarerDocType } from "@prisma/client";
|
||||||
import { AdminDialog } from "@/components/ui/admin-dialog";
|
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 { verifyAppraisal } from "../appraisals/actions";
|
||||||
import type { PpeItem } from "@prisma/client";
|
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 }) {
|
function Card({ title, sub, empty, children }: { title: string; sub: string; empty: boolean; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-8">
|
<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.uan ?? "—"}</td>
|
||||||
<td className="px-4 py-3 font-mono text-xs text-neutral-700">{e.aadhaarLast4 ?? "—"}</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 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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
||||||
30
App/app/api/epfo/otp/route.ts
Normal file
30
App/app/api/epfo/otp/route.ts
Normal 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
32
App/app/api/epfo/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "EpfDetail" ADD COLUMN "epfoCheckedAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "epfoMemberName" TEXT;
|
||||||
|
|
@ -908,6 +908,9 @@ model EpfDetail {
|
||||||
pfNumber String?
|
pfNumber String?
|
||||||
verificationStatus GateResult @default(PENDING)
|
verificationStatus GateResult @default(PENDING)
|
||||||
verifiedById String?
|
verifiedById String?
|
||||||
|
// EPFO assisted-lookup result (recorded from the EpfoService check, A3).
|
||||||
|
epfoMemberName String?
|
||||||
|
epfoCheckedAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ vi.mock("@/lib/feature-flags", () => ({ CREWING_ENABLED: true, INVENTORY_ENABLED
|
||||||
|
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
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 { makeSession, getSeedUser } from "./helpers";
|
||||||
import type { Role } from "@prisma/client";
|
import type { Role } from "@prisma/client";
|
||||||
|
|
||||||
|
|
@ -101,3 +101,20 @@ describe("bank/EPF verification (Accounts)", () => {
|
||||||
expect(await verifyBankEpf(crewId, "bank", true)).toEqual({ error: "Unauthorized" });
|
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
51
EpfoService/README.md
Normal 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
21
EpfoService/package.json
Normal 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
137
EpfoService/src/index.ts
Normal 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
12
EpfoService/tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"outDir": "dist",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue