Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions nextjs_space/app/actions/kyc-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<KycStatus> {
try {
const clerkUser = await getCurrentUser();
Expand Down Expand Up @@ -250,6 +261,7 @@ export async function checkUserKycStatus(): Promise<KycStatus> {
status: "REJECTED",
message: client.rejectionNote || undefined,
idDocumentStatus,
verificationType: narrowVerificationType(client.verificationType),
};
}

Expand All @@ -263,6 +275,7 @@ export async function checkUserKycStatus(): Promise<KycStatus> {
kycVerified: isVerified,
status,
idDocumentStatus: isVerified ? null : idDocumentStatus,
verificationType: narrowVerificationType(client.verificationType),
};
} catch (configOrApiError) {
const errMsg = configOrApiError instanceof Error ? configOrApiError.message : String(configOrApiError);
Expand Down
130 changes: 130 additions & 0 deletions nextjs_space/app/api/store/[slug]/verify/switch-to-id/route.ts
Original file line number Diff line number Diff line change
@@ -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.",
});
}
});
43 changes: 43 additions & 0 deletions nextjs_space/app/store/[slug]/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -161,6 +188,16 @@ export default function DashboardPage() {
</div>
</div>
</div>
) : switchOffer ? (
/* Legacy First-AML client (rejected or not) — one-click switch to
ID verification, then the upload form takes over in place. */
<SwitchToIdVerification
slug={slug}
rejectionReason={
kycStatus?.status === "REJECTED" ? kycStatus?.message : undefined
}
onDone={() => 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. */
Expand Down Expand Up @@ -188,6 +225,12 @@ export default function DashboardPage() {
</div>
</div>
</div>
) : needsUpload ? (
/* ID-path client with no recorded upload — finish verification. */
<CompleteIdUpload
slug={slug}
onUploaded={() => checkUserKycStatus().then(setKycStatus)}
/>
) : (
<div
className={`mb-8 flex items-start gap-3 rounded-2xl border p-5 ${
Expand Down
140 changes: 140 additions & 0 deletions nextjs_space/components/shop/SwitchToIdVerification.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"use client";

import { useState } from "react";
import { Button } from "@/components/ui/button";
import { toast } from "@/components/ui/sonner";
import { ArrowRight, UploadCloud } from "lucide-react";
import { ReUploadIdDocument } from "@/components/shop/ReUploadIdDocument";

/**
* Legacy AML → ID-upload self-service switch (dashboard card).
*
* Shown to customers whose Dr Green client is still on the old First-AML KYC
* path (verificationType === "KYC") on an ID-upload store. One explicit
* consent click calls /api/store/[slug]/verify/switch-to-id (Dr Green
* enforces eligibility server-side), then the existing ID upload form takes
* over in place. The switch itself stores nothing locally — the dashboard
* re-reads the live Dr Green client via onDone after the upload lands.
*/
export function SwitchToIdVerification({
slug,
onDone,
rejectionReason,
}: {
slug: string;
onDone?: () => 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<string | null>(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 (
<CompleteIdUpload
slug={slug}
onUploaded={onDone}
heading="Upload your ID"
body="One last step: upload a clear photo of a valid government ID (not a selfie). We'll review it and email you once your account is approved."
/>
);
}

return (
<div className="mb-8 rounded-2xl border border-sky-200 bg-sky-50/70 p-5">
<div className="flex items-start gap-3">
<UploadCloud className="mt-0.5 h-6 w-6 flex-shrink-0 text-sky-600" />
<div className="flex-1">
<h3 className="font-semibold text-sky-900">
A faster way to get verified
</h3>
{rejectionReason && (
<p className="mt-1 text-sm text-sky-800">
<span className="font-medium">
Your earlier verification wasn&apos;t approved:
</span>{" "}
{rejectionReason}
</p>
)}
<p className="mt-1 text-sm text-sky-800">
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&apos;ll review
it. No new account needed.
</p>
{error && (
<p className="mt-2 text-sm font-medium text-rose-700">{error}</p>
)}
<Button
onClick={doSwitch}
disabled={switching}
className="mt-3"
>
{switching ? "Switching…" : "Switch to ID verification"}
{!switching && <ArrowRight className="ml-2 h-4 w-4" />}
</Button>
</div>
</div>
</div>
);
}

/**
* 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 (
<div className="mb-8 rounded-2xl border border-sky-200 bg-sky-50/70 p-5">
<div className="flex items-start gap-3">
<UploadCloud className="mt-0.5 h-6 w-6 flex-shrink-0 text-sky-600" />
<div className="flex-1">
<h3 className="font-semibold text-sky-900">{heading}</h3>
<p className="mt-1 text-sm text-sky-800">{body}</p>
<ReUploadIdDocument slug={slug} onUploaded={onUploaded} />
</div>
</div>
</div>
);
}
Loading
Loading