diff --git a/nextjs_space/app/actions/kyc-check.ts b/nextjs_space/app/actions/kyc-check.ts index 84c24c15..6a23f656 100644 --- a/nextjs_space/app/actions/kyc-check.ts +++ b/nextjs_space/app/actions/kyc-check.ts @@ -16,8 +16,19 @@ export type KycStatus = { // tenants: 'UPLOADED' | 'UPLOAD_FAILED' | null. Only meaningful while // unverified; drives the dashboard re-upload CTA. idDocumentStatus?: string | null; + // Dr Green verification path: 'KYC' = legacy First-AML, 'ID' = SA + // ID-upload. Only set when the live client read succeeded. Drives the + // dashboard's switch-to-ID offer for stuck legacy AML clients on + // ID-upload tenants. + verificationType?: 'KYC' | 'ID' | null; }; +// Narrow Dr Green's string field to the two values the UI branches on; +// anything unexpected reads as null so no CTA renders off a bad value. +function narrowVerificationType(value: unknown): 'KYC' | 'ID' | null { + return value === 'KYC' || value === 'ID' ? value : null; +} + export async function checkUserKycStatus(): Promise { try { const clerkUser = await getCurrentUser(); @@ -250,6 +261,7 @@ export async function checkUserKycStatus(): Promise { status: "REJECTED", message: client.rejectionNote || undefined, idDocumentStatus, + verificationType: narrowVerificationType(client.verificationType), }; } @@ -263,6 +275,7 @@ export async function checkUserKycStatus(): Promise { kycVerified: isVerified, status, idDocumentStatus: isVerified ? null : idDocumentStatus, + verificationType: narrowVerificationType(client.verificationType), }; } catch (configOrApiError) { const errMsg = configOrApiError instanceof Error ? configOrApiError.message : String(configOrApiError); diff --git a/nextjs_space/app/api/store/[slug]/verify/switch-to-id/route.ts b/nextjs_space/app/api/store/[slug]/verify/switch-to-id/route.ts new file mode 100644 index 00000000..40ef61ce --- /dev/null +++ b/nextjs_space/app/api/store/[slug]/verify/switch-to-id/route.ts @@ -0,0 +1,130 @@ +import { NextResponse } from "next/server"; + +import { withAuth } from "@/lib/api-auth"; +import { prisma } from "@/lib/db"; +import { getCurrentTenant } from "@/lib/tenant/tenant"; +import { getTenantDrGreenConfig } from "@/lib/tenant/tenant-config"; +import { apiError } from "@/lib/api-error"; +import { parseSlug } from "@/lib/validation/parse-uuid"; +import { + switchClientToIdVerification, + mapDrGreenApiError, +} from "@/lib/drgreen-identity"; +import { + getTenantVerificationMode, + isSaIdUploadEnabled, +} from "@/lib/verification-mode"; +import { logger } from "@/lib/logger"; + +// Node runtime: the Dr Green client signs requests with node:crypto. +export const runtime = "nodejs"; + +/** + * Map a Dr Green refusal to a customer response: 403 (feature off) and 404 + * get fixed copy, 400/409 surface Dr Green's own customer-safe reason + * (already verified / not South African). Anything else stays a generic 500. + */ +function toCustomerError( + error: unknown, +): { status: number; message: string } | null { + const mapped = mapDrGreenApiError(error); + if (!mapped) return null; + if (mapped.status === 403) { + return { + status: 403, + message: "Switching to ID verification is not available right now", + }; + } + if (mapped.status === 404) { + return { status: 404, message: "We couldn't find your account" }; + } + if (mapped.status === 409 || mapped.status === 400) { + return { + status: 409, + message: + mapped.message ?? "Your account can't be switched to ID verification", + }; + } + return null; +} + +/** + * Switch the signed-in customer's Dr Green client from the legacy First-AML + * KYC path to SA ID-upload verification. Pure pass-through: eligibility + * (feature flag, ZAF shipping, not already verified) is enforced by + * Dr Green; nothing about the decision is persisted locally — the dashboard + * re-reads the live client after switching. + */ +export const POST = withAuth(async (request, { user }, { slug }) => { + try { + parseSlug(slug); + + const email = user.email; + if (!email) { + return NextResponse.json({ error: "Email not found" }, { status: 401 }); + } + + const tenant = await getCurrentTenant(); + if (!tenant) { + return NextResponse.json({ error: "Store not found" }, { status: 404 }); + } + + // Gate: global flag + tenant is in ID-upload mode (which is ZA-only) — + // same gate as the ID-document upload proxy this flow feeds into. + if ( + !isSaIdUploadEnabled() || + getTenantVerificationMode(tenant) !== "ID_UPLOAD" + ) { + return NextResponse.json( + { error: "ID verification is not available for this store" }, + { status: 403 }, + ); + } + + const dbUser = await prisma.users.findFirst({ + where: { email }, + select: { id: true, drGreenClientId: true }, + }); + if (!dbUser?.drGreenClientId) { + return NextResponse.json( + { error: "No verification record found for your account" }, + { status: 400 }, + ); + } + + const config = await getTenantDrGreenConfig(tenant.id); + + try { + const client = await switchClientToIdVerification({ + clientId: dbUser.drGreenClientId, + config: { apiKey: config.apiKey, secretKey: config.secretKey }, + baseUrl: config.apiUrl, + }); + logger.info("[SwitchToId] client switched to ID verification", { + userId: dbUser.id, + drGreenClientId: dbUser.drGreenClientId, + adminApproval: client.adminApproval, + }); + return NextResponse.json({ status: "SWITCHED" }); + } catch (switchError) { + const mapped = toCustomerError(switchError); + if (mapped) { + logger.warn("[SwitchToId] Dr Green refused the switch", { + userId: dbUser.id, + status: mapped.status, + }); + return NextResponse.json( + { error: mapped.message }, + { status: mapped.status }, + ); + } + throw switchError; + } + } catch (error) { + return apiError(error, { + route: "store.verify.switch-to-id", + status: 500, + safeMessage: "Failed to switch your verification method. Please try again.", + }); + } +}); diff --git a/nextjs_space/app/store/[slug]/dashboard/page.tsx b/nextjs_space/app/store/[slug]/dashboard/page.tsx index f2a56d67..f38c87e2 100644 --- a/nextjs_space/app/store/[slug]/dashboard/page.tsx +++ b/nextjs_space/app/store/[slug]/dashboard/page.tsx @@ -25,6 +25,10 @@ import { getStorefrontDashboard, StorefrontDashboard } from "@/app/actions/dashb import { OrderListItem, money } from "@/components/storefront/order-list-item"; import { getTenantBasePath } from "@/lib/tenant/tenant-utils"; import { ReUploadIdDocument } from "@/components/shop/ReUploadIdDocument"; +import { + SwitchToIdVerification, + CompleteIdUpload, +} from "@/components/shop/SwitchToIdVerification"; function StatCard({ icon, @@ -97,6 +101,29 @@ export default function DashboardPage() { // the client re-upload right here (they do NOT need a new account). const rejected = !verified && !showClinical && kycStatus?.status === "REJECTED"; + // Legacy First-AML client on an ID-upload store: offer the self-service + // switch to ID verification (their AML application is otherwise a dead end). + // Deliberately INCLUDES rejected KYC clients — rendered before the + // `rejected` branch below: plain re-upload would leave their First-AML + // caseId live (late-webhook un-verify risk) and admin Accept doesn't + // KYC-verify KYC-type clients, so switching first is the only path that + // actually completes for them. verificationType is only set when the live + // Dr Green read succeeded, so API_ERROR states never render the offer. + const switchOffer = + !verified && + !showClinical && + !idUploadFailed && + kycStatus?.verificationType === "KYC"; + // On the ID path with no recorded upload (a switcher who left before + // uploading, or a pre-PRD-220 registrant with no outcome flag): show the + // upload form — the amber "being reviewed" banner would be false here. + const needsUpload = + !verified && + !showClinical && + !idUploadFailed && + !rejected && + kycStatus?.verificationType === "ID" && + kycStatus?.idDocumentStatus !== "UPLOADED"; const orders = data?.orders ?? []; return ( @@ -161,6 +188,16 @@ export default function DashboardPage() { + ) : switchOffer ? ( + /* Legacy First-AML client (rejected or not) — one-click switch to + ID verification, then the upload form takes over in place. */ + checkUserKycStatus().then(setKycStatus)} + /> ) : rejected ? ( /* Admin rejected the uploaded ID — show the reason and let the client re-upload here. They do NOT need to create a new account. */ @@ -188,6 +225,12 @@ export default function DashboardPage() { + ) : needsUpload ? ( + /* ID-path client with no recorded upload — finish verification. */ + checkUserKycStatus().then(setKycStatus)} + /> ) : (
void; + // Set when this legacy client was previously admin-rejected — acknowledge + // it in the offer copy instead of hiding the switch behind the plain + // re-upload card (which would leave their First-AML caseId live). + rejectionReason?: string; +}) { + const [switching, setSwitching] = useState(false); + const [switched, setSwitched] = useState(false); + const [error, setError] = useState(null); + + const doSwitch = async () => { + setError(null); + setSwitching(true); + try { + const res = await fetch(`/api/store/${slug}/verify/switch-to-id`, { + method: "POST", + }); + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error( + body?.error || "Failed to switch your verification method", + ); + } + setSwitched(true); + toast.success("You're on ID verification now — upload your ID below."); + } catch (e) { + const message = + e instanceof Error ? e.message : "Failed to switch your verification method"; + setError(message); + toast.error(message); + } finally { + setSwitching(false); + } + }; + + if (switched) { + return ( + + ); + } + + return ( +
+
+ +
+

+ A faster way to get verified +

+ {rejectionReason && ( +

+ + Your earlier verification wasn't approved: + {" "} + {rejectionReason} +

+ )} +

+ Your account is waiting on our older KYC process. South African + customers can now verify with a simple ID upload instead — switch + below, upload a photo of your government ID, and we'll review + it. No new account needed. +

+ {error && ( +

{error}

+ )} + +
+
+
+ ); +} + +/** + * Card for a customer already on the ID path with no recorded upload — + * a switcher who left before uploading, or an ID registrant whose inline + * upload never completed. Wraps the existing upload form with neutral + * "finish your verification" copy (the amber "being reviewed" banner would + * be false here: there is nothing to review yet). + */ +export function CompleteIdUpload({ + slug, + onUploaded, + heading = "Finish your verification", + body = "We still need your ID to verify your account. Upload a clear photo of a valid government ID (not a selfie) and we'll review it.", +}: { + slug: string; + onUploaded?: () => void; + heading?: string; + body?: string; +}) { + return ( +
+
+ +
+

{heading}

+

{body}

+ +
+
+
+ ); +} diff --git a/nextjs_space/lib/drgreen-identity.ts b/nextjs_space/lib/drgreen-identity.ts index 286e7d1b..42eb60ff 100644 --- a/nextjs_space/lib/drgreen-identity.ts +++ b/nextjs_space/lib/drgreen-identity.ts @@ -177,6 +177,79 @@ export async function uploadIdentityDocument( return doc; } +// CONTRACT: Dr Green — switch a legacy First-AML client onto the ID-upload +// verification path (dr-green-backend POST /dapp/clients/switch-to-id). +// clientId travels in the SIGNED JSON body — DualAuthGuard verifies POSTs +// against JSON.stringify(req.body) — so the signature covers exactly which +// client is being switched. Dr Green enforces eligibility server-side +// (SA flag on, ZAF shipping, not already KYC- or admin-verified). +const SWITCH_TO_ID_ENDPOINT = '/dapp/clients/switch-to-id'; + +export interface SwitchToIdResult { + id: string; + verificationType: string; + adminApproval: string; +} + +/** + * Recover the HTTP status + customer-safe upstream message from a + * callDrGreenAPI error ("Doctor Green API Error: - + * "). Dr Green's 4xx refusals here carry copy written for customers + * (already verified / not South African / feature off); surfacing them beats + * a generic 500. Returns null for anything that isn't a Dr Green API error. + */ +export function mapDrGreenApiError( + error: unknown, +): { status: number; message?: string } | null { + const raw = error instanceof Error ? error.message : ''; + const statusMatch = raw.match(/^Doctor Green API Error: (\d{3})/); + if (!statusMatch) return null; + const status = Number(statusMatch[1]); + + let message: string | undefined; + const jsonStart = raw.indexOf('- {'); + if (jsonStart !== -1) { + try { + const parsed = JSON.parse(raw.slice(jsonStart + 2)); + if (typeof parsed?.message === 'string') message = parsed.message; + } catch { + // Truncated/non-JSON body — callers fall back to generic copy. + } + } + return { status, message }; +} + +export async function switchClientToIdVerification(params: { + clientId: string; + config: DrGreenIdentityConfig; + baseUrl?: string; +}): Promise { + const { clientId, config, baseUrl } = params; + if (!config?.apiKey || !config?.secretKey) { + throw new Error('MISSING_CREDENTIALS'); + } + + const response = await callDrGreenAPI(SWITCH_TO_ID_ENDPOINT, { + method: 'POST', + apiKey: config.apiKey, + secretKey: config.secretKey, + baseUrl, + body: { clientId }, + }); + + // The service returns { message, client } and the global response + // interceptor wraps it again — tolerate both nestings, mirroring + // extractClientId() on the create path. + const client = + response?.data?.client ?? response?.client ?? response?.data?.data?.client; + if (!client?.id) { + throw new Error( + 'Dr Green switch-to-id returned an unexpected response shape', + ); + } + return client as SwitchToIdResult; +} + // CONTRACT: Dr Green — create client (design doc §5.0). The other client calls // in doctor-green-api.ts use this same `/dapp/clients` path; the SA ID path // adds `verificationType: "ID"` so First-AML/medical are skipped. diff --git a/nextjs_space/lib/drgreen/doctor-green-api.ts b/nextjs_space/lib/drgreen/doctor-green-api.ts index a45feadf..bd85f11c 100644 --- a/nextjs_space/lib/drgreen/doctor-green-api.ts +++ b/nextjs_space/lib/drgreen/doctor-green-api.ts @@ -180,6 +180,9 @@ export interface DoctorGreenClient { isActive: boolean; adminApproval: string; // "VERIFIED" | "PENDING" | "REJECTED" isKYCVerified: boolean; + // "KYC" = First-AML path (legacy default), "ID" = SA ID-upload path. + // Drives the dashboard's switch-to-ID offer for stuck legacy AML clients. + verificationType?: string; verifiedAt?: string; rejectedAt?: string; rejectionNote?: string; // reason shown to the client when their ID is rejected diff --git a/nextjs_space/scripts/_spike/verify-switch-to-id.ts b/nextjs_space/scripts/_spike/verify-switch-to-id.ts new file mode 100644 index 00000000..f83c2244 --- /dev/null +++ b/nextjs_space/scripts/_spike/verify-switch-to-id.ts @@ -0,0 +1,273 @@ +/** + * THROWAWAY spike — validate the legacy-AML → ID-upload switch against Dr Green + * STAGING end-to-end, the way any storefront would: pure API, no DB seeding. + * NOT shipped and NOT imported anywhere. + * + * A "stuck legacy client" is created through the front door: a KYC-type + * registration (full medicalRecord, ZAF shipping, no verificationType) whose + * First-AML case nobody will ever complete — exactly the state of the real + * pre-June cohort. + * + * Prereqs on Dr Green staging: STG_SA_ID_ENABLED=true (it is, live). + * + * Phase 1 (default) — creates fixtures and runs every automatic assertion: + * + * DRG_HOST=https://stage-api.drgreennft.com/api/v1 \ + * DRG_APIKEY=... DRG_SECRET=... \ + * pnpm exec tsx scripts/_spike/verify-switch-to-id.ts + * + * It prints the created client id + a manual checklist (reject in stage-admin, + * then Accept in stage-admin — the real button, on purpose). After each manual + * step, re-run with the phase env: + * + * DRG_PHASE=post-reject DRG_CLIENT_ID= ... verify-switch-to-id.ts + * DRG_PHASE=post-accept DRG_CLIENT_ID= ... verify-switch-to-id.ts + */ +import { switchClientToIdVerification, uploadIdentityDocument } from "../../lib/drgreen-identity"; +import { callDrGreenAPI } from "../../lib/drgreen/drgreen-api-client"; +import { fetchClient } from "../../lib/drgreen/doctor-green-api"; + +const HOST = process.env.DRG_HOST || "https://stage-api.drgreennft.com/api/v1"; +const APIKEY = process.env.DRG_APIKEY || ""; +const SECRET = process.env.DRG_SECRET || ""; +const PHASE = process.env.DRG_PHASE || "full"; + +const config = { apiKey: APIKEY, secretKey: SECRET }; +const fetchCfg = { apiKey: APIKEY, secretKey: SECRET, apiUrl: HOST }; + +// 1x1 transparent PNG — enough for the upload contract (mime + size checks). +const TINY_PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + "base64", +); + +let failures = 0; +function ok(label: string) { + console.log(`✅ ${label}`); +} +function fail(label: string, detail?: unknown) { + failures++; + console.error(`❌ ${label}`, detail ?? ""); +} +function assertEq(label: string, actual: unknown, expected: unknown) { + if (actual === expected) ok(`${label} = ${String(expected)}`); + else fail(`${label}: expected ${String(expected)}, got ${String(actual)}`); +} + +// Known-valid medicalRecord (mirrors the live KYC consultation payload). +function medicalRecord(dob: string) { + return { + dob, + gender: "Male", + medicalConditions: ["anxiety"], + medicinesTreatments: ["melatonin"], + otherMedicalTreatments: "", + prescriptionsSupplements: "", + medicalHistory0: false, + medicalHistory1: false, + medicalHistory2: false, + medicalHistory3: false, + medicalHistory4: false, + medicalHistory5: ["none"], + medicalHistory6: false, + medicalHistory7: ["none"], + medicalHistory7Relation: "none", + medicalHistory8: false, + medicalHistory9: false, + medicalHistory10: false, + medicalHistory11: "0", + medicalHistory12: false, + medicalHistory13: "never", + medicalHistory14: ["never"], + medicalHistory15: "", + medicalHistory16: false, + }; +} + +function extractClientId(response: any): string | undefined { + return ( + response?.data?.client?.id || + response?.data?.id || + response?.client?.id || + response?.id + ); +} + +async function createKycClient(opts: { + tag: string; + countryCode3: string; // "ZAF" | "PRT" + country: string; + city: string; + state: string; + postalCode: string; + phoneCode: string; + phoneCountryCode: string; +}) { + const stamp = Date.now().toString().slice(-9); + const body = { + firstName: "SwitchSpike", + lastName: opts.tag, + email: `gerard161+switch-${opts.tag.toLowerCase()}-${stamp}@gmail.com`, + phoneCode: opts.phoneCode, + phoneCountryCode: opts.phoneCountryCode, + contactNumber: `8${stamp.slice(0, 8)}`, // unique digits-only + // NO verificationType → defaults to KYC → real First-AML case, left stuck. + shipping: { + address1: "1 Test Street", + address2: "", + landmark: "", + city: opts.city, + state: opts.state, + country: opts.country, + postalCode: opts.postalCode, + countryCode: opts.countryCode3, + }, + medicalRecord: medicalRecord("1990-01-01"), + }; + const res = await callDrGreenAPI("/dapp/clients", { + method: "POST", + apiKey: APIKEY, + secretKey: SECRET, + baseUrl: HOST, + body, + }); + const id = extractClientId(res); + if (!id) throw new Error(`no client id in create response: ${JSON.stringify(res).slice(0, 300)}`); + return { id, email: body.email }; +} + +async function expectSwitchRefusal(label: string, clientId: string, fragment: RegExp) { + try { + await switchClientToIdVerification({ clientId, config, baseUrl: HOST }); + fail(`${label}: switch unexpectedly succeeded`); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (fragment.test(msg)) ok(`${label} refused as expected (${fragment})`); + else fail(`${label}: refused with unexpected error`, msg); + } +} + +async function phaseFull() { + console.log(`\n— Phase 1: fixtures + automatic assertions against ${HOST}\n`); + + // 1. Stuck ZAF legacy client, born through the front door. + const za = await createKycClient({ + tag: "ZA", + countryCode3: "ZAF", + country: "South Africa", + city: "Cape Town", + state: "Western Cape", + postalCode: "8001", + phoneCode: "+27", + phoneCountryCode: "ZA", + }); + ok(`created stuck ZAF KYC client ${za.id} (${za.email})`); + + const before = await fetchClient(za.id, fetchCfg); + assertEq("pre-switch verificationType", before.verificationType, "KYC"); + assertEq("pre-switch isKYCVerified", before.isKYCVerified, false); + assertEq("pre-switch adminApproval", before.adminApproval, "PENDING"); + + // 2. Non-ZAF control — must be refused. + const pt = await createKycClient({ + tag: "PT", + countryCode3: "PRT", + country: "Portugal", + city: "Lisbon", + state: "Lisboa", + postalCode: "1000-001", + phoneCode: "+351", + phoneCountryCode: "PT", + }); + ok(`created non-ZAF control client ${pt.id}`); + await expectSwitchRefusal("non-ZAF switch", pt.id, /South African/i); + + // 3. The switch. + const switched = await switchClientToIdVerification({ clientId: za.id, config, baseUrl: HOST }); + assertEq("post-switch verificationType", switched.verificationType, "ID"); + + // 4. Idempotency. + const again = await switchClientToIdVerification({ clientId: za.id, config, baseUrl: HOST }); + assertEq("idempotent re-switch verificationType", again.verificationType, "ID"); + + // 5. Upload an ID document (multipart, byte-exact signing). + const doc = await uploadIdentityDocument({ + clientId: za.id, + documentType: "ID", + documentNumber: "SPIKE-SWITCH-001", + file: TINY_PNG, + mimeType: "image/png", + config, + baseUrl: HOST, + }); + assertEq("uploaded document reviewStatus", doc.reviewStatus, "PENDING"); + + console.log(` +— Manual steps in stage-admin (ClientVerification), then re-run phases — + + Client: ${za.id} (${za.email}) + + a) REJECT the client with any reason ≥5 chars, then: + DRG_PHASE=post-reject DRG_CLIENT_ID=${za.id} DRG_APIKEY=… DRG_SECRET=… pnpm exec tsx scripts/_spike/verify-switch-to-id.ts + + b) ACCEPT the client (the real button — flags + on-chain), then: + DRG_PHASE=post-accept DRG_CLIENT_ID=${za.id} DRG_APIKEY=… DRG_SECRET=… pnpm exec tsx scripts/_spike/verify-switch-to-id.ts +`); +} + +async function phasePostReject(clientId: string) { + console.log(`\n— Phase post-reject: re-upload must reset ${clientId} to PENDING\n`); + const rejected = await fetchClient(clientId, fetchCfg); + assertEq("pre-reupload adminApproval", rejected.adminApproval, "REJECTED"); + + await uploadIdentityDocument({ + clientId, + documentType: "ID", + documentNumber: "SPIKE-SWITCH-002", + file: TINY_PNG, + mimeType: "image/png", + config, + baseUrl: HOST, + }); + const after = await fetchClient(clientId, fetchCfg); + assertEq("post-reupload adminApproval (auto-reset)", after.adminApproval, "PENDING"); + assertEq("rejectionNote cleared", after.rejectionNote ?? null, null); +} + +async function phasePostAccept(clientId: string) { + console.log(`\n— Phase post-accept: Accept must fully verify ${clientId}\n`); + const client = await fetchClient(clientId, fetchCfg); + assertEq("verificationType", client.verificationType, "ID"); + assertEq("isKYCVerified (the #486 path for ID clients)", client.isKYCVerified, true); + assertEq("adminApproval", client.adminApproval, "VERIFIED"); + assertEq("isActive", client.isActive, true); + // A switched-then-verified client must never be switchable again. + await expectSwitchRefusal("switch-after-verify", clientId, /already/i); +} + +async function main() { + if (!APIKEY || !SECRET) { + throw new Error("Set DRG_APIKEY and DRG_SECRET (staging keypair; env only — never a file)."); + } + if (PHASE === "full") await phaseFull(); + else if (PHASE === "post-reject") await phasePostReject(requiredClientId()); + else if (PHASE === "post-accept") await phasePostAccept(requiredClientId()); + else throw new Error(`Unknown DRG_PHASE: ${PHASE}`); + + if (failures > 0) { + console.error(`\n${failures} assertion(s) failed.`); + process.exit(1); + } + console.log("\nAll assertions in this phase passed."); +} + +function requiredClientId(): string { + const id = process.env.DRG_CLIENT_ID; + if (!id) throw new Error("Set DRG_CLIENT_ID for this phase."); + return id; +} + +main().catch((err) => { + console.error("❌ Spike aborted:", err?.message || err); + process.exit(1); +}); diff --git a/nextjs_space/tests/unit/switch-to-id.test.ts b/nextjs_space/tests/unit/switch-to-id.test.ts new file mode 100644 index 00000000..fd7339fc --- /dev/null +++ b/nextjs_space/tests/unit/switch-to-id.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/drgreen/drgreen-api-client", () => ({ + callDrGreenAPI: vi.fn(), + generateDrGreenSignature: vi.fn(() => "sig"), +})); + +import { callDrGreenAPI } from "@/lib/drgreen/drgreen-api-client"; +import { + switchClientToIdVerification, + mapDrGreenApiError, +} from "@/lib/drgreen-identity"; + +const config = { apiKey: "k", secretKey: "s" }; + +describe("switchClientToIdVerification", () => { + beforeEach(() => vi.clearAllMocks()); + + it("posts the clientId in the signed body to /dapp/clients/switch-to-id", async () => { + (callDrGreenAPI as any).mockResolvedValue({ + data: { + client: { id: "c1", verificationType: "ID", adminApproval: "PENDING" }, + }, + }); + + const res = await switchClientToIdVerification({ clientId: "c1", config }); + + expect(res.verificationType).toBe("ID"); + const [endpoint, opts] = (callDrGreenAPI as any).mock.calls[0]; + expect(endpoint).toBe("/dapp/clients/switch-to-id"); + expect(opts.method).toBe("POST"); + // The body IS the signed payload (DualAuthGuard signs + // JSON.stringify(req.body)) — the clientId must travel there. + expect(opts.body).toEqual({ clientId: "c1" }); + }); + + it("tolerates the interceptor's single- and double-wrapped envelopes", async () => { + (callDrGreenAPI as any).mockResolvedValue({ + client: { id: "c1", verificationType: "ID", adminApproval: "PENDING" }, + }); + const res = await switchClientToIdVerification({ clientId: "c1", config }); + expect(res.id).toBe("c1"); + + (callDrGreenAPI as any).mockResolvedValue({ + data: { + data: { + client: { id: "c2", verificationType: "ID", adminApproval: "PENDING" }, + }, + }, + }); + const res2 = await switchClientToIdVerification({ clientId: "c2", config }); + expect(res2.id).toBe("c2"); + }); + + it("throws MISSING_CREDENTIALS without calling Dr Green when config is incomplete", async () => { + await expect( + switchClientToIdVerification({ + clientId: "c1", + config: { apiKey: "", secretKey: "s" }, + }), + ).rejects.toThrow("MISSING_CREDENTIALS"); + expect(callDrGreenAPI).not.toHaveBeenCalled(); + }); + + it("throws on a response with no client payload", async () => { + (callDrGreenAPI as any).mockResolvedValue({ data: { message: "ok" } }); + await expect( + switchClientToIdVerification({ clientId: "c1", config }), + ).rejects.toThrow(/unexpected response shape/); + }); +}); + +describe("mapDrGreenApiError", () => { + it("recovers status and Dr Green's customer-safe message from a 409", () => { + const err = new Error( + 'Doctor Green API Error: 409 Conflict - {"success":false,"statusCode":409,"message":"ID verification is only available for South African clients"}', + ); + expect(mapDrGreenApiError(err)).toEqual({ + status: 409, + message: "ID verification is only available for South African clients", + }); + }); + + it("recovers the status alone when the body is truncated or non-JSON", () => { + const err = new Error("Doctor Green API Error: 403 Forbidden - "); + expect(mapDrGreenApiError(err)).toEqual({ + status: 403, + message: undefined, + }); + }); + + it("returns null for non-Dr-Green errors", () => { + expect(mapDrGreenApiError(new Error("ECONNREFUSED"))).toBeNull(); + expect(mapDrGreenApiError("not an error")).toBeNull(); + }); +}); diff --git a/nextjs_space/tests/unit/verify-switch-to-id-route.test.ts b/nextjs_space/tests/unit/verify-switch-to-id-route.test.ts new file mode 100644 index 00000000..17c2f243 --- /dev/null +++ b/nextjs_space/tests/unit/verify-switch-to-id-route.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// withAuth → identity wrapper so POST is the raw handler (req, {user}, {slug}). +vi.mock("@/lib/api-auth", () => ({ withAuth: (h: any) => h })); +vi.mock("@/lib/validation/parse-uuid", () => ({ parseSlug: vi.fn() })); +vi.mock("@/lib/tenant/tenant", () => ({ getCurrentTenant: vi.fn() })); +vi.mock("@/lib/tenant/tenant-config", () => ({ + getTenantDrGreenConfig: vi.fn(async () => ({ + apiKey: "k", + secretKey: "s", + apiUrl: "https://stage/api/v1", + })), +})); +vi.mock("@/lib/db", () => ({ prisma: { users: { findFirst: vi.fn() } } })); +vi.mock("@/lib/verification-mode", () => ({ + isSaIdUploadEnabled: vi.fn(() => true), + getTenantVerificationMode: vi.fn(() => "ID_UPLOAD"), +})); +vi.mock("@/lib/logger", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); +vi.mock("@/lib/api-error", () => ({ + apiError: (_e: any, o: any) => + new Response(JSON.stringify({ error: o?.safeMessage ?? "error" }), { + status: o?.status ?? 500, + headers: { "content-type": "application/json" }, + }), +})); +// Keep the REAL mapDrGreenApiError (its mapping is part of what this file +// tests); stub only the network call. +vi.mock("@/lib/drgreen-identity", async (importOriginal) => ({ + ...(await importOriginal()), + switchClientToIdVerification: vi.fn(), +})); + +import { POST } from "@/app/api/store/[slug]/verify/switch-to-id/route"; +import { getCurrentTenant } from "@/lib/tenant/tenant"; +import { prisma } from "@/lib/db"; +import { + isSaIdUploadEnabled, + getTenantVerificationMode, +} from "@/lib/verification-mode"; +import { switchClientToIdVerification } from "@/lib/drgreen-identity"; + +const ZA_ID_TENANT = { + id: "tenant-1", + countryCode: "ZA", + settings: { verificationMode: "ID_UPLOAD" }, +}; + +const makeReq = () => + new Request("https://store.test/api/store/s/verify/switch-to-id", { + method: "POST", + }) as any; + +// Cast: tsc types POST by the real withAuth wrapper (1-2 args); the vitest +// mock replaces it with the raw (req, {user}, {slug}) handler — same idiom +// as verify-id-document-route.test.ts. +const call = () => + (POST as any)(makeReq(), { user: { email: "t@example.com" } }, { slug: "s" }); + +describe("POST /api/store/[slug]/verify/switch-to-id", () => { + beforeEach(() => { + vi.clearAllMocks(); + (getCurrentTenant as any).mockResolvedValue(ZA_ID_TENANT); + (isSaIdUploadEnabled as any).mockReturnValue(true); + (getTenantVerificationMode as any).mockReturnValue("ID_UPLOAD"); + (prisma.users.findFirst as any).mockResolvedValue({ + id: "u1", + drGreenClientId: "dg-1", + }); + (switchClientToIdVerification as any).mockResolvedValue({ + id: "dg-1", + verificationType: "ID", + adminApproval: "PENDING", + }); + }); + + it("switches the caller's own Dr Green client and returns SWITCHED", async () => { + const res = await call(); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ status: "SWITCHED" }); + expect(switchClientToIdVerification).toHaveBeenCalledWith({ + clientId: "dg-1", + config: { apiKey: "k", secretKey: "s" }, + baseUrl: "https://stage/api/v1", + }); + }); + + it("403s when the SA-ID env flag is off", async () => { + (isSaIdUploadEnabled as any).mockReturnValue(false); + const res = await call(); + expect(res.status).toBe(403); + expect(switchClientToIdVerification).not.toHaveBeenCalled(); + }); + + it("403s for a KYC-mode tenant", async () => { + (getTenantVerificationMode as any).mockReturnValue("KYC"); + const res = await call(); + expect(res.status).toBe(403); + }); + + it("404s when the tenant cannot be resolved", async () => { + (getCurrentTenant as any).mockResolvedValue(null); + const res = await call(); + expect(res.status).toBe(404); + }); + + it("400s when the user has no linked Dr Green client", async () => { + (prisma.users.findFirst as any).mockResolvedValue({ + id: "u1", + drGreenClientId: null, + }); + const res = await call(); + expect(res.status).toBe(400); + expect(switchClientToIdVerification).not.toHaveBeenCalled(); + }); + + it("surfaces Dr Green's customer-safe 409 reason", async () => { + (switchClientToIdVerification as any).mockRejectedValue( + new Error( + 'Doctor Green API Error: 409 Conflict - {"success":false,"statusCode":409,"message":"ID verification is only available for South African clients"}', + ), + ); + const res = await call(); + expect(res.status).toBe(409); + expect((await res.json()).error).toBe( + "ID verification is only available for South African clients", + ); + }); + + it("maps an upstream 403 (backend flag off) to fixed copy", async () => { + (switchClientToIdVerification as any).mockRejectedValue( + new Error("Doctor Green API Error: 403 Forbidden - {}"), + ); + const res = await call(); + expect(res.status).toBe(403); + expect((await res.json()).error).toBe( + "Switching to ID verification is not available right now", + ); + }); + + it("falls back to a generic 500 for non-Dr-Green errors", async () => { + (switchClientToIdVerification as any).mockRejectedValue( + new Error("ECONNREFUSED"), + ); + const res = await call(); + expect(res.status).toBe(500); + expect((await res.json()).error).toMatch(/Failed to switch/); + }); +});