From 9659646e51940ea01f4d91acdf6530d3bcfb8777 Mon Sep 17 00:00:00 2001 From: Gerard Kavanagh Date: Fri, 18 Sep 2026 10:17:33 +0100 Subject: [PATCH 1/7] feat(verification): South African ID rules on every upload path + X-DRG-Client capability header (BS-201..205) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the Dr Green September 2026 alignment (tasks/prd-drgreen-phase-alignment-2026-09.md). - lib/verification/sa-id.ts: one validator (strip spaces, 13 digits, real 1900s/2000s date not in the future, digit 11 in {0,1,2}, Luhn over 13), the shared SA_ID_INVALID code + copy, and the inline form helper. Proven by lib/verification/__tests__/sa-id-vectors.json — 29 synthetic vectors (no real number) that Dr Green US-201 and plugin 1.3.0 run too; the canonical copy sits at dr-green-backend/docs/design/sa-id-test-vectors.json. - Both upload routes refuse an impossible number with 400 { code: SA_ID_INVALID } before anything is sent: the verify proxy via a superRefine on its meta schema, the consultation submit before ANY account, questionnaire or Dr Green client exists. SA tenants and document type ID only; passports, licences and non-SA tenants untouched. A valid number is forwarded space-stripped. Dr Green's own SA_ID_INVALID coming back through the proxy is mapped to the same 400 and recorded as UPLOAD_FAILED with the customer copy, which the dashboard now shows beside the re-upload card (kyc-check surfaces only allow-listed copy). - All three upload components validate on blur and submit for the ID option, show the copy on the number field, label it 'South African ID', and route a server SA_ID_INVALID to the field instead of the banner. - X-DRG-Client: budstacks/ on every Dr Green call (callDrGreenAPI and the multipart upload). Outside every signed payload — tests verify each signature against the payload Dr Green reconstructs. package.json gains a version for it; APP_VERSION overrides. - The KYC client payload builder is lifted to lib/drgreen/kyc-client-payload.ts (behaviour-preserving, unit-tested) so the submit route stays under the 800-line lint ceiling. - Super-admin manual: SA ID-upload tenant checklist — the Dr Green key must list the storefront host for rejection emails to land on /dashboard (BS-205). --- docs/guides/SUPER_ADMIN_MANUAL.md | 10 + nextjs_space/app/actions/kyc-check.ts | 32 ++- .../app/api/consultation/submit/route.ts | 158 ++++-------- .../store/[slug]/verify/id-document/route.ts | 43 +++- .../app/store/[slug]/dashboard/page.tsx | 8 + .../consultation/id-upload-form.tsx | 11 + .../consultation/steps/id-upload-step.tsx | 58 ++++- .../components/shop/IdDocumentUpload.tsx | 79 +++++- .../components/shop/ReUploadIdDocument.tsx | 48 +++- nextjs_space/lib/drgreen-identity.ts | 7 +- nextjs_space/lib/drgreen/client-version.ts | 38 +++ .../lib/drgreen/drgreen-api-client.ts | 9 +- .../lib/drgreen/kyc-client-payload.ts | 162 ++++++++++++ .../verification/__tests__/sa-id-vectors.json | 198 +++++++++++++++ .../lib/verification/id-document-errors.ts | 19 ++ nextjs_space/lib/verification/sa-id-schema.ts | 82 ++++++ nextjs_space/lib/verification/sa-id.ts | 123 +++++++++ nextjs_space/package.json | 1 + .../consultation-submit-ownership.test.ts | 1 + .../unit/consultation-submit-sa-id.test.ts | 233 ++++++++++++++++++ .../tests/unit/drgreen-client-header.test.ts | 164 ++++++++++++ .../tests/unit/kyc-client-payload.test.ts | 96 ++++++++ nextjs_space/tests/unit/sa-id.test.ts | 108 ++++++++ .../unit/verify-id-document-route.test.ts | 95 +++++++ 24 files changed, 1642 insertions(+), 141 deletions(-) create mode 100644 nextjs_space/lib/drgreen/client-version.ts create mode 100644 nextjs_space/lib/drgreen/kyc-client-payload.ts create mode 100644 nextjs_space/lib/verification/__tests__/sa-id-vectors.json create mode 100644 nextjs_space/lib/verification/id-document-errors.ts create mode 100644 nextjs_space/lib/verification/sa-id-schema.ts create mode 100644 nextjs_space/lib/verification/sa-id.ts create mode 100644 nextjs_space/tests/unit/consultation-submit-sa-id.test.ts create mode 100644 nextjs_space/tests/unit/drgreen-client-header.test.ts create mode 100644 nextjs_space/tests/unit/kyc-client-payload.test.ts create mode 100644 nextjs_space/tests/unit/sa-id.test.ts diff --git a/docs/guides/SUPER_ADMIN_MANUAL.md b/docs/guides/SUPER_ADMIN_MANUAL.md index 536f3c11..24a99947 100644 --- a/docs/guides/SUPER_ADMIN_MANUAL.md +++ b/docs/guides/SUPER_ADMIN_MANUAL.md @@ -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: `.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:///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 diff --git a/nextjs_space/app/actions/kyc-check.ts b/nextjs_space/app/actions/kyc-check.ts index 6a23f656..31cc46e3 100644 --- a/nextjs_space/app/actions/kyc-check.ts +++ b/nextjs_space/app/actions/kyc-check.ts @@ -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 = { @@ -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 { + 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 { @@ -64,7 +79,7 @@ export async function checkUserKycStatus(): Promise { tenantId: tenantId, email: { equals: clerkUser.email, mode: 'insensitive' } }, - select: { id: true, idDocumentStatus: true } + select: { id: true, idDocumentStatus: true, idDocumentError: true } }); if (questionnaire) { @@ -73,6 +88,10 @@ export async function checkUserKycStatus(): Promise { kycVerified: false, status: "PENDING", idDocumentStatus: questionnaire.idDocumentStatus ?? null, + ...uploadFailureReason( + questionnaire.idDocumentStatus, + questionnaire.idDocumentError, + ), }; } @@ -108,9 +127,15 @@ export async function checkUserKycStatus(): Promise { { 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 { @@ -261,6 +286,7 @@ export async function checkUserKycStatus(): Promise { status: "REJECTED", message: client.rejectionNote || undefined, idDocumentStatus, + ...uploadFailureReason(idDocumentStatus, idDocumentError), verificationType: narrowVerificationType(client.verificationType), }; } @@ -275,6 +301,7 @@ export async function checkUserKycStatus(): Promise { kycVerified: isVerified, status, idDocumentStatus: isVerified ? null : idDocumentStatus, + ...uploadFailureReason(isVerified ? null : idDocumentStatus, idDocumentError), verificationType: narrowVerificationType(client.verificationType), }; } catch (configOrApiError) { @@ -286,6 +313,7 @@ export async function checkUserKycStatus(): Promise { status: "API_ERROR", message: `Dr Green API error: ${errMsg}`, idDocumentStatus, + ...uploadFailureReason(idDocumentStatus, idDocumentError), }; } diff --git a/nextjs_space/app/api/consultation/submit/route.ts b/nextjs_space/app/api/consultation/submit/route.ts index 9e5b5301..56572650 100644 --- a/nextjs_space/app/api/consultation/submit/route.ts +++ b/nextjs_space/app/api/consultation/submit/route.ts @@ -11,15 +11,21 @@ import { createSaIdClient, uploadIdentityDocument } from "@/lib/drgreen-identity import { recordIdDocumentOutcome } from "@/lib/verification/id-document-status"; import { getTenantVerificationMode, + isSaIdEligibleTenant, isSaIdUploadEnabled, } from "@/lib/verification-mode"; +import { + documentNumberToForward, + hasSaIdInvalidIssue, + saIdDocumentRefinement, + saIdInvalidBody, +} from "@/lib/verification/sa-id-schema"; import { prisma } from "@/lib/db"; -import { mapMedicalConditionsForDrGreen } from '@/lib/drgreen/dr-green-mapping'; +import { buildKycClientPayload } from '@/lib/drgreen/kyc-client-payload'; import crypto from "crypto"; import { z } from "zod"; -import { toAlpha3 as convertToAlpha3CountryCode } from '@/lib/country-codes'; import { checkRateLimit } from '@/lib/security/rate-limit'; import { getTenantFromRequest } from '@/lib/tenant/tenant'; import { resolveTenant } from '@/lib/tenant/tenant-resolver'; @@ -37,6 +43,21 @@ function accountExistsResponse() { }); } +// SA ID-upload (idMode): document sent inline with registration so the +// account + Dr Green client + document are created in one action. +const idDocumentSchema = z.object({ + fileBase64: z.string().min(1), + mimeType: z.string().max(100), + documentType: z.enum(["ID", "PASSPORT", "DRIVING_LICENCE"]), + documentNumber: z.string().trim().min(1).max(100), +}); + +// BS-202: the South African ID rules need the tenant, which is resolved from +// the request AFTER the body is parsed — so the refinement is applied to the +// already-validated `idDocument` object once the tenant is known (below). +const idDocumentSchemaFor = (enforceSaId: boolean) => + idDocumentSchema.superRefine(saIdDocumentRefinement(enforceSaId)); + // SECURITY (C1, C13): Strict whitelist schema — no `.passthrough()`. Every // field that lands in the database or is forwarded to Dr. Green must be // declared here and length-capped. The tenant is resolved server-side from @@ -62,16 +83,8 @@ const consultationSchema = z.object({ // and UNTICKED by default — absent or false records NO consent. marketingConsent: z.boolean().optional(), - // SA ID-upload (idMode): document sent inline with registration so the - // account + Dr Green client + document are created in one action. - idDocument: z - .object({ - fileBase64: z.string().min(1), - mimeType: z.string().max(100), - documentType: z.enum(["ID", "PASSPORT", "DRIVING_LICENCE"]), - documentNumber: z.string().trim().min(1).max(100), - }) - .optional(), + // SA ID-upload (idMode) — see idDocumentSchema above. + idDocument: idDocumentSchema.optional(), // Shipping address addressLine1: z.string().max(300).optional().default(""), @@ -171,6 +184,26 @@ export async function POST(request: NextRequest) { } const tenantId = tenant.id; + // BS-202: refuse an impossible South African ID number before ANY account, + // questionnaire or Dr Green client exists — the customer corrects the + // number and resubmits with nothing to clean up. South African tenants and + // document type ID only; the copy is the one Dr Green and WordPress use. + let idDocumentNumber: string | undefined; + if (body.idDocument) { + const enforceSaId = isSaIdEligibleTenant(tenant); + const idDoc = idDocumentSchemaFor(enforceSaId).safeParse(body.idDocument); + if (!idDoc.success) { + if (hasSaIdInvalidIssue(idDoc.error)) { + return NextResponse.json(saIdInvalidBody(), { status: 400 }); + } + return apiValidationError( + "Invalid document type or number", + "POST /api/consultation/submit", + ); + } + idDocumentNumber = documentNumberToForward(idDoc.data, enforceSaId); + } + // A storefront with no published privacy notice tells visitors exactly that // — so taking a consultation here would collect special-category data with // no Art. 13 notice at all. Checked before ANY account or record is created. @@ -471,7 +504,8 @@ export async function POST(request: NextRequest) { await uploadIdentityDocument({ clientId, documentType: body.idDocument.documentType, - documentNumber: body.idDocument.documentNumber, + // Space-stripped for an SA ID (BS-202); as typed otherwise. + documentNumber: idDocumentNumber ?? body.idDocument.documentNumber, file: Buffer.from(body.idDocument.fileBase64, "base64"), mimeType: body.idDocument.mimeType, config: { apiKey, secretKey }, @@ -501,102 +535,8 @@ export async function POST(request: NextRequest) { } } } else { - // Format date for Dr. Green API (YYYY-MM-DD) - const dobFormatted = body.dateOfBirth - ? new Date(body.dateOfBirth).toISOString().split("T")[0] - : new Date().toISOString().split("T")[0]; - - // Prepare Dr. Green API payload - const drGreenPayload = { - firstName: body.firstName, - lastName: body.lastName, - email: body.email.toLowerCase(), // Dr Green requires lowercase - phoneCode: body.phoneCode.replace(/[^\+\d]/g, ""), // e.g. "+351" - phoneCountryCode: body.countryCode, // e.g. "PT" (2-letter ISO code) - contactNumber: body.phoneNumber.replace(/\D/g, ""), // e.g. "7970433737" (digits only, NO prefix) - - shipping: { - address1: body.addressLine1, - address2: body.addressLine2 || '', - landmark: '', - city: body.city, - state: body.state, - postalCode: body.postalCode, - country: body.country, - countryCode: convertToAlpha3CountryCode(body.countryCode), // Convert PT → PRT - }, - - ...(body.businessType && body.businessName - ? { - clientBusiness: { - businessType: body.businessType, - name: body.businessName, - address1: body.businessAddress1 || "", - address2: body.businessAddress2 || "", - city: body.businessCity || "", - state: body.businessState || "", - postalCode: body.businessPostalCode || "", - country: body.businessCountry || "", - countryCode: body.businessCountryCode || "", - }, - } - : {}), - - medicalRecord: { - dob: dobFormatted, - gender: body.gender, - medicalConditions: mapMedicalConditionsForDrGreen( - body.medicalConditions || [], - ), - // Only include otherMedicalCondition if we have conditions that map to 'other_medical_condition' - ...(body.medicalConditions?.includes("lupus") || - body.medicalConditions?.includes("asthma") || - body.medicalConditions?.includes("glaucoma") || - body.medicalConditions?.includes("other_medical_condition") || - body.medicalConditions?.includes("other") || - body.otherCondition - ? { - otherMedicalCondition: - body.medicalConditions - ?.filter((c: string) => - [ - "lupus", - "asthma", - "glaucoma", - "other_medical_condition", - "other", - ].includes(c), - ) - .map((c: string) => c.charAt(0).toUpperCase() + c.slice(1)) - .join(", ") || - body.otherCondition || - "Other medical condition", - } - : {}), - otherMedicalTreatments: "", - prescribedSupplements: body.prescribedSupplements || "", - - // Medical History - Dr Green uses specific field names - medicalHistory0: body.hasHeartProblems, - medicalHistory1: body.hasCancerTreatment, - medicalHistory2: body.hasImmunosuppressants, - medicalHistory3: body.hasLiverDisease, - medicalHistory4: body.hasPsychiatricHistory, - medicalHistory5: body.hasPsychiatricHistory ? ["depression"] : ["none"], - medicalHistory6: false, // Suicidal history - medicalHistory7: ["none"], // Family history - medicalHistory7Relation: "none", - medicalHistory8: body.hasDrugServices, - medicalHistory9: body.hasAlcoholAbuse, - medicalHistory10: body.hasDrugServices, - medicalHistory11: body.alcoholUnitsPerWeek || "0", - medicalHistory12: body.cannabisReducesMeds, - medicalHistory13: body.cannabisFrequency || "never", - medicalHistory14: body.cannabisFrequency && body.cannabisFrequency !== "never" ? ["vaporizing"] : ["never"], - medicalHistory15: body.cannabisAmountPerDay || "", - medicalHistory16: false, // cannabisReaction - }, - }; + // Prepare Dr. Green API payload — lib/drgreen/kyc-client-payload.ts + const drGreenPayload = buildKycClientPayload(body); // Submit to Dr. Green API via shared client const drGreenResponse = await callDrGreenAPI('/dapp/clients', { diff --git a/nextjs_space/app/api/store/[slug]/verify/id-document/route.ts b/nextjs_space/app/api/store/[slug]/verify/id-document/route.ts index 53773e12..234f953b 100644 --- a/nextjs_space/app/api/store/[slug]/verify/id-document/route.ts +++ b/nextjs_space/app/api/store/[slug]/verify/id-document/route.ts @@ -15,19 +15,35 @@ import { } from "@/lib/drgreen-identity"; import { getTenantVerificationMode, + isSaIdEligibleTenant, isSaIdUploadEnabled, } from "@/lib/verification-mode"; import { recordIdDocumentOutcome } from "@/lib/verification/id-document-status"; +import { SA_ID_INVALID_MESSAGE } from "@/lib/verification/sa-id"; +import { + documentNumberToForward, + hasSaIdInvalidIssue, + isSaIdInvalidUpstreamError, + saIdDocumentRefinement, + saIdInvalidBody, +} from "@/lib/verification/sa-id-schema"; // Node runtime is REQUIRED: drgreen-identity signs over a Node Buffer, whose // JSON.stringify form differs from a Uint8Array/Blob. Edge would break signing. export const runtime = "nodejs"; -const metaSchema = z.object({ +const baseMetaSchema = z.object({ documentType: z.enum(["ID", "PASSPORT", "DRIVING_LICENCE"]), documentNumber: z.string().trim().min(1).max(100), }); +// BS-202: on a South African tenant an ID-type upload must carry a number that +// can exist — checked here, before anything is sent, with the copy Dr Green +// and the WordPress plugin use. Passport and driving-licence numbers, and +// every non-SA tenant, are untouched. +const metaSchemaFor = (enforceSaId: boolean) => + baseMetaSchema.superRefine(saIdDocumentRefinement(enforceSaId)); + /** * Forward a customer's ID document to Dr Green for the SA ID-upload path. * Budstacks is a pure pass-through: it validates, forwards, and stores NOTHING @@ -100,11 +116,19 @@ export const POST = withAuth(async (request, { user }, { slug }) => { ); } - const meta = metaSchema.safeParse({ + // The tenant's country decides whether the SA ID rules apply — never the + // customer's address (PRD §6). The gate above already limits this route to + // ZA tenants; the flag keeps the rule explicit and testable. + const enforceSaId = isSaIdEligibleTenant(tenant); + const meta = metaSchemaFor(enforceSaId).safeParse({ documentType: form.get("documentType"), documentNumber: form.get("documentNumber"), }); if (!meta.success) { + if (hasSaIdInvalidIssue(meta.error)) { + // Nothing was attempted upstream, so no UPLOAD_FAILED outcome either. + return NextResponse.json(saIdInvalidBody(), { status: 400 }); + } return NextResponse.json( { error: "Invalid document type or number" }, { status: 400 }, @@ -118,13 +142,26 @@ export const POST = withAuth(async (request, { user }, { slug }) => { await uploadIdentityDocument({ clientId: dbUser.drGreenClientId, documentType: meta.data.documentType as IdentityDocumentType, - documentNumber: meta.data.documentNumber, + documentNumber: documentNumberToForward(meta.data, enforceSaId), file: fileBuffer, mimeType, config: { apiKey: config.apiKey, secretKey: config.secretKey }, baseUrl: config.apiUrl, }); } catch (uploadError) { + // BS-204: Dr Green's own strict check refused the number (only when the + // two validators drift — ours ran first). Nothing was stored upstream; + // record the customer-facing reason so the dashboard shows it beside the + // re-upload card, and answer exactly as the local check would have. + if (isSaIdInvalidUpstreamError(uploadError)) { + await recordIdDocumentOutcome({ + tenantId: tenant.id, + email, + outcome: "UPLOAD_FAILED", + error: new Error(SA_ID_INVALID_MESSAGE), + }); + return NextResponse.json(saIdInvalidBody(), { status: 400 }); + } // PRD-220 Part B: keep the outcome flag truthful so the dashboard CTA // and the tenant-admin badge stay in sync with reality. await recordIdDocumentOutcome({ diff --git a/nextjs_space/app/store/[slug]/dashboard/page.tsx b/nextjs_space/app/store/[slug]/dashboard/page.tsx index f38c87e2..249cf523 100644 --- a/nextjs_space/app/store/[slug]/dashboard/page.tsx +++ b/nextjs_space/app/store/[slug]/dashboard/page.tsx @@ -181,6 +181,14 @@ export default function DashboardPage() { Your account was created, but the ID upload didn't go through — verification can't start until we have it. Please upload it again below.

+ {/* BS-204: the reason, when it is copy written for customers + (an ID number Dr Green refused); raw errors are never shown. */} + {kycStatus?.idDocumentError && ( +

+ Reason:{" "} + {kycStatus.idDocumentError} +

+ )} checkUserKycStatus().then(setKycStatus)} diff --git a/nextjs_space/components/consultation/id-upload-form.tsx b/nextjs_space/components/consultation/id-upload-form.tsx index b4361fac..c167a4b5 100644 --- a/nextjs_space/components/consultation/id-upload-form.tsx +++ b/nextjs_space/components/consultation/id-upload-form.tsx @@ -7,6 +7,7 @@ import { ContactDetailsStep } from "./steps/contact-details-step"; import { AddressStep } from "./steps/address-step"; import { IdUploadStep, type IdDocumentType } from "./steps/id-upload-step"; import { toast } from "@/components/ui/sonner"; +import { SA_ID_INVALID_CODE, SA_ID_INVALID_MESSAGE } from "@/lib/verification/sa-id"; import { useRouter } from "next/navigation"; import type { ConsultationFormData } from "./consultation-form-types"; @@ -44,6 +45,8 @@ export function IdUploadForm({ tenantSlug }: IdUploadFormProps) { const [idFile, setIdFile] = useState(null); const [documentType, setDocumentType] = useState("ID"); const [documentNumber, setDocumentNumber] = useState(""); + // BS-203: an SA_ID_INVALID answer from the server lands on the number field. + const [documentNumberError, setDocumentNumberError] = useState(null); const [formData, setFormData] = useState({ firstName: "", @@ -145,6 +148,12 @@ export function IdUploadForm({ tenantSlug }: IdUploadFormProps) { const result = await response.json(); if (!response.ok) { + if (result?.code === SA_ID_INVALID_CODE) { + // The number, not the upload, is the problem: show it on the field + // and keep the customer on this step — no generic failure toast. + setDocumentNumberError(result.error || SA_ID_INVALID_MESSAGE); + return; + } throw new Error(result.error || "Registration failed"); } @@ -189,7 +198,9 @@ export function IdUploadForm({ tenantSlug }: IdUploadFormProps) { documentType={documentType} documentNumber={documentNumber} onFileChange={setIdFile} + documentNumberError={documentNumberError} onUpdate={(d) => { + setDocumentNumberError(null); if (d.documentType !== undefined) setDocumentType(d.documentType); if (d.documentNumber !== undefined) setDocumentNumber(d.documentNumber); diff --git a/nextjs_space/components/consultation/steps/id-upload-step.tsx b/nextjs_space/components/consultation/steps/id-upload-step.tsx index 67a85ddb..bd1a4ee2 100644 --- a/nextjs_space/components/consultation/steps/id-upload-step.tsx +++ b/nextjs_space/components/consultation/steps/id-upload-step.tsx @@ -12,6 +12,7 @@ import { SelectValue, } from "@/components/ui/select"; import { UploadCloud, FileCheck2 } from "lucide-react"; +import { saIdFieldError } from "@/lib/verification/sa-id"; export type IdDocumentType = "ID" | "PASSPORT" | "DRIVING_LICENCE"; @@ -30,6 +31,17 @@ interface IdUploadStepProps { onSubmit: () => void; onBack: () => void; isSubmitting: boolean; + /** + * BS-203: an SA_ID_INVALID answer from the submit route (or from Dr Green + * through it) — shown on the number field, never as a generic banner. + */ + documentNumberError?: string | null; + /** + * Apply the South African ID rules to the ID option. This step only renders + * on ID-upload tenants, which are South African by construction + * (lib/verification-mode.ts), so the rules are on unless a caller says not. + */ + validateSaId?: boolean; } export function IdUploadStep({ @@ -41,10 +53,16 @@ export function IdUploadStep({ onSubmit, onBack, isSubmitting, + documentNumberError = null, + validateSaId = true, }: IdUploadStepProps) { const [error, setError] = useState(null); + const [numberError, setNumberError] = useState(null); const inputRef = useRef(null); + const idOptionLabel = validateSaId ? "South African ID" : "National ID"; + const fieldError = numberError ?? documentNumberError; + const pick = (e: React.ChangeEvent) => { const f = e.target.files?.[0] ?? null; setError(null); @@ -55,10 +73,22 @@ export function IdUploadStep({ onFileChange(f); }; + // BS-203: the shared rules, on blur and on submit, only for the ID option. + const checkNumber = (): boolean => { + const message = saIdFieldError({ + documentType, + documentNumber, + enforce: validateSaId, + }); + setNumberError(message); + return message === null; + }; + const submit = () => { if (!file) return setError("Please upload a photo of your ID."); if (!documentNumber.trim()) return setError("Please enter your document number."); + if (!checkNumber()) return; setError(null); onSubmit(); }; @@ -70,9 +100,9 @@ export function IdUploadStep({ Verify your identity

- Upload a clear photo of a valid government ID (National - ID, passport or driving licence). It must be your actual ID - document — selfies or other photos will be rejected. + Upload a clear photo of a valid government ID ( + {idOptionLabel}, passport or driving licence). It must be your actual + ID document — selfies or other photos will be rejected. An admin reviews it to verify your account — no medical consultation needed.

@@ -82,13 +112,16 @@ export function IdUploadStep({ onUpdate({ documentNumber: e.target.value })} + onChange={(e) => { + setNumberError(null); + onUpdate({ documentNumber: e.target.value }); + }} + onBlur={checkNumber} + inputMode={documentType === "ID" && validateSaId ? "numeric" : "text"} + aria-invalid={fieldError ? true : undefined} + aria-describedby={fieldError ? "documentNumber-error" : undefined} + className={fieldError ? "border-red-500" : ""} placeholder="As shown on your document" /> + {fieldError && ( +

+ {fieldError} +

+ )}
diff --git a/nextjs_space/components/shop/IdDocumentUpload.tsx b/nextjs_space/components/shop/IdDocumentUpload.tsx index ebb54be9..065368d1 100644 --- a/nextjs_space/components/shop/IdDocumentUpload.tsx +++ b/nextjs_space/components/shop/IdDocumentUpload.tsx @@ -2,26 +2,53 @@ import { useState } from "react"; import { Loader2, Upload, CheckCircle2, AlertCircle } from "lucide-react"; +import { SA_ID_INVALID_CODE, saIdFieldError } from "@/lib/verification/sa-id"; // Mirror the server limits (drgreen-identity.ts / Dr Green identity.service.ts) // for fast client-side feedback; the server remains the source of truth. const ALLOWED_MIME = ["image/jpeg", "image/png", "application/pdf"]; const MAX_BYTES = 10 * 1024 * 1024; -const DOC_TYPES = [ - { value: "ID", label: "National ID" }, - { value: "PASSPORT", label: "Passport" }, - { value: "DRIVING_LICENCE", label: "Driving licence" }, -] as const; - type UploadState = "idle" | "submitting" | "pending" | "error"; -export function IdDocumentUpload({ slug }: { slug: string }) { +/** + * Stand-alone ID upload card (posts to the same pass-through route as the + * dashboard re-upload). BS-203: South African ID rules inline for the ID + * option, and an SA_ID_INVALID answer from the route lands on the number + * field rather than the failed-upload banner. Only ever mounted on ID-upload + * tenants, which are South African by construction, so `validateSaId` + * defaults on. + */ +export function IdDocumentUpload({ + slug, + validateSaId = true, +}: { + slug: string; + validateSaId?: boolean; +}) { const [file, setFile] = useState(null); const [documentType, setDocumentType] = useState("ID"); const [documentNumber, setDocumentNumber] = useState(""); const [state, setState] = useState("idle"); const [error, setError] = useState(null); + const [numberError, setNumberError] = useState(null); + + const idOptionLabel = validateSaId ? "South African ID" : "National ID"; + const docTypes = [ + { value: "ID", label: idOptionLabel }, + { value: "PASSPORT", label: "Passport" }, + { value: "DRIVING_LICENCE", label: "Driving licence" }, + ]; + + const checkNumber = (): boolean => { + const message = saIdFieldError({ + documentType, + documentNumber, + enforce: validateSaId, + }); + setNumberError(message); + return message === null; + }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -34,6 +61,7 @@ export function IdDocumentUpload({ slug }: { slug: string }) { return setError("File must be 10MB or smaller."); if (!documentNumber.trim()) return setError("Please enter the document number."); + if (!checkNumber()) return; setState("submitting"); try { @@ -47,7 +75,14 @@ export function IdDocumentUpload({ slug }: { slug: string }) { body: form, }); const data = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(data?.error || "Upload failed. Please try again."); + if (!res.ok) { + if (data?.code === SA_ID_INVALID_CODE) { + setNumberError(data.error); + setState("idle"); + return; + } + throw new Error(data?.error || "Upload failed. Please try again."); + } setState("pending"); } catch (err: any) { @@ -96,11 +131,14 @@ export function IdDocumentUpload({ slug }: { slug: string }) { setDocumentNumber(e.target.value)} + onChange={(e) => { + setNumberError(null); + setDocumentNumber(e.target.value); + }} + onBlur={checkNumber} + inputMode={documentType === "ID" && validateSaId ? "numeric" : "text"} + aria-invalid={numberError ? true : undefined} + aria-describedby={numberError ? "id-upload-doc-number-error" : undefined} maxLength={100} disabled={submitting} placeholder="As printed on the document" - className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm" + className={`w-full rounded-lg border px-3 py-2 text-sm ${ + numberError ? "border-red-500" : "border-slate-300" + }`} /> + {numberError && ( +

+ {numberError} +

+ )}
diff --git a/nextjs_space/components/shop/ReUploadIdDocument.tsx b/nextjs_space/components/shop/ReUploadIdDocument.tsx index 4c48df3a..ccd13459 100644 --- a/nextjs_space/components/shop/ReUploadIdDocument.tsx +++ b/nextjs_space/components/shop/ReUploadIdDocument.tsx @@ -13,6 +13,7 @@ import { } from "@/components/ui/select"; import { toast } from "@/components/ui/sonner"; import { UploadCloud, FileCheck2 } from "lucide-react"; +import { SA_ID_INVALID_CODE, saIdFieldError } from "@/lib/verification/sa-id"; type IdDocumentType = "ID" | "PASSPORT" | "DRIVING_LICENCE"; @@ -26,13 +27,20 @@ const MAX_BYTES = 10 * 1024 * 1024; // 10 MB * PRD-220 Part B — dashboard re-upload for a failed inline ID upload. * Posts multipart to /api/store/[slug]/verify/id-document (the existing * pass-through endpoint; nothing about the document is stored on our side). + * + * BS-203: the South African ID rules run inline (blur + submit) for the ID + * option, and an SA_ID_INVALID answer from the route lands on the number + * field. This card only renders on ID-upload tenants, which are South African + * by construction, so `validateSaId` defaults on. */ export function ReUploadIdDocument({ slug, onUploaded, + validateSaId = true, }: { slug: string; onUploaded?: () => void; + validateSaId?: boolean; }) { const inputRef = useRef(null); const [file, setFile] = useState(null); @@ -40,6 +48,9 @@ export function ReUploadIdDocument({ const [documentNumber, setDocumentNumber] = useState(""); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); + const [numberError, setNumberError] = useState(null); + + const idOptionLabel = validateSaId ? "South African ID" : "National ID"; const pick = (e: React.ChangeEvent) => { const f = e.target.files?.[0] ?? null; @@ -52,9 +63,20 @@ export function ReUploadIdDocument({ setFile(f); }; + const checkNumber = (): boolean => { + const message = saIdFieldError({ + documentType, + documentNumber, + enforce: validateSaId, + }); + setNumberError(message); + return message === null; + }; + const submit = async () => { if (!file) return setError("Please choose your ID document."); if (!documentNumber.trim()) return setError("Please enter your document number."); + if (!checkNumber()) return; setError(null); setSubmitting(true); try { @@ -69,6 +91,10 @@ export function ReUploadIdDocument({ }); if (!res.ok) { const body = await res.json().catch(() => null); + if (body?.code === SA_ID_INVALID_CODE) { + setNumberError(body.error); + return; + } throw new Error(body?.error || "Upload failed. Please try again."); } @@ -88,13 +114,16 @@ export function ReUploadIdDocument({ setDocumentNumber(e.target.value)} + onChange={(e) => { + setNumberError(null); + setDocumentNumber(e.target.value); + }} + onBlur={checkNumber} + inputMode={documentType === "ID" && validateSaId ? "numeric" : "text"} + aria-invalid={numberError ? true : undefined} + aria-describedby={numberError ? "reupload-doc-number-error" : undefined} + className={numberError ? "border-red-500" : ""} placeholder="As shown on your document" /> + {numberError && ( +

+ {numberError} +

+ )}
diff --git a/nextjs_space/lib/drgreen-identity.ts b/nextjs_space/lib/drgreen-identity.ts index 42eb60ff..a9a514f5 100644 --- a/nextjs_space/lib/drgreen-identity.ts +++ b/nextjs_space/lib/drgreen-identity.ts @@ -16,6 +16,7 @@ */ import { generateDrGreenSignature, callDrGreenAPI } from './drgreen/drgreen-api-client'; import { DR_GREEN_SA_COUNTRY_CODE } from './verification-mode'; +import { withDrgClientHeader } from './drgreen/client-version'; export type IdentityDocumentType = 'ID' | 'PASSPORT' | 'DRIVING_LICENCE'; @@ -142,10 +143,12 @@ export async function uploadIdentityDocument( // Do NOT set Content-Type — fetch derives the multipart boundary itself. const response = await fetch(url, { method: 'POST', - headers: { + // X-DRG-Client (BS-204) is a plain header: it never enters the signed + // multipart reconstruction above, so the byte-exact contract is untouched. + headers: withDrgClientHeader({ 'x-auth-apikey': config.apiKey, 'x-auth-signature': signature, - }, + }), body: form, cache: 'no-store', }); diff --git a/nextjs_space/lib/drgreen/client-version.ts b/nextjs_space/lib/drgreen/client-version.ts new file mode 100644 index 00000000..1818029c --- /dev/null +++ b/nextjs_space/lib/drgreen/client-version.ts @@ -0,0 +1,38 @@ +/** + * `X-DRG-Client` — the capability header every Dr Green call carries (BS-204). + * + * Dr Green applies strict validation (Phase 2 US-202) only to callers that + * declare themselves; legacy WordPress plugins send nothing and get soft mode, + * so an old storefront never breaks. The header sits OUTSIDE every signed + * payload — JSON bodies, query strings and the multipart reconstruction alike + * — so adding it changes no signature. Dr Green also records it per API key + * as version telemetry (US-210), which is why the value is sanitised here to + * the charset it stores (`[A-Za-z0-9./_-]`, at most 64 characters). + */ +import packageJson from "../../package.json"; + +export const DRG_CLIENT_HEADER = "X-DRG-Client"; +export const DRG_CLIENT_NAME = "budstacks"; + +const DRG_CLIENT_MAX_LENGTH = 64; +const UNSAFE_CHARS = /[^A-Za-z0-9./_-]/g; +const FALLBACK_VERSION = "0.0.0"; + +/** APP_VERSION when set (a release override), else the package.json version. */ +export function resolveAppVersion(env: NodeJS.ProcessEnv = process.env): string { + const fromEnv = env.APP_VERSION?.trim(); + if (fromEnv) return fromEnv; + return packageJson.version || FALLBACK_VERSION; +} + +export function drgClientHeaderValue(env: NodeJS.ProcessEnv = process.env): string { + const raw = `${DRG_CLIENT_NAME}/${resolveAppVersion(env)}`; + return raw.replace(UNSAFE_CHARS, "-").slice(0, DRG_CLIENT_MAX_LENGTH); +} + +/** A new headers object with the capability header added; input untouched. */ +export function withDrgClientHeader( + headers: Record, +): Record { + return { [DRG_CLIENT_HEADER]: drgClientHeaderValue(), ...headers }; +} diff --git a/nextjs_space/lib/drgreen/drgreen-api-client.ts b/nextjs_space/lib/drgreen/drgreen-api-client.ts index 0b111c0c..5555a225 100644 --- a/nextjs_space/lib/drgreen/drgreen-api-client.ts +++ b/nextjs_space/lib/drgreen/drgreen-api-client.ts @@ -8,6 +8,7 @@ import * as secp256k1 from '@noble/secp256k1'; import { sha256 } from '@noble/hashes/sha256'; import { hmac } from '@noble/hashes/hmac'; import { logger } from '@/lib/logger'; +import { withDrgClientHeader } from '@/lib/drgreen/client-version'; // Required for noble secp256k1 signing — copied from template line 10-14 secp256k1.etc.hmacSha256Sync = (key: Uint8Array, ...messages: Uint8Array[]) => { @@ -261,12 +262,14 @@ export async function callDrGreenAPI( ? (typeof body === 'string' ? body : JSON.stringify(body)) : ''; - // Headers — same as template: Content-Type + x-auth-apikey + x-auth-signature - const requestHeaders: Record = { + // Headers — same as template: Content-Type + x-auth-apikey + x-auth-signature, + // plus X-DRG-Client (BS-204). The capability header is outside the signed + // payload, so the signature below is computed exactly as before. + const requestHeaders: Record = withDrgClientHeader({ 'Content-Type': 'application/json', 'x-auth-apikey': apiKey, ...headers, - }; + }); // What to sign — matches template drGreenRequestBody / drGreenRequestGet let signaturePayload = ''; diff --git a/nextjs_space/lib/drgreen/kyc-client-payload.ts b/nextjs_space/lib/drgreen/kyc-client-payload.ts new file mode 100644 index 00000000..086e94ae --- /dev/null +++ b/nextjs_space/lib/drgreen/kyc-client-payload.ts @@ -0,0 +1,162 @@ +/** + * The Dr Green `POST /dapp/clients` payload for a KYC (First-AML) + * registration, lifted out of app/api/consultation/submit/route.ts so that + * route stays under the 800-line lint ceiling and the mapping is testable on + * its own. Behaviour-preserving: field names, defaults and the medicalHistory* + * mapping are exactly what the route sent before the lift. + * + * The input is structural (what the builder reads), not the route's zod type, + * so this module never imports the route and no import cycle can form. + */ +import { mapMedicalConditionsForDrGreen } from "@/lib/drgreen/dr-green-mapping"; +import { toAlpha3 } from "@/lib/country-codes"; + +const OTHER_CONDITION_KEYS = [ + "lupus", + "asthma", + "glaucoma", + "other_medical_condition", + "other", +]; + +export interface KycRegistrationInput { + firstName: string; + lastName: string; + email: string; + phoneCode: string; + phoneNumber: string; + countryCode: string; + dateOfBirth?: string | null; + gender: string; + + addressLine1: string; + addressLine2?: string; + city: string; + state: string; + postalCode: string; + country: string; + + businessType?: string; + businessName?: string; + businessAddress1?: string; + businessAddress2?: string; + businessCity?: string; + businessState?: string; + businessPostalCode?: string; + businessCountry?: string; + businessCountryCode?: string; + + medicalConditions?: string[]; + otherCondition?: string; + prescribedSupplements?: string; + + hasHeartProblems: boolean; + hasCancerTreatment: boolean; + hasImmunosuppressants: boolean; + hasLiverDisease: boolean; + hasPsychiatricHistory: boolean; + hasAlcoholAbuse: boolean; + hasDrugServices: boolean; + alcoholUnitsPerWeek?: string; + cannabisReducesMeds: boolean; + cannabisFrequency?: string; + cannabisAmountPerDay?: string; +} + +/** YYYY-MM-DD; today when the form sent nothing (unchanged legacy default). */ +function formatDateOfBirth(dateOfBirth: string | null | undefined): string { + const date = dateOfBirth ? new Date(dateOfBirth) : new Date(); + return date.toISOString().split("T")[0]; +} + +// Only present when a condition maps to Dr Green's free-text slot, or the +// customer typed one. Capitalised list of the mapped keys wins over the free +// text, which wins over the fixed fallback — the order the route always used. +function otherMedicalCondition( + body: KycRegistrationInput, +): { otherMedicalCondition: string } | Record { + const conditions = body.medicalConditions ?? []; + const mapped = conditions.filter((c) => OTHER_CONDITION_KEYS.includes(c)); + if (mapped.length === 0 && !body.otherCondition) return {}; + return { + otherMedicalCondition: + mapped.map((c) => c.charAt(0).toUpperCase() + c.slice(1)).join(", ") || + body.otherCondition || + "Other medical condition", + }; +} + +function clientBusiness( + body: KycRegistrationInput, +): { clientBusiness: Record } | Record { + if (!body.businessType || !body.businessName) return {}; + return { + clientBusiness: { + businessType: body.businessType, + name: body.businessName, + address1: body.businessAddress1 || "", + address2: body.businessAddress2 || "", + city: body.businessCity || "", + state: body.businessState || "", + postalCode: body.businessPostalCode || "", + country: body.businessCountry || "", + countryCode: body.businessCountryCode || "", + }, + }; +} + +export function buildKycClientPayload(body: KycRegistrationInput) { + return { + firstName: body.firstName, + lastName: body.lastName, + email: body.email.toLowerCase(), // Dr Green requires lowercase + phoneCode: body.phoneCode.replace(/[^\+\d]/g, ""), // e.g. "+351" + phoneCountryCode: body.countryCode, // e.g. "PT" (2-letter ISO code) + contactNumber: body.phoneNumber.replace(/\D/g, ""), // digits only, NO prefix + + shipping: { + address1: body.addressLine1, + address2: body.addressLine2 || "", + landmark: "", + city: body.city, + state: body.state, + postalCode: body.postalCode, + country: body.country, + countryCode: toAlpha3(body.countryCode), // Convert PT → PRT + }, + + ...clientBusiness(body), + + medicalRecord: { + dob: formatDateOfBirth(body.dateOfBirth), + gender: body.gender, + medicalConditions: mapMedicalConditionsForDrGreen(body.medicalConditions || []), + ...otherMedicalCondition(body), + otherMedicalTreatments: "", + prescribedSupplements: body.prescribedSupplements || "", + + // Medical History - Dr Green uses specific field names + medicalHistory0: body.hasHeartProblems, + medicalHistory1: body.hasCancerTreatment, + medicalHistory2: body.hasImmunosuppressants, + medicalHistory3: body.hasLiverDisease, + medicalHistory4: body.hasPsychiatricHistory, + medicalHistory5: body.hasPsychiatricHistory ? ["depression"] : ["none"], + medicalHistory6: false, // Suicidal history + medicalHistory7: ["none"], // Family history + medicalHistory7Relation: "none", + medicalHistory8: body.hasDrugServices, + medicalHistory9: body.hasAlcoholAbuse, + medicalHistory10: body.hasDrugServices, + medicalHistory11: body.alcoholUnitsPerWeek || "0", + medicalHistory12: body.cannabisReducesMeds, + medicalHistory13: body.cannabisFrequency || "never", + medicalHistory14: + body.cannabisFrequency && body.cannabisFrequency !== "never" + ? ["vaporizing"] + : ["never"], + medicalHistory15: body.cannabisAmountPerDay || "", + medicalHistory16: false, // cannabisReaction + }, + }; +} diff --git a/nextjs_space/lib/verification/__tests__/sa-id-vectors.json b/nextjs_space/lib/verification/__tests__/sa-id-vectors.json new file mode 100644 index 00000000..83c624f4 --- /dev/null +++ b/nextjs_space/lib/verification/__tests__/sa-id-vectors.json @@ -0,0 +1,198 @@ +{ + "name": "South African ID number \u2014 shared test vectors", + "version": 1, + "updatedAt": "2026-09-18", + "owner": "Dr Green Phase 2 (US-201/203/204); BudStacks BS-201", + "canonicalCopies": [ + "dr-green-backend/docs/design/sa-id-test-vectors.json", + "budstack-saas/nextjs_space/lib/verification/__tests__/sa-id-vectors.json", + "drg-wp-plugins/drg-id-upload (1.3.0) test fixture" + ], + "rules": [ + "Strip all whitespace before every check.", + "length: exactly 13 characters after stripping.", + "digits: all 13 characters are ASCII digits.", + "date: digits 1-6 (YYMMDD) form a real calendar date in the 1900s or the 2000s that is not after today; either century that yields such a date is accepted (age policy is out of scope).", + "citizenship: digit 11 is 0, 1 or 2.", + "checksum: Luhn over all 13 digits (digit 13 is the check digit).", + "Digit 12 is NOT enforced (legacy values exist).", + "Checks run in this order and the first failure is the reason." + ], + "synthetic": "Every number here was generated by choosing a date/sequence and computing the Luhn check digit. None belongs to a real person.", + "vectors": [ + { + "input": "9001015009086", + "valid": true, + "normalised": "9001015009086", + "note": "1990-01-01, citizen, digit 12 = 8" + }, + { + "input": "0002295000182", + "valid": true, + "normalised": "0002295000182", + "note": "2000-02-29: real only in 2000 (1900 is not a leap year)" + }, + { + "input": "8506150123196", + "valid": true, + "normalised": "8506150123196", + "note": "1985-06-15, permanent resident (digit 11 = 1), digit 12 = 9" + }, + { + "input": "1207310044276", + "valid": true, + "normalised": "1207310044276", + "note": "2012-07-31, legacy digit 12 = 7 (digit 12 is not enforced)" + }, + { + "input": "9912317777289", + "valid": true, + "normalised": "9912317777289", + "note": "1999-12-31, digit 11 = 2" + }, + { + "input": "2612315000083", + "valid": true, + "normalised": "2612315000083", + "note": "YY=26 is not in the future as 1926-12-31; any century yielding a non-future date is accepted" + }, + { + "input": "0402291234084", + "valid": true, + "normalised": "0402291234084", + "note": "2004-02-29 is a real date" + }, + { + "input": "900101 5009 086", + "valid": true, + "normalised": "9001015009086", + "note": "internal spaces are stripped before every check" + }, + { + "input": " 8506150123196 ", + "valid": true, + "normalised": "8506150123196", + "note": "surrounding whitespace is stripped" + }, + { + "input": "", + "valid": false, + "reason": "length", + "note": "empty" + }, + { + "input": "900101500908", + "valid": false, + "reason": "length", + "note": "12 digits" + }, + { + "input": "90010150090861", + "valid": false, + "reason": "length", + "note": "14 digits" + }, + { + "input": "90010 15009", + "valid": false, + "reason": "length", + "note": "spaces stripped first: 10 digits" + }, + { + "input": "90O1015009087", + "valid": false, + "reason": "digits", + "note": "letter O in place of zero, 13 characters" + }, + { + "input": "9001015009O87", + "valid": false, + "reason": "digits", + "note": "letter inside, 13 characters" + }, + { + "input": "900101-500908", + "valid": false, + "reason": "digits", + "note": "hyphen counted as a character" + }, + { + "input": "9013015009081", + "valid": false, + "reason": "date", + "note": "month 13" + }, + { + "input": "9001325009081", + "valid": false, + "reason": "date", + "note": "day 32" + }, + { + "input": "9002305009083", + "valid": false, + "reason": "date", + "note": "30 February" + }, + { + "input": "0102295009082", + "valid": false, + "reason": "date", + "note": "2001-02-29 and 1901-02-29 are both non-leap" + }, + { + "input": "9000005009080", + "valid": false, + "reason": "date", + "note": "month 00" + }, + { + "input": "9001005009088", + "valid": false, + "reason": "date", + "note": "day 00" + }, + { + "input": "9001015009383", + "valid": false, + "reason": "citizenship", + "note": "digit 11 = 3" + }, + { + "input": "9001015009987", + "valid": false, + "reason": "citizenship", + "note": "digit 11 = 9" + }, + { + "input": "9001015009087", + "valid": false, + "reason": "checksum", + "note": "check digit off by one" + }, + { + "input": "8506150123197", + "valid": false, + "reason": "checksum", + "note": "check digit off by one, digit 12 = 9" + }, + { + "input": "90O10150090", + "valid": false, + "reason": "length", + "note": "length is checked before digits" + }, + { + "input": "9013015009388", + "valid": false, + "reason": "date", + "note": "date is checked before citizenship" + }, + { + "input": "9001015009384", + "valid": false, + "reason": "citizenship", + "note": "citizenship is checked before checksum" + } + ] +} diff --git a/nextjs_space/lib/verification/id-document-errors.ts b/nextjs_space/lib/verification/id-document-errors.ts new file mode 100644 index 00000000..bf0245da --- /dev/null +++ b/nextjs_space/lib/verification/id-document-errors.ts @@ -0,0 +1,19 @@ +import { SA_ID_INVALID_MESSAGE } from "@/lib/verification/sa-id"; + +/** + * Stored upload errors (consultation_questionnaires.idDocumentError) are raw + * upstream strings — status lines, JSON bodies — and must never be shown to a + * customer. The dashboard may only surface a reason that was written for + * customers in the first place; today that is the SA ID copy (BS-204). + * + * Pure and dependency-free so the server action that reads the flag can use + * it without pulling Prisma into its tests. + */ +const CUSTOMER_SAFE_ID_DOCUMENT_ERRORS: readonly string[] = [SA_ID_INVALID_MESSAGE]; + +export function customerSafeIdDocumentError( + stored: string | null | undefined, +): string | null { + if (!stored) return null; + return CUSTOMER_SAFE_ID_DOCUMENT_ERRORS.includes(stored) ? stored : null; +} diff --git a/nextjs_space/lib/verification/sa-id-schema.ts b/nextjs_space/lib/verification/sa-id-schema.ts new file mode 100644 index 00000000..2aaf390e --- /dev/null +++ b/nextjs_space/lib/verification/sa-id-schema.ts @@ -0,0 +1,82 @@ +/** + * Server-side glue for the shared SA ID validator (BS-202 / BS-204): the zod + * refinement both upload routes apply, the 400 body they answer with, and the + * matcher for Dr Green's own refusal coming back through the proxy. + * + * Kept apart from lib/verification/sa-id.ts so the upload components can + * import the validator without dragging zod-issue plumbing into the browser. + */ +import { z } from "zod"; + +import { + SA_ID_DOCUMENT_TYPE, + SA_ID_INVALID_CODE, + SA_ID_INVALID_MESSAGE, + validateSouthAfricanId, +} from "@/lib/verification/sa-id"; + +export interface IdDocumentFields { + documentType: string; + documentNumber: string; +} + +/** + * `superRefine` for an ID-document object. Runs the shared validator only + * when the tenant is South African (`enforce`) AND the document type is ID; + * passports, driving licences and every non-SA tenant carry no check. + */ +export function saIdDocumentRefinement(enforce: boolean) { + return (value: IdDocumentFields, ctx: z.RefinementCtx): void => { + if (!enforce || value.documentType !== SA_ID_DOCUMENT_TYPE) return; + const result = validateSouthAfricanId(value.documentNumber); + if (result.valid) return; + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["documentNumber"], + message: SA_ID_INVALID_MESSAGE, + params: { code: SA_ID_INVALID_CODE, reason: result.reason }, + }); + }; +} + +export function hasSaIdInvalidIssue(error: z.ZodError): boolean { + return error.issues.some( + (issue) => + issue.code === z.ZodIssueCode.custom && + issue.params?.code === SA_ID_INVALID_CODE, + ); +} + +/** The 400 body both routes return — the same code and copy as Dr Green's. */ +export function saIdInvalidBody(): { code: string; error: string } { + return { code: SA_ID_INVALID_CODE, error: SA_ID_INVALID_MESSAGE }; +} + +/** + * What to forward to Dr Green: the space-stripped number for a South African + * ID (the form the backend encrypts), the number as typed for everything else. + */ +export function documentNumberToForward( + doc: IdDocumentFields, + enforce: boolean, +): string { + if (!enforce || doc.documentType !== SA_ID_DOCUMENT_TYPE) return doc.documentNumber; + const result = validateSouthAfricanId(doc.documentNumber); + return result.valid ? result.normalised : doc.documentNumber; +} + +const UPSTREAM_400 = + /(?:Doctor Green API Error|Dr Green identity upload failed): 400\b/; + +/** + * Dr Green refused the upload with its own SA_ID_INVALID (BS-204). Only + * reachable if the two validators drift — the storefront checks first — but a + * 400 from upstream must still land on the number field rather than on the + * generic failed-upload banner. Matches the error code or the shared copy in + * whichever body shape Dr Green sends back. + */ +export function isSaIdInvalidUpstreamError(error: unknown): boolean { + const raw = error instanceof Error ? error.message : ""; + if (!UPSTREAM_400.test(raw)) return false; + return raw.includes(SA_ID_INVALID_CODE) || raw.includes(SA_ID_INVALID_MESSAGE); +} diff --git a/nextjs_space/lib/verification/sa-id.ts b/nextjs_space/lib/verification/sa-id.ts new file mode 100644 index 00000000..e372e23f --- /dev/null +++ b/nextjs_space/lib/verification/sa-id.ts @@ -0,0 +1,123 @@ +/** + * South African ID number validation — Dr Green Phase 2 (BS-201). + * + * PURE AND BROWSER-SAFE: no zod, no Prisma, no Node APIs. The three upload + * components import this for inline validation; the two upload routes reach + * it through lib/verification/sa-id-schema.ts. The rules and the copy are + * shared with Dr Green's backend validator and the WordPress plugin, and all + * three are proven against one vector file (__tests__/sa-id-vectors.json). + * + * Rules, in order — the first failure is the reason: + * 1. strip whitespace, exactly 13 characters → "length" + * 2. all 13 are digits → "digits" + * 3. YYMMDD is a real calendar date in the 1900s or the 2000s + * that is not after today (either century is accepted) → "date" + * 4. digit 11 is 0, 1 or 2 → "citizenship" + * 5. Luhn over all 13 digits → "checksum" + * Digit 12 is not enforced (legacy values exist). Nothing is derived from the + * number: no date of birth, sex or citizenship leaves this module. + */ + +export type SaIdInvalidReason = + | "length" + | "digits" + | "date" + | "citizenship" + | "checksum"; + +export type SaIdValidation = + | { valid: true; normalised: string } + | { valid: false; reason: SaIdInvalidReason }; + +export const SA_ID_LENGTH = 13; + +/** Error code on the 400 from both upload routes; Dr Green uses the same one. */ +export const SA_ID_INVALID_CODE = "SA_ID_INVALID"; + +/** The one message shown everywhere: forms, routes, and Dr Green's own 400. */ +export const SA_ID_INVALID_MESSAGE = + "That does not look like a valid South African ID number. Check the 13 digits and try again."; + +/** The document type the rules apply to (Dr Green's DocumentType.ID). */ +export const SA_ID_DOCUMENT_TYPE = "ID"; + +const CENTURIES = [1900, 2000] as const; +const ALLOWED_CITIZENSHIP_DIGITS = new Set(["0", "1", "2"]); +const THIRTEEN_DIGITS = /^\d{13}$/; + +export function normaliseSouthAfricanId(input: string): string { + return (input ?? "").replace(/\s+/g, ""); +} + +function daysInMonth(year: number, month: number): number { + // Day 0 of the following month is the last day of this one (month is 1-12). + return new Date(Date.UTC(year, month, 0)).getUTCDate(); +} + +function isRealPastDate( + year: number, + month: number, + day: number, + now: Date, +): boolean { + if (month < 1 || month > 12) return false; + if (day < 1 || day > daysInMonth(year, month)) return false; + return Date.UTC(year, month - 1, day) <= now.getTime(); +} + +/** YYMMDD is acceptable when EITHER century yields a real, non-future date. */ +function isPlausibleBirthDate(yymmdd: string, now: Date): boolean { + const yy = Number(yymmdd.slice(0, 2)); + const mm = Number(yymmdd.slice(2, 4)); + const dd = Number(yymmdd.slice(4, 6)); + return CENTURIES.some((century) => isRealPastDate(century + yy, mm, dd, now)); +} + +/** Luhn over the whole string; the last digit is the check digit. */ +function passesLuhn(digits: string): boolean { + let sum = 0; + for (let i = 0; i < digits.length; i += 1) { + let digit = digits.charCodeAt(digits.length - 1 - i) - 48; + if (i % 2 === 1) { + digit *= 2; + if (digit > 9) digit -= 9; + } + sum += digit; + } + return sum % 10 === 0; +} + +export function validateSouthAfricanId( + input: string, + options: { now?: Date } = {}, +): SaIdValidation { + const normalised = normaliseSouthAfricanId(input); + if (normalised.length !== SA_ID_LENGTH) return { valid: false, reason: "length" }; + if (!THIRTEEN_DIGITS.test(normalised)) return { valid: false, reason: "digits" }; + if (!isPlausibleBirthDate(normalised.slice(0, 6), options.now ?? new Date())) { + return { valid: false, reason: "date" }; + } + if (!ALLOWED_CITIZENSHIP_DIGITS.has(normalised[10])) { + return { valid: false, reason: "citizenship" }; + } + if (!passesLuhn(normalised)) return { valid: false, reason: "checksum" }; + return { valid: true, normalised }; +} + +/** + * The inline field error for an upload form: the shared copy when the rules + * apply (South African rules on, document type ID) and the number fails, + * otherwise null. An empty number is NOT an SA-ID error — every form has its + * own "please enter your document number" message for that. + */ +export function saIdFieldError(params: { + documentType: string; + documentNumber: string; + enforce: boolean; +}): string | null { + if (!params.enforce || params.documentType !== SA_ID_DOCUMENT_TYPE) return null; + if (normaliseSouthAfricanId(params.documentNumber) === "") return null; + return validateSouthAfricanId(params.documentNumber).valid + ? null + : SA_ID_INVALID_MESSAGE; +} diff --git a/nextjs_space/package.json b/nextjs_space/package.json index 29814bff..77f82bb4 100644 --- a/nextjs_space/package.json +++ b/nextjs_space/package.json @@ -1,5 +1,6 @@ { "name": "app", + "version": "1.0.0", "private": true, "packageManager": "pnpm@10.30.2", "engines": { diff --git a/nextjs_space/tests/unit/consultation-submit-ownership.test.ts b/nextjs_space/tests/unit/consultation-submit-ownership.test.ts index 55f9b6bf..8bd9d222 100644 --- a/nextjs_space/tests/unit/consultation-submit-ownership.test.ts +++ b/nextjs_space/tests/unit/consultation-submit-ownership.test.ts @@ -66,6 +66,7 @@ vi.mock("@/lib/legal/policy-gate", () => ({ checkPolicyGate: libMock.checkPolicy vi.mock("@/lib/verification-mode", () => ({ getTenantVerificationMode: libMock.getTenantVerificationMode, isSaIdUploadEnabled: libMock.isSaIdUploadEnabled, + isSaIdEligibleTenant: () => true, })); vi.mock("@/lib/tenant/tenant-config", () => ({ getTenantDrGreenConfig: libMock.getTenantDrGreenConfig, diff --git a/nextjs_space/tests/unit/consultation-submit-sa-id.test.ts b/nextjs_space/tests/unit/consultation-submit-sa-id.test.ts new file mode 100644 index 00000000..c161fcf5 --- /dev/null +++ b/nextjs_space/tests/unit/consultation-submit-sa-id.test.ts @@ -0,0 +1,233 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * BS-202 on the consultation submit route (SA ID-upload registration): an + * impossible South African ID number is refused BEFORE any account, + * questionnaire or Dr Green client exists — the customer fixes the number and + * resubmits with nothing to clean up. Same mock harness as + * consultation-submit-ownership.test.ts: the real handler runs against mocked + * module boundaries. + */ + +const clerkMock = vi.hoisted(() => ({ + currentUser: vi.fn(), + createUser: vi.fn(), + clerkClient: vi.fn(), +})); +const prismaMock = vi.hoisted(() => ({ + users: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn() }, + consultation_questionnaires: { create: vi.fn(), update: vi.fn() }, +})); +const libMock = vi.hoisted(() => ({ + checkRateLimit: vi.fn(), + getTenantFromRequest: vi.fn(), + resolveTenant: vi.fn(), + checkPolicyGate: vi.fn(), + getTenantVerificationMode: vi.fn(), + isSaIdUploadEnabled: vi.fn(), + isSaIdEligibleTenant: vi.fn(), + getTenantDrGreenConfig: vi.fn(), + callDrGreenAPI: vi.fn(), + createSaIdClient: vi.fn(), + uploadIdentityDocument: vi.fn(), + recordIdDocumentOutcome: vi.fn(), + createAuditLog: vi.fn(), + triggerWebhook: vi.fn(), + mapMedicalConditionsForDrGreen: vi.fn(), +})); + +vi.mock("@clerk/nextjs/server", () => ({ + currentUser: clerkMock.currentUser, + clerkClient: clerkMock.clerkClient, +})); +vi.mock("@/lib/db", () => ({ prisma: prismaMock })); +vi.mock("@/lib/security/rate-limit", () => ({ checkRateLimit: libMock.checkRateLimit })); +vi.mock("@/lib/tenant/tenant", () => ({ getTenantFromRequest: libMock.getTenantFromRequest })); +vi.mock("@/lib/tenant/tenant-resolver", () => ({ resolveTenant: libMock.resolveTenant })); +vi.mock("@/lib/legal/policy-gate", () => ({ checkPolicyGate: libMock.checkPolicyGate })); +vi.mock("@/lib/verification-mode", () => ({ + getTenantVerificationMode: libMock.getTenantVerificationMode, + isSaIdUploadEnabled: libMock.isSaIdUploadEnabled, + isSaIdEligibleTenant: libMock.isSaIdEligibleTenant, +})); +vi.mock("@/lib/tenant/tenant-config", () => ({ + getTenantDrGreenConfig: libMock.getTenantDrGreenConfig, +})); +vi.mock("@/lib/drgreen/drgreen-api-client", () => ({ callDrGreenAPI: libMock.callDrGreenAPI })); +vi.mock("@/lib/drgreen-identity", () => ({ + createSaIdClient: libMock.createSaIdClient, + uploadIdentityDocument: libMock.uploadIdentityDocument, +})); +vi.mock("@/lib/verification/id-document-status", () => ({ + recordIdDocumentOutcome: libMock.recordIdDocumentOutcome, +})); +vi.mock("@/lib/drgreen/dr-green-mapping", () => ({ + mapMedicalConditionsForDrGreen: libMock.mapMedicalConditionsForDrGreen, +})); +vi.mock("@/lib/audit-log", () => ({ + createAuditLog: libMock.createAuditLog, + AUDIT_ACTIONS: { CONSULTATION_SUBMITTED: "consultation.submitted" }, + getClientInfo: () => ({}), +})); +vi.mock("@/lib/integrations/webhook", () => ({ + triggerWebhook: libMock.triggerWebhook, + WEBHOOK_EVENTS: { CONSULTATION_SUBMITTED: "consultation.submitted" }, +})); + +import { POST } from "@/app/api/consultation/submit/route"; +import { SA_ID_INVALID_CODE, SA_ID_INVALID_MESSAGE } from "@/lib/verification/sa-id"; + +const ZA_TENANT = { + id: "tenant-za", + subdomain: "lekker", + countryCode: "ZA", + settings: { verificationMode: "ID_UPLOAD" }, +}; + +// Synthetic numbers from lib/verification/__tests__/sa-id-vectors.json. +const VALID_SA_ID = "9001015009086"; +const VALID_SA_ID_SPACED = "900101 5009 086"; +const INVALID_SA_ID = "9001015009087"; // check digit off by one + +function idDocument(over: Record = {}) { + return { + fileBase64: Buffer.from("fake-png").toString("base64"), + mimeType: "image/png", + documentType: "ID", + documentNumber: INVALID_SA_ID, + ...over, + }; +} + +function submission(over: Record = {}) { + return { + firstName: "Thabo", + lastName: "Mokoena", + email: "thabo-new@example.com", + password: "sup3rsecret!", + phoneCode: "+27", + phoneNumber: "821234567", + countryCode: "ZA", + country: "South Africa", + addressLine1: "1 Long St", + city: "Cape Town", + state: "Western Cape", + postalCode: "8001", + idDocument: idDocument(), + ...over, + }; +} + +function request(body: unknown) { + return new NextRequest("http://lekker.localhost/api/consultation/submit", { + method: "POST", + headers: { "content-type": "application/json", "x-forwarded-for": "203.0.113.9" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + libMock.checkRateLimit.mockResolvedValue({ success: true }); + libMock.getTenantFromRequest.mockResolvedValue(ZA_TENANT); + libMock.checkPolicyGate.mockResolvedValue({ allowed: true }); + libMock.getTenantVerificationMode.mockReturnValue("ID_UPLOAD"); + libMock.isSaIdUploadEnabled.mockReturnValue(true); + libMock.isSaIdEligibleTenant.mockReturnValue(true); + libMock.getTenantDrGreenConfig.mockResolvedValue({ + apiKey: "k", + secretKey: "s", + apiUrl: "https://stage/api/v1", + }); + libMock.createSaIdClient.mockResolvedValue({ clientId: "drg-1" }); + libMock.uploadIdentityDocument.mockResolvedValue({ id: "doc-1" }); + libMock.recordIdDocumentOutcome.mockResolvedValue(true); + libMock.createAuditLog.mockResolvedValue(undefined); + libMock.triggerWebhook.mockResolvedValue(undefined); + clerkMock.currentUser.mockResolvedValue(null); + clerkMock.createUser.mockResolvedValue({ id: "clerk_new" }); + clerkMock.clerkClient.mockResolvedValue({ users: { createUser: clerkMock.createUser } }); + prismaMock.users.findUnique.mockResolvedValue(null); + prismaMock.users.create.mockResolvedValue({ id: "clerk_new" }); + prismaMock.users.update.mockResolvedValue({}); + prismaMock.consultation_questionnaires.create.mockResolvedValue({ id: "q-1" }); + prismaMock.consultation_questionnaires.update.mockResolvedValue({}); +}); + +/** A refused number must leave no trace anywhere. */ +function expectNothingCreated() { + expect(clerkMock.createUser).not.toHaveBeenCalled(); + expect(prismaMock.users.create).not.toHaveBeenCalled(); + expect(prismaMock.users.update).not.toHaveBeenCalled(); + expect(prismaMock.consultation_questionnaires.create).not.toHaveBeenCalled(); + expect(libMock.createSaIdClient).not.toHaveBeenCalled(); + expect(libMock.uploadIdentityDocument).not.toHaveBeenCalled(); + expect(libMock.recordIdDocumentOutcome).not.toHaveBeenCalled(); +} + +describe("consultation submit — South African ID number (BS-202)", () => { + it("400s with SA_ID_INVALID before any account or client exists", async () => { + const res = await POST(request(submission())); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + code: SA_ID_INVALID_CODE, + error: SA_ID_INVALID_MESSAGE, + }); + expectNothingCreated(); + }); + + it("creates the client and forwards a valid number space-stripped", async () => { + const res = await POST( + request(submission({ idDocument: idDocument({ documentNumber: VALID_SA_ID_SPACED }) })), + ); + + expect(res.status).toBe(200); + expect(libMock.createSaIdClient).toHaveBeenCalledTimes(1); + expect(libMock.uploadIdentityDocument).toHaveBeenCalledTimes(1); + const upload = libMock.uploadIdentityDocument.mock.calls[0][0]; + expect(upload.documentNumber).toBe(VALID_SA_ID); + expect(upload.documentType).toBe("ID"); + expect(libMock.recordIdDocumentOutcome).toHaveBeenCalledWith( + expect.objectContaining({ outcome: "UPLOADED" }), + ); + }); + + it("leaves a passport number unchecked and forwards it as typed", async () => { + const res = await POST( + request( + submission({ + idDocument: idDocument({ documentType: "PASSPORT", documentNumber: "A1234567" }), + }), + ), + ); + + expect(res.status).toBe(200); + const upload = libMock.uploadIdentityDocument.mock.calls[0][0]; + expect(upload.documentNumber).toBe("A1234567"); + }); + + it("does not apply the rules on a non-South-African tenant", async () => { + libMock.isSaIdEligibleTenant.mockReturnValue(false); + libMock.getTenantVerificationMode.mockReturnValue("KYC"); + libMock.callDrGreenAPI.mockResolvedValue({ data: { client: { id: "drg-kyc" } } }); + + const res = await POST( + request(submission({ countryCode: "PT", dateOfBirth: "1990-01-01", gender: "Other" })), + ); + + expect(res.status).not.toBe(400); + expect(libMock.callDrGreenAPI).toHaveBeenCalled(); + }); + + it("still rejects a malformed idDocument object as a plain validation error", async () => { + const res = await POST( + request(submission({ idDocument: { fileBase64: "x", mimeType: "image/png", documentType: "NOPE", documentNumber: "1" } })), + ); + + expect(res.status).toBe(400); + expect((await res.json()).code).toBeUndefined(); + expectNothingCreated(); + }); +}); diff --git a/nextjs_space/tests/unit/drgreen-client-header.test.ts b/nextjs_space/tests/unit/drgreen-client-header.test.ts new file mode 100644 index 00000000..034ec67e --- /dev/null +++ b/nextjs_space/tests/unit/drgreen-client-header.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createVerify, generateKeyPairSync } from "crypto"; + +// Isolate from the pino-based logger drgreen-api-client imports. +vi.mock("@/lib/logger", () => ({ + logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }, +})); + +import { callDrGreenAPI } from "@/lib/drgreen/drgreen-api-client"; +import { + buildIdentityUploadSignaturePayload, + uploadIdentityDocument, +} from "@/lib/drgreen-identity"; +import { + DRG_CLIENT_HEADER, + DRG_CLIENT_NAME, + drgClientHeaderValue, + resolveAppVersion, + withDrgClientHeader, +} from "@/lib/drgreen/client-version"; + +/** + * BS-204 — every Dr Green call declares itself with `X-DRG-Client` so Dr + * Green applies strict validation to BudStacks (legacy WordPress plugins send + * nothing and stay in soft mode). The header must sit OUTSIDE the signed + * payload: these tests verify each request's signature against the payload + * Dr Green reconstructs, exactly as its DualAuthGuard does. + */ +function makeKeyPair() { + const { publicKey, privateKey } = generateKeyPairSync("ec", { + namedCurve: "secp256k1", + }); + const publicPem = publicKey.export({ type: "spki", format: "pem" }) as string; + const privatePem = privateKey.export({ type: "pkcs8", format: "pem" }) as string; + return { publicPem, secretKey: Buffer.from(privatePem, "utf-8").toString("base64") }; +} + +function verifiesAsDrGreen(publicPem: string, payload: string, signatureB64: string): boolean { + const verifier = createVerify("SHA256"); + verifier.update(payload); + verifier.end(); + return verifier.verify(publicPem, Buffer.from(signatureB64, "base64")); +} + +describe("drgClientHeaderValue", () => { + it("is budstacks/ when APP_VERSION is unset", () => { + const value = drgClientHeaderValue({}); + expect(value).toBe(`${DRG_CLIENT_NAME}/${resolveAppVersion({})}`); + expect(value).toMatch(/^budstacks\/\d+\.\d+\.\d+$/); + }); + + it("prefers APP_VERSION and sanitises it to Dr Green's stored charset", () => { + expect(drgClientHeaderValue({ APP_VERSION: "2.1.0+build 7" })).toBe("budstacks/2.1.0-build-7"); + }); + + it("caps the value at the 64 characters Dr Green keeps", () => { + expect(drgClientHeaderValue({ APP_VERSION: "9".repeat(100) })).toHaveLength(64); + }); + + it("withDrgClientHeader returns a new object and lets explicit headers win", () => { + const input = { "x-auth-apikey": "k" }; + const out = withDrgClientHeader(input); + expect(out).not.toBe(input); + expect(input).toEqual({ "x-auth-apikey": "k" }); + expect(out[DRG_CLIENT_HEADER]).toBe(drgClientHeaderValue()); + expect(withDrgClientHeader({ [DRG_CLIENT_HEADER]: "custom/1" })[DRG_CLIENT_HEADER]).toBe("custom/1"); + }); +}); + +describe("X-DRG-Client on every Dr Green call, outside the signature", () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("callDrGreenAPI (JSON): header present, body signature verifies unchanged", async () => { + const { publicPem, secretKey } = makeKeyPair(); + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ success: true }), { status: 200 }), + ); + + await callDrGreenAPI("/dapp/clients", { + method: "POST", + apiKey: "k", + secretKey, + body: { clientId: "c1" }, + baseUrl: "https://stage/api/v1", + }); + + const [, init] = fetchMock.mock.calls[0]; + const headers = init.headers as Record; + expect(headers[DRG_CLIENT_HEADER]).toBe(drgClientHeaderValue()); + expect(headers["x-auth-apikey"]).toBe("k"); + // The signed payload is the JSON body alone — the header is not in it. + expect(verifiesAsDrGreen(publicPem, JSON.stringify({ clientId: "c1" }), headers["x-auth-signature"])).toBe(true); + expect(init.body).toBe(JSON.stringify({ clientId: "c1" })); + }); + + it("callDrGreenAPI (GET with query): header present, query-string signature unchanged", async () => { + const { publicPem, secretKey } = makeKeyPair(); + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ data: { strains: [] } }), { status: 200 }), + ); + + await callDrGreenAPI("/dapp/strains", { + apiKey: "k", + secretKey, + queryParams: { countryCode: "ZAF", take: 100 }, + baseUrl: "https://stage/api/v1", + }); + + const [url, init] = fetchMock.mock.calls[0]; + const headers = init.headers as Record; + expect(url).toBe("https://stage/api/v1/dapp/strains?countryCode=ZAF&take=100"); + expect(headers[DRG_CLIENT_HEADER]).toBe(drgClientHeaderValue()); + expect(verifiesAsDrGreen(publicPem, "countryCode=ZAF&take=100", headers["x-auth-signature"])).toBe(true); + }); + + it("uploadIdentityDocument (multipart): header present, multipart signature byte-for-byte unchanged", async () => { + const { publicPem, secretKey } = makeKeyPair(); + const file = Buffer.from([1, 2, 255, 0, 128]); + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + data: { data: { id: "doc-1", documentType: "ID", reviewStatus: "PENDING", createdAt: "x" } }, + }), + { status: 200 }, + ), + ); + + const doc = await uploadIdentityDocument({ + clientId: "c1", + documentType: "ID", + documentNumber: "9001015009086", + file, + mimeType: "image/png", + config: { apiKey: "k", secretKey }, + baseUrl: "https://stage/api/v1", + }); + expect(doc.id).toBe("doc-1"); + + const [url, init] = fetchMock.mock.calls[0]; + const headers = init.headers as Record; + expect(url).toBe("https://stage/api/v1/identity/documents"); + expect(headers[DRG_CLIENT_HEADER]).toBe(drgClientHeaderValue()); + // No Content-Type: fetch derives the multipart boundary (unchanged). + expect(headers["Content-Type"]).toBeUndefined(); + // Dr Green rebuilds {fields..., file: Buffer} and verifies that string. + const canonical = buildIdentityUploadSignaturePayload({ + clientId: "c1", + documentType: "ID", + documentNumber: "9001015009086", + fileBuffer: file, + }); + expect(verifiesAsDrGreen(publicPem, canonical, headers["x-auth-signature"])).toBe(true); + expect(init.body).toBeInstanceOf(FormData); + }); +}); diff --git a/nextjs_space/tests/unit/kyc-client-payload.test.ts b/nextjs_space/tests/unit/kyc-client-payload.test.ts new file mode 100644 index 00000000..a7189ddd --- /dev/null +++ b/nextjs_space/tests/unit/kyc-client-payload.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/drgreen/dr-green-mapping", () => ({ + mapMedicalConditionsForDrGreen: (conditions: string[]) => + conditions.map((c) => `mapped:${c}`), +})); + +import { + buildKycClientPayload, + type KycRegistrationInput, +} from "@/lib/drgreen/kyc-client-payload"; + +/** + * The KYC payload builder was lifted out of the consultation submit route + * unchanged. These pin the mappings a regression would silently break. + */ +const base: KycRegistrationInput = { + firstName: "Ana", + lastName: "Silva", + email: "Ana.Silva@Example.com", + phoneCode: "+351 ", + phoneNumber: "912 345 678", + countryCode: "PT", + dateOfBirth: "1990-01-15T00:00:00.000Z", + gender: "Female", + addressLine1: "Rua A 1", + addressLine2: "", + city: "Lisboa", + state: "Lisboa", + postalCode: "1000-001", + country: "Portugal", + medicalConditions: ["anxiety"], + otherCondition: "", + prescribedSupplements: "", + hasHeartProblems: false, + hasCancerTreatment: false, + hasImmunosuppressants: false, + hasLiverDisease: false, + hasPsychiatricHistory: true, + hasAlcoholAbuse: false, + hasDrugServices: false, + alcoholUnitsPerWeek: "", + cannabisReducesMeds: false, + cannabisFrequency: "weekly", + cannabisAmountPerDay: "1g", +}; + +describe("buildKycClientPayload", () => { + it("normalises contact fields and converts the shipping country to alpha-3", () => { + const payload = buildKycClientPayload(base); + expect(payload.email).toBe("ana.silva@example.com"); + expect(payload.phoneCode).toBe("+351"); + expect(payload.contactNumber).toBe("912345678"); + expect(payload.phoneCountryCode).toBe("PT"); + expect(payload.shipping.countryCode).toBe("PRT"); + expect(payload.shipping.landmark).toBe(""); + expect("clientBusiness" in payload).toBe(false); + }); + + it("formats the date of birth as YYYY-MM-DD and maps the history flags", () => { + const payload = buildKycClientPayload(base); + expect(payload.medicalRecord.dob).toBe("1990-01-15"); + expect(payload.medicalRecord.medicalConditions).toEqual(["mapped:anxiety"]); + expect(payload.medicalRecord.medicalHistory4).toBe(true); + expect(payload.medicalRecord.medicalHistory5).toEqual(["depression"]); + expect(payload.medicalRecord.medicalHistory13).toBe("weekly"); + expect(payload.medicalRecord.medicalHistory14).toEqual(["vaporizing"]); + expect(payload.medicalRecord.medicalHistory11).toBe("0"); + expect("otherMedicalCondition" in payload.medicalRecord).toBe(false); + }); + + it("fills otherMedicalCondition from the mapped keys, then free text, then the fallback", () => { + expect( + buildKycClientPayload({ ...base, medicalConditions: ["asthma", "other"] }).medicalRecord + .otherMedicalCondition, + ).toBe("Asthma, Other"); + expect( + buildKycClientPayload({ ...base, medicalConditions: [], otherCondition: "Migraine" }) + .medicalRecord.otherMedicalCondition, + ).toBe("Migraine"); + }); + + it("includes clientBusiness only when both type and name are present", () => { + const withBusiness = buildKycClientPayload({ + ...base, + businessType: "pharmacy", + businessName: "Farmácia A", + }); + expect(withBusiness.clientBusiness).toEqual( + expect.objectContaining({ businessType: "pharmacy", name: "Farmácia A", countryCode: "" }), + ); + expect( + "clientBusiness" in buildKycClientPayload({ ...base, businessType: "pharmacy" }), + ).toBe(false); + }); +}); diff --git a/nextjs_space/tests/unit/sa-id.test.ts b/nextjs_space/tests/unit/sa-id.test.ts new file mode 100644 index 00000000..3426af4d --- /dev/null +++ b/nextjs_space/tests/unit/sa-id.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; + +import vectorFile from "@/lib/verification/__tests__/sa-id-vectors.json"; +import { + SA_ID_INVALID_MESSAGE, + normaliseSouthAfricanId, + saIdFieldError, + validateSouthAfricanId, + type SaIdInvalidReason, +} from "@/lib/verification/sa-id"; + +/** + * BS-201 — one validator, proven against the vector file Dr Green's backend + * (US-201) and the WordPress plugin (US-203) run too. The file is the + * contract: if any vector disagrees, the three implementations have drifted. + */ +interface SaIdVector { + input: string; + valid: boolean; + normalised?: string; + reason?: SaIdInvalidReason; + note?: string; +} + +const VECTORS = (vectorFile as { vectors: SaIdVector[] }).vectors; + +// Pinned so the "not in the future" rule is deterministic in CI. +const NOW = new Date("2026-09-18T12:00:00Z"); + +const REASONS: SaIdInvalidReason[] = ["length", "digits", "date", "citizenship", "checksum"]; + +describe("validateSouthAfricanId — shared vector file", () => { + it("has vectors to run (the contract file is not empty)", () => { + expect(VECTORS.length).toBeGreaterThan(20); + }); + + it("no vector is a real person's number (all synthetic, all documented)", () => { + // Every vector carries a note explaining what it exercises. + expect(VECTORS.every((v) => typeof v.note === "string" && v.note.length > 0)).toBe(true); + }); + + for (const vector of VECTORS) { + const label = vector.valid ? "valid" : vector.reason; + it(`${JSON.stringify(vector.input)} → ${label} (${vector.note})`, () => { + const result = validateSouthAfricanId(vector.input, { now: NOW }); + if (vector.valid) { + expect(result).toEqual({ valid: true, normalised: vector.normalised }); + } else { + expect(result).toEqual({ valid: false, reason: vector.reason }); + } + }); + } + + it("exercises every failure reason at least once", () => { + const seen = new Set(VECTORS.filter((v) => !v.valid).map((v) => v.reason)); + for (const reason of REASONS) expect(seen.has(reason)).toBe(true); + }); +}); + +describe("validateSouthAfricanId — rules the vectors cannot pin", () => { + it("rejects a date that is real but after today as 'date'", () => { + // 2025-12-31 is in the past for 1925 and 2025 alike; pretend today is 1920. + const result = validateSouthAfricanId("2512315000082", { now: new Date("1920-01-01T00:00:00Z") }); + expect(result).toEqual({ valid: false, reason: "date" }); + }); + + it("returns the space-stripped number as `normalised`", () => { + expect(normaliseSouthAfricanId(" 900101 5009 086 ")).toBe("9001015009086"); + }); + + it("derives nothing from the number (no date of birth, sex or citizenship in the result)", () => { + const result = validateSouthAfricanId("9001015009086", { now: NOW }); + expect(Object.keys(result).sort()).toEqual(["normalised", "valid"]); + }); +}); + +describe("saIdFieldError — inline form copy", () => { + it("returns the shared copy for an invalid ID number when the rules are on", () => { + expect( + saIdFieldError({ documentType: "ID", documentNumber: "9001015009087", enforce: true }), + ).toBe(SA_ID_INVALID_MESSAGE); + }); + + it("returns null for a valid number, with or without spaces", () => { + expect( + saIdFieldError({ documentType: "ID", documentNumber: "900101 5009 086", enforce: true }), + ).toBeNull(); + }); + + it("never fires for passports or driving licences", () => { + expect( + saIdFieldError({ documentType: "PASSPORT", documentNumber: "junk", enforce: true }), + ).toBeNull(); + expect( + saIdFieldError({ documentType: "DRIVING_LICENCE", documentNumber: "junk", enforce: true }), + ).toBeNull(); + }); + + it("never fires when the rules are off (non-South-African context)", () => { + expect( + saIdFieldError({ documentType: "ID", documentNumber: "junk", enforce: false }), + ).toBeNull(); + }); + + it("leaves an empty number to the form's own required-field message", () => { + expect(saIdFieldError({ documentType: "ID", documentNumber: " ", enforce: true })).toBeNull(); + }); +}); diff --git a/nextjs_space/tests/unit/verify-id-document-route.test.ts b/nextjs_space/tests/unit/verify-id-document-route.test.ts index 5143f56a..939e0fda 100644 --- a/nextjs_space/tests/unit/verify-id-document-route.test.ts +++ b/nextjs_space/tests/unit/verify-id-document-route.test.ts @@ -22,6 +22,9 @@ vi.mock("@/lib/drgreen-identity", () => ({ ALLOWED_DOCUMENT_MIME_TYPES: ["image/jpeg", "image/png", "application/pdf"], MAX_DOCUMENT_BYTES: 10 * 1024 * 1024, })); +vi.mock("@/lib/verification/id-document-status", () => ({ + recordIdDocumentOutcome: vi.fn(async () => true), +})); vi.mock("@/lib/api-error", () => ({ apiError: (_e: any, o: any) => new Response(JSON.stringify({ error: o?.safeMessage ?? "error" }), { @@ -34,6 +37,13 @@ import { POST } from "@/app/api/store/[slug]/verify/id-document/route"; import { getCurrentTenant } from "@/lib/tenant/tenant"; import { prisma } from "@/lib/db"; import { uploadIdentityDocument } from "@/lib/drgreen-identity"; +import { recordIdDocumentOutcome } from "@/lib/verification/id-document-status"; +import { SA_ID_INVALID_CODE, SA_ID_INVALID_MESSAGE } from "@/lib/verification/sa-id"; + +// Synthetic numbers from lib/verification/__tests__/sa-id-vectors.json. +const VALID_SA_ID = "9001015009086"; +const VALID_SA_ID_SPACED = "900101 5009 086"; +const INVALID_SA_ID = "9001015009087"; // check digit off by one const ZA_ID_TENANT = { id: "tenant-1", @@ -133,3 +143,88 @@ describe("POST /api/store/[slug]/verify/id-document", () => { expect(uploadIdentityDocument).not.toHaveBeenCalled(); }); }); + +describe("POST /api/store/[slug]/verify/id-document — South African ID rules (BS-202/BS-204)", () => { + it("400s with SA_ID_INVALID for an impossible ID number and attempts nothing", async () => { + const res = await call( + makeReq({ file: jpeg(), documentType: "ID", documentNumber: INVALID_SA_ID }), + ); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + code: SA_ID_INVALID_CODE, + error: SA_ID_INVALID_MESSAGE, + }); + expect(uploadIdentityDocument).not.toHaveBeenCalled(); + // Nothing was attempted, so nothing is recorded — no UPLOAD_FAILED flag. + expect(recordIdDocumentOutcome).not.toHaveBeenCalled(); + }); + + it("forwards a valid ID number space-stripped", async () => { + const res = await call( + makeReq({ file: jpeg(), documentType: "ID", documentNumber: VALID_SA_ID_SPACED }), + ); + expect(res.status).toBe(200); + const arg = (uploadIdentityDocument as any).mock.calls[0][0]; + expect(arg.documentNumber).toBe(VALID_SA_ID); + }); + + it("leaves passport and driving-licence numbers unchecked", async () => { + for (const documentType of ["PASSPORT", "DRIVING_LICENCE"]) { + (uploadIdentityDocument as any).mockClear(); + const res = await call( + makeReq({ file: jpeg(), documentType, documentNumber: "not-13-digits" }), + ); + expect(res.status).toBe(200); + const arg = (uploadIdentityDocument as any).mock.calls[0][0]; + expect(arg.documentNumber).toBe("not-13-digits"); + } + }); + + it("never reaches the SA rules for a non-South-African tenant (the ID-upload path is ZA-only)", async () => { + (getCurrentTenant as any).mockResolvedValue({ ...ZA_ID_TENANT, countryCode: "PT" }); + const res = await call( + makeReq({ file: jpeg(), documentType: "ID", documentNumber: INVALID_SA_ID }), + ); + expect(res.status).toBe(403); + expect(uploadIdentityDocument).not.toHaveBeenCalled(); + }); + + it("maps Dr Green's own SA_ID_INVALID 400 onto the field and records the reason", async () => { + (uploadIdentityDocument as any).mockRejectedValueOnce( + new Error( + 'Dr Green identity upload failed: 400 Bad Request - {"success":false,"statusCode":400,"message":"' + + SA_ID_INVALID_MESSAGE + + '","error":"SA_ID_INVALID"}', + ), + ); + const res = await call( + makeReq({ file: jpeg(), documentType: "ID", documentNumber: VALID_SA_ID }), + ); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + code: SA_ID_INVALID_CODE, + error: SA_ID_INVALID_MESSAGE, + }); + expect(recordIdDocumentOutcome).toHaveBeenCalledTimes(1); + const outcome = (recordIdDocumentOutcome as any).mock.calls[0][0]; + expect(outcome.outcome).toBe("UPLOAD_FAILED"); + expect(outcome.tenantId).toBe("tenant-1"); + expect(outcome.email).toBe("a@b.com"); + // The stored reason is the customer copy, so the dashboard may show it. + expect(outcome.error).toBeInstanceOf(Error); + expect((outcome.error as Error).message).toBe(SA_ID_INVALID_MESSAGE); + }); + + it("still records any other upstream failure and answers 500 as before", async () => { + (uploadIdentityDocument as any).mockRejectedValueOnce( + new Error("Dr Green identity upload failed: 502 Bad Gateway - "), + ); + const res = await call( + makeReq({ file: jpeg(), documentType: "ID", documentNumber: VALID_SA_ID }), + ); + expect(res.status).toBe(500); + const outcome = (recordIdDocumentOutcome as any).mock.calls[0][0]; + expect(outcome.outcome).toBe("UPLOAD_FAILED"); + expect((outcome.error as Error).message).toMatch(/502/); + }); +}); From ba56f1365917b22deb609132439076d9e4149cc4 Mon Sep 17 00:00:00 2001 From: Gerard Kavanagh Date: Fri, 18 Sep 2026 10:26:42 +0100 Subject: [PATCH 2/7] feat(consent): marketing consent + salutation on every registration path, forwarded to Dr Green (BS-301..305) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the Dr Green September 2026 alignment. - users.title and users.marketingConsentSource (additive, idempotent migration). users.marketingConsentAt REMAINS the consent test: the campaign audience, saved segments, the newsletter unsubscribe and the tenant-admin toggle all read it, so the PRD's separate boolean was not added — two columns for one fact is how a withdrawn customer gets mailed. - All three Dr Green client-create paths send title, marketingConsent and consentSource (budstacks-consultation / budstacks-id-upload / budstacks-shop-register); Dr Green strips them until Phase 3 US-301/302 is released, so there is no conditional code. The shop-register path posted to /client, which no Dr Green controller serves — it now uses /dapp/clients like the other two, with envelope-tolerant id extraction. - Consultation contact step and shop onboarding gain an optional title select (one constant, lib/customers/titles.ts); shop onboarding gains the unticked marketing checkbox next to the required terms consent. Copy reads the store name and is the legal placeholder until confirmed. - Store settings: 'Marketing emails and SMS' toggle. New GET/PATCH /api/store/[slug]/consent writes the local column FIRST and always (a withdrawal never waits on a partner API), audits who/when/where, then forwards to Dr Green's PATCH /dapp/clients/:id/marketing-consent best-effort — the response says whether that landed, with a customer-safe warning mapped from 404/409 (the route is not on Dr Green production yet). - Tenant admin: Marketing column, 'Consented only' filter (?consent=yes), consented count pill, and marketingConsent + marketingConsentAt in the customers CSV (which exports the filtered page). Campaign sending was verified already consent-only (campaign-audience-query, segment-query, materialised recipients) — documented, no change. - GDPR erasure withdraws consent and title with the identity. - Tests: consent route (grant, withdraw, Dr Green 404/409, local-only, bad body), createClient endpoint + envelope extraction, constants, createSaIdClient/KYC payload forwarding, erasure fields. --- .../app/api/consultation/submit/route.ts | 45 ++- nextjs_space/app/api/shop/register/route.ts | 21 +- .../app/api/store/[slug]/consent/route.ts | 204 ++++++++++++++ .../app/store/[slug]/consultation/page.tsx | 4 +- .../app/store/[slug]/settings/page.tsx | 75 +++++ .../customers/customers-table.tsx | 61 ++++- .../app/tenant-admin/customers/page.tsx | 26 +- .../consultation/consultation-form-types.ts | 2 + .../consultation/consultation-form.tsx | 5 + .../consultation/id-upload-form.tsx | 6 +- .../steps/contact-details-step.tsx | 35 ++- .../components/shop/ClientOnboarding.tsx | 6 + .../shop/onboarding/MedicalStep.tsx | 42 +++ .../shop/onboarding/PersonalDetailsStep.tsx | 41 +++ .../shop/onboarding/onboarding-schema.ts | 6 + .../lib/customers/marketing-consent.ts | 39 +++ nextjs_space/lib/customers/titles.ts | 29 ++ .../lib/documents/guides/customers.ts | 5 +- nextjs_space/lib/drgreen-identity.ts | 38 +++ nextjs_space/lib/drgreen/doctor-green-api.ts | 67 ++++- .../lib/drgreen/kyc-client-payload.ts | 10 + nextjs_space/lib/gdpr/erasure.ts | 8 + .../migration.sql | 25 ++ nextjs_space/prisma/schema.prisma | 2 + .../unit/customer-consent-constants.test.ts | 58 ++++ .../tests/unit/drgreen-create-client.test.ts | 92 +++++++ .../tests/unit/drgreen-sa-client.test.ts | 23 ++ nextjs_space/tests/unit/gdpr-erasure.test.ts | 4 + .../tests/unit/kyc-client-payload.test.ts | 17 ++ .../tests/unit/store-consent-route.test.ts | 259 ++++++++++++++++++ 30 files changed, 1216 insertions(+), 39 deletions(-) create mode 100644 nextjs_space/app/api/store/[slug]/consent/route.ts create mode 100644 nextjs_space/lib/customers/marketing-consent.ts create mode 100644 nextjs_space/lib/customers/titles.ts create mode 100644 nextjs_space/prisma/migrations/20260918000000_phase3_title_consent_source/migration.sql create mode 100644 nextjs_space/tests/unit/customer-consent-constants.test.ts create mode 100644 nextjs_space/tests/unit/drgreen-create-client.test.ts create mode 100644 nextjs_space/tests/unit/store-consent-route.test.ts diff --git a/nextjs_space/app/api/consultation/submit/route.ts b/nextjs_space/app/api/consultation/submit/route.ts index 56572650..e3e7ce74 100644 --- a/nextjs_space/app/api/consultation/submit/route.ts +++ b/nextjs_space/app/api/consultation/submit/route.ts @@ -32,6 +32,8 @@ import { resolveTenant } from '@/lib/tenant/tenant-resolver'; import { logger } from '@/lib/logger'; import { apiError, apiValidationError } from '@/lib/api-error'; import { checkPolicyGate } from '@/lib/legal/policy-gate'; +import { CUSTOMER_TITLES, normaliseCustomerTitle } from '@/lib/customers/titles'; +import { CONSENT_SOURCE } from '@/lib/customers/marketing-consent'; /** 409 for "that address already belongs to an account you have not proven you own". */ function accountExistsResponse() { @@ -83,6 +85,9 @@ const consultationSchema = z.object({ // and UNTICKED by default — absent or false records NO consent. marketingConsent: z.boolean().optional(), + // BS-303: optional salutation from the fixed list; "" = not chosen. + title: z.union([z.enum(CUSTOMER_TITLES), z.literal("")]).optional(), + // SA ID-upload (idMode) — see idDocumentSchema above. idDocument: idDocumentSchema.optional(), @@ -204,6 +209,19 @@ export async function POST(request: NextRequest) { idDocumentNumber = documentNumberToForward(idDoc.data, enforceSaId); } + // SA ID-upload path creates the client via verificationType "ID" (no + // medical questionnaire). Otherwise the standard KYC/First-AML payload. + // Phase 3 (BS-301..303): consent and title are attributed to that path. + const idMode = + isSaIdUploadEnabled() && + getTenantVerificationMode(tenant) === "ID_UPLOAD"; + const registrationSource = idMode + ? CONSENT_SOURCE.ID_UPLOAD + : CONSENT_SOURCE.CONSULTATION; + const customerTitle = normaliseCustomerTitle(body.title); + // US-023: consent only on an explicit tick — never inferred. + const consented = body.marketingConsent === true; + // A storefront with no published privacy notice tells visitors exactly that // — so taking a consultation here would collect special-category data with // no Art. 13 notice at all. Checked before ANY account or record is created. @@ -325,10 +343,12 @@ export async function POST(request: NextRequest) { firstName: body.firstName, lastName: body.lastName, phone: [body.phoneCode, body.phoneNumber].filter(Boolean).join(" ").trim() || null, + ...(customerTitle ? { title: customerTitle } : {}), // US-023: a tick at signup grants consent; unticked NEVER clears // an earlier grant — withdrawal is unsubscribe/admin-only. - ...(body.marketingConsent === true && { + ...(consented && { marketingConsentAt: new Date(), + marketingConsentSource: registrationSource, }), updatedAt: new Date(), }, @@ -354,8 +374,10 @@ export async function POST(request: NextRequest) { phone: [body.phoneCode, body.phoneNumber].filter(Boolean).join(" ").trim() || null, role: "PATIENT", tenantId, + title: customerTitle, // US-023: consent only on an explicit tick — never inferred. - marketingConsentAt: body.marketingConsent === true ? new Date() : null, + marketingConsentAt: consented ? new Date() : null, + marketingConsentSource: consented ? registrationSource : null, updatedAt: new Date(), }, }); @@ -393,8 +415,9 @@ export async function POST(request: NextRequest) { tenantId, role: "PATIENT", // US-023: the webhook race must not lose an explicit tick. - ...(body.marketingConsent === true && { + ...(consented && { marketingConsentAt: new Date(), + marketingConsentSource: registrationSource, }), updatedAt: new Date(), }, @@ -463,12 +486,6 @@ export async function POST(request: NextRequest) { const { apiKey, secretKey, apiUrl } = await getTenantDrGreenConfig(tenantId); logger.debug("[Consultation] Dr Green credentials loaded", { tenantId }); - // SA ID-upload path creates the client via verificationType "ID" (no - // medical questionnaire). Otherwise the standard KYC/First-AML payload. - const idMode = - isSaIdUploadEnabled() && - getTenantVerificationMode(tenant) === "ID_UPLOAD"; - let clientId: string | undefined; let kycLink: string | null = null; @@ -480,6 +497,9 @@ export async function POST(request: NextRequest) { phoneCode: body.phoneCode.replace(/[^\+\d]/g, ""), phoneCountryCode: body.countryCode, contactNumber: body.phoneNumber.replace(/\D/g, ""), + title: customerTitle, + marketingConsent: consented, + consentSource: registrationSource, shipping: { address1: body.addressLine1, address2: body.addressLine2 || "", @@ -536,7 +556,12 @@ export async function POST(request: NextRequest) { } } else { // Prepare Dr. Green API payload — lib/drgreen/kyc-client-payload.ts - const drGreenPayload = buildKycClientPayload(body); + const drGreenPayload = buildKycClientPayload({ + ...body, + title: customerTitle, + marketingConsent: consented, + consentSource: registrationSource, + }); // Submit to Dr. Green API via shared client const drGreenResponse = await callDrGreenAPI('/dapp/clients', { diff --git a/nextjs_space/app/api/shop/register/route.ts b/nextjs_space/app/api/shop/register/route.ts index 253dfa79..0b38061e 100644 --- a/nextjs_space/app/api/shop/register/route.ts +++ b/nextjs_space/app/api/shop/register/route.ts @@ -5,6 +5,8 @@ import { prisma } from '@/lib/db'; import { getCurrentTenant } from '@/lib/tenant/tenant'; import { getTenantDrGreenConfig } from '@/lib/tenant/tenant-config'; import { apiError, apiValidationError } from '@/lib/api-error'; +import { normaliseCustomerTitle } from '@/lib/customers/titles'; +import { CONSENT_SOURCE } from '@/lib/customers/marketing-consent'; export const POST = withAuth(async (req, { user }) => { try { @@ -28,13 +30,18 @@ export const POST = withAuth(async (req, { user }) => { } const body = await req.json(); - const { personal, address, medicalRecord } = body; + const { personal, address, medicalRecord, marketingConsent, title } = body; // Validate required fields if (!personal || !address || !medicalRecord) { return apiValidationError("Missing required fields", "POST /api/shop/register"); } + // Phase 3 (BS-301..303): salutation from the fixed list and marketing + // consent — only an explicit true counts; anything else records nothing. + const customerTitle = normaliseCustomerTitle(title ?? personal.title); + const consented = marketingConsent === true; + // Get current tenant for Dr. Green API keys const tenant = await getCurrentTenant(); @@ -121,6 +128,9 @@ export const POST = withAuth(async (req, { user }) => { phoneCode: phoneCode, phoneCountryCode: tenant?.countryCode || "ZA", contactNumber: contactNumber, + title: customerTitle ?? undefined, + marketingConsent: consented, + consentSource: CONSENT_SOURCE.SHOP_REGISTER, shipping: { address1: address.street, city: address.city, @@ -156,6 +166,15 @@ export const POST = withAuth(async (req, { user }) => { // Phone was collected + validated above but previously only sent to // Dr Green — persist it locally so Customers detail/export show it. phone: `${phoneCode} ${contactNumber}`.trim(), + ...(customerTitle ? { title: customerTitle } : {}), + // US-023 rule: a tick grants consent; unticked never clears an + // earlier grant (withdrawal is the customer's own settings toggle). + ...(consented + ? { + marketingConsentAt: new Date(), + marketingConsentSource: CONSENT_SOURCE.SHOP_REGISTER, + } + : {}), // The Dr Green client id was previously returned to the browser but // never persisted, leaving these customers unreachable by webhooks // and status sync — permanently "pending" on every admin surface. diff --git a/nextjs_space/app/api/store/[slug]/consent/route.ts b/nextjs_space/app/api/store/[slug]/consent/route.ts new file mode 100644 index 00000000..61bd68c0 --- /dev/null +++ b/nextjs_space/app/api/store/[slug]/consent/route.ts @@ -0,0 +1,204 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +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 { parseJsonBody } from "@/lib/validation/body"; +import { createAuditLog, AUDIT_ACTIONS, getClientInfo } from "@/lib/audit-log"; +import { + mapDrGreenApiError, + updateClientMarketingConsent, +} from "@/lib/drgreen-identity"; +import { CONSENT_SOURCE } from "@/lib/customers/marketing-consent"; +import { logger } from "@/lib/logger"; + +// Node runtime: the Dr Green client signs requests with node:crypto. +export const runtime = "nodejs"; + +const ROUTE = "store.consent"; + +const consentBodySchema = z.object({ consent: z.boolean() }).strict(); + +const USER_SELECT = { + id: true, + email: true, + drGreenClientId: true, + marketingConsentAt: true, +} as const; + +interface ConsentState { + marketingConsent: boolean; + marketingConsentAt: string | null; +} + +function consentState(marketingConsentAt: Date | null): ConsentState { + return { + marketingConsent: marketingConsentAt !== null, + marketingConsentAt: marketingConsentAt?.toISOString() ?? null, + }; +} + +/** + * Customer-safe explanation when Dr Green did not take the change. 404 is + * either "no such client" or, until Phase 3 is on production, "no such + * route"; both read the same to the customer. + */ +function forwardWarning(error: unknown): string { + const mapped = mapDrGreenApiError(error); + if (mapped?.status === 404) { + return "Your choice is saved for this store. Dr Green's record of your account could not be updated yet."; + } + if (mapped?.status === 409 || mapped?.status === 400) { + return mapped.message + ? `Your choice is saved for this store. Dr Green replied: ${mapped.message}` + : "Your choice is saved for this store, but Dr Green did not accept the change."; + } + return "Your choice is saved for this store. We could not reach Dr Green to update its record; we will keep your choice here."; +} + +/** + * GET /api/store/[slug]/consent — the signed-in customer's own marketing + * consent state, read from the column every BudStacks send is gated on. + */ +export const GET = withAuth(async (_request, { user }, { slug }) => { + try { + parseSlug(slug); + if (!user.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 }); + } + const dbUser = await prisma.users.findFirst({ + where: { email: user.email }, + select: { marketingConsentAt: true }, + }); + if (!dbUser) { + return NextResponse.json({ error: "Account not found" }, { status: 404 }); + } + return NextResponse.json(consentState(dbUser.marketingConsentAt)); + } catch (error) { + return apiError(error, { + route: `GET ${ROUTE}`, + status: 500, + safeMessage: "Could not load your marketing preference.", + }); + } +}); + +/** + * PATCH /api/store/[slug]/consent — BS-304. The customer gives or withdraws + * marketing consent from their settings page. + * + * ORDER MATTERS. The local column is written FIRST and unconditionally: it is + * the consent test for every campaign BudStacks sends (the tenant's own POPIA + * exposure), so a withdrawal must never depend on a partner API answering. + * Dr Green (`PATCH /dapp/clients/:id/marketing-consent`, Phase 3 US-302) is + * then updated best-effort so the KEY holder's export agrees; when it cannot + * be — the route is not on production yet, the client is unknown, or Dr Green + * refuses — the response still succeeds and says so in `warning`, with the + * status logged. The audit row is written either way: who flipped it, when, + * and from where. + */ +export const PATCH = withAuth(async (request, { user }, { slug }) => { + try { + parseSlug(slug); + if (!user.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 }); + } + + const { consent } = await parseJsonBody(request, consentBodySchema); + + const dbUser = await prisma.users.findFirst({ + where: { email: user.email }, + select: USER_SELECT, + }); + if (!dbUser) { + return NextResponse.json({ error: "Account not found" }, { status: 404 }); + } + + const now = new Date(); + const marketingConsentAt = consent ? now : null; + await prisma.users.update({ + where: { id: dbUser.id }, + data: { + marketingConsentAt, + marketingConsentSource: CONSENT_SOURCE.STORE_SETTINGS, + updatedAt: now, + }, + }); + + const { ipAddress, userAgent } = getClientInfo(request.headers); + await createAuditLog({ + action: consent + ? AUDIT_ACTIONS.CUSTOMER_MARKETING_CONSENT_GRANTED + : AUDIT_ACTIONS.CUSTOMER_MARKETING_CONSENT_REVOKED, + entityType: "User", + entityId: dbUser.id, + userId: dbUser.id, + userEmail: dbUser.email, + tenantId: tenant.id, + metadata: { + source: CONSENT_SOURCE.STORE_SETTINGS, + previousConsentAt: dbUser.marketingConsentAt?.toISOString() ?? null, + newConsentAt: marketingConsentAt?.toISOString() ?? null, + }, + ipAddress, + userAgent, + }); + + let forwarded = false; + let warning: string | undefined; + if (dbUser.drGreenClientId) { + try { + const config = await getTenantDrGreenConfig(tenant.id); + await updateClientMarketingConsent({ + clientId: dbUser.drGreenClientId, + consent, + consentSource: CONSENT_SOURCE.STORE_SETTINGS, + config: { apiKey: config.apiKey, secretKey: config.secretKey }, + baseUrl: config.apiUrl, + }); + forwarded = true; + } catch (forwardError) { + const mapped = mapDrGreenApiError(forwardError); + logger.warn("[Consent] Dr Green did not take the consent change", { + userId: dbUser.id, + drGreenClientId: dbUser.drGreenClientId, + consent, + status: mapped?.status ?? null, + error: + forwardError instanceof Error + ? forwardError.message + : String(forwardError), + }); + warning = forwardWarning(forwardError); + } + } else { + logger.info("[Consent] no Dr Green client on this account; local only", { + userId: dbUser.id, + }); + } + + return NextResponse.json({ + ...consentState(marketingConsentAt), + forwarded, + ...(warning ? { warning } : {}), + }); + } catch (error) { + return apiError(error, { + route: `PATCH ${ROUTE}`, + status: 500, + safeMessage: "Could not update your marketing preference. Please try again.", + }); + } +}); diff --git a/nextjs_space/app/store/[slug]/consultation/page.tsx b/nextjs_space/app/store/[slug]/consultation/page.tsx index 107ed4bc..cea716c5 100644 --- a/nextjs_space/app/store/[slug]/consultation/page.tsx +++ b/nextjs_space/app/store/[slug]/consultation/page.tsx @@ -74,7 +74,7 @@ export default async function ConsultationPage({ > Register & verify with your ID - + @@ -95,7 +95,7 @@ export default async function ConsultationPage({ > Register here - + diff --git a/nextjs_space/app/store/[slug]/settings/page.tsx b/nextjs_space/app/store/[slug]/settings/page.tsx index c37eacb1..2cf58e12 100644 --- a/nextjs_space/app/store/[slug]/settings/page.tsx +++ b/nextjs_space/app/store/[slug]/settings/page.tsx @@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; import Link from "next/link"; import { toast } from "@/components/ui/sonner"; import { getTenantBasePath } from "@/lib/tenant/tenant-utils"; @@ -28,6 +29,49 @@ export default function SettingsPage() { checkUserKycStatus().then(setKycStatus); }, []); + // BS-304: marketing consent — read from and written to the column every + // BudStacks campaign is gated on; the change is forwarded to Dr Green. + type ConsentState = { marketingConsent: boolean; marketingConsentAt: string | null }; + const [consent, setConsent] = useState(null); + const [consentSaving, setConsentSaving] = useState(false); + + useEffect(() => { + if (!slug) return; + fetch(`/api/store/${slug}/consent`) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => setConsent(data ?? null)) + .catch(() => setConsent(null)); + }, [slug]); + + const handleConsentChange = async (next: boolean) => { + setConsentSaving(true); + try { + const response = await fetch(`/api/store/${slug}/consent`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ consent: next }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(data?.error || "Could not update your preference"); + } + setConsent({ + marketingConsent: data.marketingConsent === true, + marketingConsentAt: data.marketingConsentAt ?? null, + }); + toast.success( + next + ? "You'll hear about products and offers from this store." + : "You won't receive marketing from this store.", + ); + if (data.warning) toast.warning(data.warning); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Could not update your preference"); + } finally { + setConsentSaving(false); + } + }; + const [formData, setFormData] = useState({ firstName: "", lastName: "", @@ -402,6 +446,37 @@ export default function SettingsPage() { + {/* Marketing preferences — BS-304 */} +
+

+ + Marketing emails and SMS +

+

+ Choose whether we may tell you about products and offers. Emails about + your orders and verification are always sent. +

+
+
+ +

+ {consent === null + ? "Loading your preference…" + : consent.marketingConsent + ? `On${consent.marketingConsentAt ? ` since ${new Date(consent.marketingConsentAt).toLocaleDateString()}` : ""}` + : "Off — you can turn this on at any time"} +

+
+ +
+
+ {/* Back to Dashboard */}
diff --git a/nextjs_space/app/tenant-admin/customers/customers-table.tsx b/nextjs_space/app/tenant-admin/customers/customers-table.tsx index 5a029ae2..aaecd952 100644 --- a/nextjs_space/app/tenant-admin/customers/customers-table.tsx +++ b/nextjs_space/app/tenant-admin/customers/customers-table.tsx @@ -31,6 +31,8 @@ export interface Customer { name: string | null; phone?: string | null; createdAt: Date; + /** BS-305: when the customer opted in to marketing; null = no consent. */ + marketingConsentAt?: Date | null; _count: { orders: number; }; @@ -46,6 +48,8 @@ interface CustomersTableProps { availableTags?: string[]; /** Tenant-wide approval breakdown (unfiltered, matches the stat cards). */ statusCounts?: Record; + /** BS-305: tenant-wide count of customers who opted in to marketing. */ + consentedCount?: number; /** Last "Refresh from Dr Green" run (ISO), or null if never refreshed. */ lastSyncedAt?: string | null; /** False for a cross-tenant super-admin view — nothing to refresh. */ @@ -57,17 +61,24 @@ function StatusPill({ status }: { status: CustomerVerificationStatus }) { return {display.label}; } -/** Filter shape for useTableState — `tag` rides the URL as ?tag=. */ -type CustomerFilters = { tag: string } & Record; +/** Filter shape for useTableState — `tag` rides the URL as ?tag=, + * `consent` as ?consent=yes (BS-305, "Consented only"). */ +type CustomerFilters = { tag: string; consent: string } & Record; /** Module-level so the object identity is stable across renders. */ -const DEFAULT_FILTERS: CustomerFilters = { tag: "" }; +const DEFAULT_FILTERS: CustomerFilters = { tag: "", consent: "" }; + +const CONSENT_FILTER_OPTIONS = [ + { value: "", label: "All customers" }, + { value: "yes", label: "Consented only" }, +]; export function CustomersTable({ customers, totalCount, availableTags = [], statusCounts, + consentedCount, lastSyncedAt, canRefresh = false, }: CustomersTableProps) { @@ -103,8 +114,10 @@ export function CustomersTable({ const tagFilter = normalizeTag(filters.tag || ""); const hasTagFilter = tagFilter.length > 0; + const consentFilter = filters.consent === "yes"; + const hasSearchQuery = search.trim().length > 0; - const hasActiveFilters = hasSearchQuery || hasTagFilter; + const hasActiveFilters = hasSearchQuery || hasTagFilter || consentFilter; const noResults = totalCount === 0 && hasActiveFilters; const tagOptions = useMemo(() => { @@ -119,6 +132,11 @@ export function CustomersTable({ }, [availableTags, hasTagFilter, tagFilter]); const emptyDescription = useMemo(() => { + if (consentFilter) { + return hasSearchQuery || hasTagFilter + ? "No customers matching those filters have opted in to marketing." + : "No customers have opted in to marketing yet."; + } if (hasSearchQuery && hasTagFilter) { return `No customers tagged "${tagFilter}" match "${search}". Try different filters.`; } @@ -129,13 +147,16 @@ export function CustomersTable({ return `No customers found matching "${search}". Try a different search term.`; } return "No customers yet. Share your store URL to get started."; - }, [hasSearchQuery, hasTagFilter, search, tagFilter]); + }, [consentFilter, hasSearchQuery, hasTagFilter, search, tagFilter]); const handleClearFilters = () => { // Consecutive URL-state setters clobber each other (each reads the not-yet- // updated params), so clear with exactly one call per case. - if (hasSearchQuery && hasTagFilter) { + const active = [hasSearchQuery, hasTagFilter, consentFilter].filter(Boolean).length; + if (active > 1) { resetFilters(); + } else if (consentFilter) { + setFilter("consent", null); } else if (hasTagFilter) { setFilter("tag", null); } else { @@ -155,6 +176,12 @@ export function CustomersTable({ : "N/A", orders: c._count.orders, createdAt: format(new Date(c.createdAt), "yyyy-MM-dd"), + // BS-305: consent travels with every export; the rows are the + // on-screen (already filtered) page, so "Consented only" is honoured. + marketingConsent: c.marketingConsentAt ? "yes" : "no", + marketingConsentAt: c.marketingConsentAt + ? format(new Date(c.marketingConsentAt), "yyyy-MM-dd HH:mm") + : "", })); const csvHeaders = [ @@ -164,6 +191,8 @@ export function CustomersTable({ { key: "status" as const, label: "Status" }, { key: "orders" as const, label: "Orders" }, { key: "createdAt" as const, label: "Joined" }, + { key: "marketingConsent" as const, label: "Marketing consent" }, + { key: "marketingConsentAt" as const, label: "Consent given" }, ]; await exportToCSV( @@ -227,6 +256,13 @@ export function CustomersTable({ /> )} + setFilter("consent", value || null)} + options={CONSENT_FILTER_OPTIONS} + aria-label="Filter by marketing consent" + /> + {statusCounts.NOT_SUBMITTED} not submitted + {typeof consentedCount === "number" && ( + {consentedCount} consented to marketing + )}
{canRefresh && (
@@ -282,7 +321,7 @@ export function CustomersTable({ variant="muted" size="default" action={{ - label: hasTagFilter ? "Clear filters" : "Clear search", + label: hasTagFilter || consentFilter ? "Clear filters" : "Clear search", onClick: handleClearFilters, variant: "outline", }} @@ -324,6 +363,7 @@ export function CustomersTable({ className="hidden md:table-cell" /> Status + Marketing
+ {/* BS-303: optional salutation — the customer's own choice, never + inferred from an identity document. */} +
+ + +
+
@@ -346,7 +379,7 @@ export function ContactDetailsStep({ onChange={(e) => onUpdate({ marketingConsent: e.target.checked })} className="h-4 w-4 mt-0.5 text-emerald-600 focus:ring-emerald-500" /> - Email me offers and updates + {marketingConsentCopy(storeName)}
diff --git a/nextjs_space/components/shop/ClientOnboarding.tsx b/nextjs_space/components/shop/ClientOnboarding.tsx index f14e4540..78fb9152 100644 --- a/nextjs_space/components/shop/ClientOnboarding.tsx +++ b/nextjs_space/components/shop/ClientOnboarding.tsx @@ -42,6 +42,7 @@ export function ClientOnboarding() { const personalForm = useForm({ resolver: zodResolver(personalDetailsSchema), defaultValues: formData.personal || { + title: "", firstName: "", lastName: "", email: user?.primaryEmailAddress?.emailAddress || "", @@ -69,6 +70,7 @@ export function ClientOnboarding() { previousCannabisUse: false, doctorApproval: false, consent: false, + marketingConsent: false, }, }); @@ -104,6 +106,10 @@ export function ClientOnboarding() { personal: formData.personal, address: formData.address, medicalRecord: data, + // Phase 3 (BS-301..303): top-level so the route never reads + // consent out of the medical record. + title: formData.personal?.title || undefined, + marketingConsent: data.marketingConsent === true, }), }); diff --git a/nextjs_space/components/shop/onboarding/MedicalStep.tsx b/nextjs_space/components/shop/onboarding/MedicalStep.tsx index 45d86e73..c84011d7 100644 --- a/nextjs_space/components/shop/onboarding/MedicalStep.tsx +++ b/nextjs_space/components/shop/onboarding/MedicalStep.tsx @@ -16,12 +16,15 @@ import { } from "@/components/ui/form"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import type { Medical } from "./onboarding-schema"; +import { marketingConsentCopy } from "@/lib/customers/marketing-consent"; interface MedicalStepProps { form: UseFormReturn; onSubmit: (data: Medical) => Promise | void; onBack: () => void; isSubmitting: boolean; + /** Read into the marketing-consent copy (BS-302). */ + storeName?: string; } export function MedicalStep({ @@ -29,6 +32,7 @@ export function MedicalStep({ onSubmit, onBack, isSubmitting, + storeName, }: MedicalStepProps) { return ( )} /> + {/* BS-302 (POPIA): marketing consent — optional, UNTICKED by + default, separate from the required terms consent above. */} + ( + + + field.onChange(checked === true)} + /> + +
+ + {marketingConsentCopy(storeName)} + +

+ Optional. You can change this any time in your account + settings. +

+
+
+ )} + />