Skip to content
Merged
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
10 changes: 10 additions & 0 deletions docs/guides/SUPER_ADMIN_MANUAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,16 @@ Upon approval, the system automatically:

> **Note:** `tenants.nftTokenId` is an optional legacy field on the tenant record. It does not gate access or activation and can be left empty.

### South African ID-upload tenants — Dr Green key checklist

Applies to every tenant whose verification mode is **ID upload** (South Africa only). Dr Green's identity emails — "we've received your ID document" and the rejection email with its re-upload link — build the link back to the storefront from the partner's branding website or, failing that, from the **first allowed return host on the tenant's Dr Green API key**, plus `/dashboard`. A key with no host sends the customer to the Dr Green app instead of the store.

1. The tenant admin adds the Dr Green API key and secret under **Tenant Admin → Settings**.
2. On the Dr Green dApp **Keys** page, the KEY holder adds the storefront host to that key's allowed return hosts: `<subdomain>.budstacks.io`, and the custom domain as well once it is live. Wildcards (`*.example`) are ignored by the link resolver, so list the exact host.
3. Confirm on staging before go-live: reject a test customer's ID in the Dr Green admin and check the email link lands on `https://<tenant host>/dashboard`, where the existing re-upload card is shown.

The link resolution is Dr Green Phase 2 (US-208); until that release is on production the rejection email still points at the partner branding website or the Dr Green app. See `tasks/prd-drgreen-phase-alignment-2026-09.md` (BS-205).

---

## Analytics & Reporting
Expand Down
32 changes: 30 additions & 2 deletions nextjs_space/app/actions/kyc-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { prisma } from "@/lib/db";
import { getTenantDrGreenConfig } from "@/lib/tenant/tenant-config";
import { fetchClient, fetchClientByEmail } from "@/lib/drgreen/doctor-green-api";
import { canonicalAdminApproval } from "@/lib/drgreen/approval-status";
import { customerSafeIdDocumentError } from "@/lib/verification/id-document-errors";
import { logger } from "@/lib/logger";

export type KycStatus = {
Expand All @@ -21,8 +22,22 @@ export type KycStatus = {
// dashboard's switch-to-ID offer for stuck legacy AML clients on
// ID-upload tenants.
verificationType?: 'KYC' | 'ID' | null;
// BS-204 — why the last upload was recorded UPLOAD_FAILED, but only when
// the stored reason is customer-facing copy (the SA ID message). Present
// only in that case; raw upstream errors never leave the server.
idDocumentError?: string;
};

// The `{ idDocumentError }` fragment for an UPLOAD_FAILED flag, or nothing —
// so every other state keeps exactly the object shape it had.
function uploadFailureReason(
status: string | null | undefined,
stored: string | null | undefined,
): { idDocumentError: string } | Record<string, never> {
const safe = status === "UPLOAD_FAILED" ? customerSafeIdDocumentError(stored) : null;
return safe ? { idDocumentError: safe } : {};
}

// 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 {
Expand Down Expand Up @@ -64,7 +79,7 @@ export async function checkUserKycStatus(): Promise<KycStatus> {
tenantId: tenantId,
email: { equals: clerkUser.email, mode: 'insensitive' }
},
select: { id: true, idDocumentStatus: true }
select: { id: true, idDocumentStatus: true, idDocumentError: true }
});

if (questionnaire) {
Expand All @@ -73,6 +88,10 @@ export async function checkUserKycStatus(): Promise<KycStatus> {
kycVerified: false,
status: "PENDING",
idDocumentStatus: questionnaire.idDocumentStatus ?? null,
...uploadFailureReason(
questionnaire.idDocumentStatus,
questionnaire.idDocumentError,
),
};
}

Expand Down Expand Up @@ -108,9 +127,15 @@ export async function checkUserKycStatus(): Promise<KycStatus> {
{ isKycVerified: 'desc' },
{ createdAt: 'desc' }
],
select: { isKycVerified: true, adminApproval: true, idDocumentStatus: true }
select: {
isKycVerified: true,
adminApproval: true,
idDocumentStatus: true,
idDocumentError: true,
}
});
const idDocumentStatus = questionnaire?.idDocumentStatus ?? null;
const idDocumentError = questionnaire?.idDocumentError ?? null;

// Fetch Config and check Dr Green API
try {
Expand Down Expand Up @@ -261,6 +286,7 @@ export async function checkUserKycStatus(): Promise<KycStatus> {
status: "REJECTED",
message: client.rejectionNote || undefined,
idDocumentStatus,
...uploadFailureReason(idDocumentStatus, idDocumentError),
verificationType: narrowVerificationType(client.verificationType),
};
}
Expand All @@ -275,6 +301,7 @@ export async function checkUserKycStatus(): Promise<KycStatus> {
kycVerified: isVerified,
status,
idDocumentStatus: isVerified ? null : idDocumentStatus,
...uploadFailureReason(isVerified ? null : idDocumentStatus, idDocumentError),
verificationType: narrowVerificationType(client.verificationType),
};
} catch (configOrApiError) {
Expand All @@ -286,6 +313,7 @@ export async function checkUserKycStatus(): Promise<KycStatus> {
status: "API_ERROR",
message: `Dr Green API error: ${errMsg}`,
idDocumentStatus,
...uploadFailureReason(idDocumentStatus, idDocumentError),
};
}

Expand Down
Loading
Loading