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({
Document type
onUpdate({ documentType: v as IdDocumentType })}
+ onValueChange={(v) => {
+ setNumberError(null);
+ onUpdate({ documentType: v as IdDocumentType });
+ }}
>
- National ID
+ {idOptionLabel}
Passport
Driving licence
@@ -100,9 +133,22 @@ 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 }) {
setDocumentType(e.target.value)}
+ onChange={(e) => {
+ setNumberError(null);
+ setDocumentType(e.target.value);
+ }}
disabled={submitting}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
>
- {DOC_TYPES.map((t) => (
+ {docTypes.map((t) => (
{t.label}
@@ -109,17 +147,32 @@ export function IdDocumentUpload({ slug }: { slug: string }) {
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({
Document type
setDocumentType(v as IdDocumentType)}
+ onValueChange={(v) => {
+ setNumberError(null);
+ setDocumentType(v as IdDocumentType);
+ }}
>
- National ID
+ {idOptionLabel}
Passport
Driving licence
@@ -105,9 +134,22 @@ 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.
+
+
+
+
Marketing emails and SMS
+
+ {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
@@ -392,6 +432,13 @@ export function CustomersTable({
—
)}
+
+ {customer.marketingConsentAt ? (
+ Consented
+ ) : (
+ No consent
+ )}
+
{customer._count.orders}
diff --git a/nextjs_space/app/tenant-admin/customers/page.tsx b/nextjs_space/app/tenant-admin/customers/page.tsx
index 96d07ade..08382ce6 100644
--- a/nextjs_space/app/tenant-admin/customers/page.tsx
+++ b/nextjs_space/app/tenant-admin/customers/page.tsx
@@ -85,6 +85,10 @@ export default async function CustomersListPage({
// US-024: tag filter param, matched in the tag's canonical form. A malformed
// value (blank after trim, over-long) is treated as no filter — a shared URL
// should degrade to the full list, not an error page.
+ // BS-305: "Consented only" — customers who opted in to marketing
+ // (users.marketingConsentAt set). Same test the campaign audience uses.
+ const consentFilter = params.consent === "yes";
+
const rawTag = typeof params.tag === "string" ? params.tag : "";
const parsedTag = rawTag ? tagSchema.safeParse(rawTag) : null;
const tagFilter = parsedTag?.success ? parsedTag.data : "";
@@ -117,6 +121,7 @@ export default async function CustomersListPage({
role: "PATIENT",
...(tenantId && { tenantId }),
...notErased,
+ ...(consentFilter && { marketingConsentAt: { not: null } }),
};
// Apply search filter (case-insensitive across multiple fields)
@@ -149,7 +154,7 @@ export default async function CustomersListPage({
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
- const [filteredCount, rawCustomers, totalCustomersCount, recentSignupsCount, tenantQuestionnaires, availableTags, allCustomerEmails, lastStatusRefresh] =
+ const [filteredCount, rawCustomers, totalCustomersCount, recentSignupsCount, tenantQuestionnaires, availableTags, allCustomerEmails, lastStatusRefresh, consentedCount] =
await Promise.all([
prisma.users.count({ where: whereClause }),
prisma.users.findMany({
@@ -160,6 +165,7 @@ export default async function CustomersListPage({
name: true,
phone: true,
createdAt: true,
+ marketingConsentAt: true,
_count: {
select: {
orders: true,
@@ -230,6 +236,16 @@ export default async function CustomersListPage({
select: { createdAt: true },
})
: Promise.resolve(null),
+ // BS-305: tenant-wide consented count for the summary pill (unfiltered,
+ // like the other counts).
+ prisma.users.count({
+ where: {
+ role: "PATIENT",
+ ...(tenantId && { tenantId }),
+ ...notErased,
+ marketingConsentAt: { not: null },
+ },
+ }),
]);
// Backfill name/phone for customers whose intake saved the name only to
@@ -263,7 +279,12 @@ export default async function CustomersListPage({
};
const customers = rawCustomers.map(
- (customer: { email: string; name: string | null; phone: string | null }) => {
+ (customer: {
+ email: string;
+ name: string | null;
+ phone: string | null;
+ marketingConsentAt: Date | null;
+ }) => {
const q = questionnaireByEmail.get(customer.email.toLowerCase());
return {
...customer,
@@ -328,6 +349,7 @@ export default async function CustomersListPage({
diff --git a/nextjs_space/components/consultation/id-upload-form.tsx b/nextjs_space/components/consultation/id-upload-form.tsx
index c167a4b5..9c3a9868 100644
--- a/nextjs_space/components/consultation/id-upload-form.tsx
+++ b/nextjs_space/components/consultation/id-upload-form.tsx
@@ -23,6 +23,8 @@ const STEP_NAMES = ["Contact Details", "Address Information", "Verify Identity"]
interface IdUploadFormProps {
tenantSlug: string;
+ /** The store's business name — read into the marketing-consent copy (BS-302). */
+ storeName?: string;
}
const fileToBase64 = (file: File): Promise =>
@@ -37,7 +39,7 @@ const fileToBase64 = (file: File): Promise =>
reader.readAsDataURL(file);
});
-export function IdUploadForm({ tenantSlug }: IdUploadFormProps) {
+export function IdUploadForm({ tenantSlug, storeName }: IdUploadFormProps) {
const router = useRouter();
const [currentStep, setCurrentStep] = useState(1);
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -59,6 +61,7 @@ export function IdUploadForm({ tenantSlug }: IdUploadFormProps) {
password: "",
confirmPassword: "",
marketingConsent: false,
+ title: "",
addressLine1: "",
addressLine2: "",
@@ -178,6 +181,7 @@ export function IdUploadForm({ tenantSlug }: IdUploadFormProps) {
return (
diff --git a/nextjs_space/components/consultation/steps/contact-details-step.tsx b/nextjs_space/components/consultation/steps/contact-details-step.tsx
index eb849d5d..49ea43a4 100644
--- a/nextjs_space/components/consultation/steps/contact-details-step.tsx
+++ b/nextjs_space/components/consultation/steps/contact-details-step.tsx
@@ -21,18 +21,27 @@ import { CalendarIcon, Eye, EyeOff } from "lucide-react";
import { format } from "date-fns";
import type { ConsultationFormData } from "../consultation-form-types";
import { COUNTRY_CODES } from "@/lib/consultation-constants";
+import { CUSTOMER_TITLES } from "@/lib/customers/titles";
+import { marketingConsentCopy } from "@/lib/customers/marketing-consent";
import { cn } from "@/lib/utils";
interface ContactDetailsStepProps {
data: ConsultationFormData;
onUpdate: (data: Partial) => void;
onNext: () => void;
+ /** Read into the marketing-consent copy (BS-302). */
+ storeName?: string;
}
+// Radix Select rejects an empty-string item value, so "not chosen" is a
+// sentinel that maps back to "" in the form data.
+const NO_TITLE = "none";
+
export function ContactDetailsStep({
data,
onUpdate,
onNext,
+ storeName,
}: ContactDetailsStepProps) {
const [errors, setErrors] = useState>({});
const [showPassword, setShowPassword] = useState(false);
@@ -80,6 +89,30 @@ export function ContactDetailsStep({
+ {/* BS-303: optional salutation — the customer's own choice, never
+ inferred from an identity document. */}
+
+ Title
+
+ onUpdate({ title: value === NO_TITLE ? "" : value })
+ }
+ >
+
+
+
+
+ Prefer not to say
+ {CUSTOMER_TITLES.map((title) => (
+
+ {title}
+
+ ))}
+
+
+
+
First Name*
@@ -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.
+
+
+
+ )}
+ />
;
@@ -51,6 +59,39 @@ export function PersonalDetailsStep({
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
+ {/* BS-303: optional salutation — the customer's own choice. */}
+ (
+
+
+ Title (optional)
+
+
+
+ Prefer not to say
+ {CUSTOMER_TITLES.map((title) => (
+
+ {title}
+
+ ))}
+
+
+
+
+ )}
+ />
val, "You must consent to continue"),
+ // BS-302 (POPIA): marketing consent — optional and UNTICKED by default.
+ // Distinct from the terms `consent` above, which is required.
+ marketingConsent: z.boolean().optional(),
});
export type PersonalDetails = z.infer;
diff --git a/nextjs_space/lib/customers/marketing-consent.ts b/nextjs_space/lib/customers/marketing-consent.ts
new file mode 100644
index 00000000..0005b49c
--- /dev/null
+++ b/nextjs_space/lib/customers/marketing-consent.ts
@@ -0,0 +1,39 @@
+/**
+ * Marketing consent — Dr Green Phase 3 (BS-301/302/304).
+ *
+ * THE CONSENT TEST IS `users.marketingConsentAt !== null` (Email Phase 2,
+ * US-023) and nothing else: the campaign audience, saved segments, the
+ * newsletter unsubscribe and the tenant-admin toggle all read that column. A
+ * separate boolean was deliberately NOT added — two columns for one fact is
+ * how a withdrawn customer gets mailed. What Phase 3 adds is attribution
+ * (`marketingConsentSource`, below) and forwarding to Dr Green.
+ *
+ * Pure and browser-safe (the forms import the copy).
+ */
+
+/** Where a consent value came from — sent to Dr Green as `consentSource`. */
+export const CONSENT_SOURCE = {
+ CONSULTATION: "budstacks-consultation",
+ ID_UPLOAD: "budstacks-id-upload",
+ SHOP_REGISTER: "budstacks-shop-register",
+ STORE_SETTINGS: "budstacks-settings",
+} as const;
+
+export type ConsentSource = (typeof CONSENT_SOURCE)[keyof typeof CONSENT_SOURCE];
+
+const DEFAULT_STORE_NAME = "this store";
+
+/**
+ * The checkbox label. Placeholder wording until Ricardo/legal confirm the
+ * final copy and whether SMS is included (PRD open question) — change it here
+ * and every form follows.
+ */
+export function marketingConsentCopy(storeName?: string | null): string {
+ const name = storeName?.trim() || DEFAULT_STORE_NAME;
+ return `Keep me informed about products and offers from ${name} by email or SMS`;
+}
+
+/** `true` only for an explicit boolean true — consent is never inferred. */
+export function isExplicitConsent(value: unknown): boolean {
+ return value === true;
+}
diff --git a/nextjs_space/lib/customers/titles.ts b/nextjs_space/lib/customers/titles.ts
new file mode 100644
index 00000000..02faf2fe
--- /dev/null
+++ b/nextjs_space/lib/customers/titles.ts
@@ -0,0 +1,29 @@
+/**
+ * Customer salutation — Dr Green Phase 3 (BS-303).
+ *
+ * ONE constant reused by the consultation contact step, the shop onboarding
+ * form, both registration routes and the settings surfaces; the same list Dr
+ * Green's CreateClientDto validates against (US-302). Free choice by the
+ * customer — never derived from an identity document.
+ *
+ * Pure and browser-safe.
+ */
+export const CUSTOMER_TITLES = ["Mr", "Mrs", "Ms", "Mx", "Dr", "Prof"] as const;
+
+export type CustomerTitle = (typeof CUSTOMER_TITLES)[number];
+
+export function isCustomerTitle(value: unknown): value is CustomerTitle {
+ return (
+ typeof value === "string" &&
+ (CUSTOMER_TITLES as readonly string[]).includes(value)
+ );
+}
+
+/**
+ * The stored form of a submitted title: one of the list, or null for "not
+ * chosen" (an empty string, whitespace, or anything off the list).
+ */
+export function normaliseCustomerTitle(value: unknown): CustomerTitle | null {
+ const trimmed = typeof value === "string" ? value.trim() : "";
+ return isCustomerTitle(trimmed) ? trimmed : null;
+}
diff --git a/nextjs_space/lib/documents/guides/customers.ts b/nextjs_space/lib/documents/guides/customers.ts
index 509d94d2..8d888641 100644
--- a/nextjs_space/lib/documents/guides/customers.ts
+++ b/nextjs_space/lib/documents/guides/customers.ts
@@ -15,7 +15,7 @@ export const customersGuide: Guide = {
"Everyone who shops with you — their details, their history, the labels you put on them, and what you're allowed to email them about.",
status: "published",
video: { youtubeId: "XiK9lg0L8ME", title: "The Customer Book" },
- updatedAt: "2026-08-15",
+ updatedAt: "2026-09-18",
sections: [
{
id: "list",
@@ -36,7 +36,8 @@ export const customersGuide: Guide = {
"The Orders column shows how many orders each person has placed — that column is how you spot a regular.",
"Once you have tagged anybody, a tag dropdown appears next to the search box and narrows the list to one label.",
"Rows per page runs from 10 to 100, with first and last buttons — the heading tells you how many matched.",
- "Export downloads what's on screen as a spreadsheet file: name, email, phone, order count, and join date.",
+ "Export downloads what's on screen as a spreadsheet file: name, email, phone, status, order count, join date, and marketing consent (yes or no, plus when it was given).",
+ "A Marketing column shows who has agreed to hear from you. The \"Consented only\" filter narrows the list — and the export — to those people; campaigns only ever go to them anyway.",
"A red \"ID upload failed\" pill marks anyone whose ID document didn't make it through at sign-up — they stay unverified until it's re-uploaded.",
"View opens that person's own page, covered next.",
],
diff --git a/nextjs_space/lib/drgreen-identity.ts b/nextjs_space/lib/drgreen-identity.ts
index a9a514f5..a9484b3f 100644
--- a/nextjs_space/lib/drgreen-identity.ts
+++ b/nextjs_space/lib/drgreen-identity.ts
@@ -273,6 +273,12 @@ export interface CreateSaIdClientParams {
country: string;
postalCode: string;
};
+ // Phase 3 (BS-301): salutation and marketing consent. Optional on Dr Green
+ // (US-301/302); before that release its DTO whitelist strips them silently,
+ // so sending them early is safe. Consent is only ever an explicit true.
+ title?: string | null;
+ marketingConsent?: boolean;
+ consentSource?: string;
config: DrGreenIdentityConfig;
baseUrl?: string;
}
@@ -317,6 +323,9 @@ export async function createSaIdClient(
countryCode: DR_GREEN_SA_COUNTRY_CODE,
},
// No medicalRecord on the ID path.
+ ...(params.title ? { title: params.title } : {}),
+ marketingConsent: params.marketingConsent === true,
+ ...(params.consentSource ? { consentSource: params.consentSource } : {}),
};
const response = await callDrGreenAPI(DAPP_CLIENTS_ENDPOINT, {
@@ -333,3 +342,32 @@ export async function createSaIdClient(
}
return { clientId };
}
+
+// CONTRACT: Dr Green Phase 3 (US-302) — customer-initiated marketing-consent
+// change, PATCH /dapp/clients/:clientId/marketing-consent { consent,
+// consentSource }. NFT-scoped by DualAuthGuard; the body is the signed
+// payload. Until that release is on production Dr Green answers 404 for the
+// route — callers treat the forward as best-effort (the local column is the
+// consent test for everything BudStacks sends) and report whether it landed.
+const marketingConsentEndpoint = (clientId: string) =>
+ `/dapp/clients/${encodeURIComponent(clientId)}/marketing-consent`;
+
+export async function updateClientMarketingConsent(params: {
+ clientId: string;
+ consent: boolean;
+ consentSource: string;
+ config: DrGreenIdentityConfig;
+ baseUrl?: string;
+}): Promise {
+ const { clientId, consent, consentSource, config, baseUrl } = params;
+ if (!config?.apiKey || !config?.secretKey) {
+ throw new Error('MISSING_CREDENTIALS');
+ }
+ await callDrGreenAPI(marketingConsentEndpoint(clientId), {
+ method: 'PATCH',
+ apiKey: config.apiKey,
+ secretKey: config.secretKey,
+ baseUrl,
+ body: { consent, consentSource },
+ });
+}
diff --git a/nextjs_space/lib/drgreen/doctor-green-api.ts b/nextjs_space/lib/drgreen/doctor-green-api.ts
index bd85f11c..9c780200 100644
--- a/nextjs_space/lib/drgreen/doctor-green-api.ts
+++ b/nextjs_space/lib/drgreen/doctor-green-api.ts
@@ -599,6 +599,11 @@ export async function updateClient(
* - camelCase keys
* - nested 'medicalRecord' with specific booleans (medicalHistory0..16)
*/
+// CONTRACT: Dr Green — POST /dapp/clients, the create route DualAuthGuard
+// serves for storefronts (client.controller.ts). Shared by the KYC path here
+// and the SA ID path in lib/drgreen-identity.ts.
+const DAPP_CLIENTS_ENDPOINT = "/dapp/clients";
+
export async function createClient(
clientData: {
firstName: string;
@@ -644,6 +649,11 @@ export async function createClient(
medicalHistory16?: boolean;
prescriptionsSupplements?: string;
};
+ // Phase 3 (BS-301): optional on Dr Green (US-301/302); stripped by its DTO
+ // whitelist before that release, so sending early is safe.
+ title?: string;
+ marketingConsent?: boolean;
+ consentSource?: string;
},
config: DoctorGreenConfig,
): Promise<{ clientId: string; kycLink?: string }> {
@@ -657,29 +667,60 @@ export async function createClient(
phoneCountryCode: clientData.phoneCountryCode,
contactNumber: clientData.contactNumber,
shipping: clientData.shipping,
- medicalRecord: clientData.medicalRecord
+ medicalRecord: clientData.medicalRecord,
+ ...(clientData.title ? { title: clientData.title } : {}),
+ marketingConsent: clientData.marketingConsent === true,
+ ...(clientData.consentSource ? { consentSource: clientData.consentSource } : {}),
};
- // Response is nested: { success: true, data: { data: { clientId, kycLink } } }
- // OR sometimes: { success: true, data: { clientId, kycLink } } depending on proxy version
- // We type it as 'any' to handle the normalization manually
- const response = await doctorGreenRequest("/client", { // Endpoint is /client singular? Findings say POST /client
+ // CONTRACT: Dr Green — POST /dapp/clients (DualAuthGuard, the same route the
+ // consultation submit and the SA ID path use). This used to post to
+ // "/client", which no Dr Green controller serves (client.controller.ts
+ // registers only dapp/clients and dapp/clients/switch-to-id), so every call
+ // 404'd; the store-name comment that justified it was never verified.
+ const response = await doctorGreenRequest(DAPP_CLIENTS_ENDPOINT, {
method: "POST",
body: payload,
config,
});
- // Normalize response
- const rawData = response.data || {};
- const nestedData = rawData.data || rawData;
-
- const clientId = nestedData.clientId || rawData.clientId;
- const kycLink = nestedData.kycLink || rawData.kycLink;
-
+ const { clientId, kycLink } = extractCreatedClient(response);
if (!clientId) {
- console.error("DrGreen createClient failed to return clientId", response);
+ console.error("DrGreen createClient failed to return clientId", {
+ topKeys: Object.keys(response || {}),
+ });
throw new Error("Failed to create client: No ID returned");
}
return { clientId, kycLink };
}
+
+
+/**
+ * Dr Green nests the created client differently across versions and the
+ * global response interceptor may wrap it again: { data: { client: {...} } },
+ * { data: { data: { clientId } } }, { data: { clientId } }, { client: {...} }.
+ * Mirrors the tolerant extraction the consultation submit route performs.
+ */
+export function extractCreatedClient(
+ response: any,
+): { clientId?: string; kycLink?: string } {
+ const data = response?.data ?? response ?? {};
+ const nested = data?.data ?? data;
+ const client = nested?.client ?? data?.client ?? response?.client;
+ return {
+ clientId:
+ client?.id ||
+ nested?.clientId ||
+ data?.clientId ||
+ nested?.id ||
+ response?.clientId ||
+ undefined,
+ kycLink:
+ client?.kycLink ||
+ nested?.kycLink ||
+ data?.kycLink ||
+ response?.kycLink ||
+ undefined,
+ };
+}
diff --git a/nextjs_space/lib/drgreen/kyc-client-payload.ts b/nextjs_space/lib/drgreen/kyc-client-payload.ts
index 086e94ae..eea1003a 100644
--- a/nextjs_space/lib/drgreen/kyc-client-payload.ts
+++ b/nextjs_space/lib/drgreen/kyc-client-payload.ts
@@ -61,6 +61,12 @@ export interface KycRegistrationInput {
cannabisReducesMeds: boolean;
cannabisFrequency?: string;
cannabisAmountPerDay?: string;
+
+ // Phase 3 (BS-301): optional on Dr Green (US-301/302); stripped by its DTO
+ // whitelist before that release. Consent is only ever an explicit true.
+ title?: string | null;
+ marketingConsent?: boolean;
+ consentSource?: string;
}
/** YYYY-MM-DD; today when the form sent nothing (unchanged legacy default). */
@@ -127,6 +133,10 @@ export function buildKycClientPayload(body: KycRegistrationInput) {
...clientBusiness(body),
+ ...(body.title ? { title: body.title } : {}),
+ marketingConsent: body.marketingConsent === true,
+ ...(body.consentSource ? { consentSource: body.consentSource } : {}),
+
medicalRecord: {
dob: formatDateOfBirth(body.dateOfBirth),
gender: body.gender,
diff --git a/nextjs_space/lib/gdpr/erasure.ts b/nextjs_space/lib/gdpr/erasure.ts
index 02630da6..555c74e3 100644
--- a/nextjs_space/lib/gdpr/erasure.ts
+++ b/nextjs_space/lib/gdpr/erasure.ts
@@ -146,6 +146,9 @@ export function buildAnonymizedUserData(userId: string): {
resetTokenExpiry: null;
drGreenClientId: null;
clerkUserId: null;
+ title: null;
+ marketingConsentAt: null;
+ marketingConsentSource: null;
} {
return {
email: `deleted-${userId}@${ERASURE_EMAIL_DOMAIN}`,
@@ -160,6 +163,11 @@ export function buildAnonymizedUserData(userId: string): {
resetTokenExpiry: null,
drGreenClientId: null,
clerkUserId: null,
+ // BS-305: an erased customer can never be addressed or marketed to —
+ // consent is withdrawn with the identity, not left behind on the row.
+ title: null,
+ marketingConsentAt: null,
+ marketingConsentSource: null,
};
}
diff --git a/nextjs_space/prisma/migrations/20260918000000_phase3_title_consent_source/migration.sql b/nextjs_space/prisma/migrations/20260918000000_phase3_title_consent_source/migration.sql
new file mode 100644
index 00000000..4fe93c37
--- /dev/null
+++ b/nextjs_space/prisma/migrations/20260918000000_phase3_title_consent_source/migration.sql
@@ -0,0 +1,25 @@
+-- Dr Green Phase 3 alignment (BS-302 / BS-303) — salutation and consent
+-- attribution on users. See tasks/prd-drgreen-phase-alignment-2026-09.md.
+--
+-- entrypoint.sh runs `prisma migrate deploy` on boot, which only APPLIES
+-- migration files. Hand-written and IDEMPOTENT (IF NOT EXISTS) so it is safe
+-- to (re)apply on any environment — PRD-213/220/301 pattern. Additive,
+-- nullable, no index, no table rewrite.
+--
+-- `marketingConsentAt` (Email Phase 2, US-023) REMAINS the consent test
+-- everywhere. A separate boolean was deliberately not added: the campaign
+-- audience, saved segments, the newsletter unsubscribe and the tenant-admin
+-- toggle all read the timestamp, and a second column for the same fact is how
+-- a withdrawn customer ends up mailed. `marketingConsentSource` records which
+-- registration path or settings action last changed it (attribution only).
+-- `title` is the salutation the customer chose from the fixed list; it is
+-- never derived from an identity document.
+--
+-- Canonical form verified against:
+-- prisma migrate diff --from-schema-datamodel \
+-- --to-schema-datamodel prisma/schema.prisma --script
+-- => ALTER TABLE "users" ADD COLUMN "title" TEXT, ADD COLUMN "marketingConsentSource" TEXT;
+
+ALTER TABLE "users"
+ ADD COLUMN IF NOT EXISTS "title" TEXT,
+ ADD COLUMN IF NOT EXISTS "marketingConsentSource" TEXT;
diff --git a/nextjs_space/prisma/schema.prisma b/nextjs_space/prisma/schema.prisma
index 5f29fbb0..aed9d0aa 100644
--- a/nextjs_space/prisma/schema.prisma
+++ b/nextjs_space/prisma/schema.prisma
@@ -984,6 +984,8 @@ model users {
address Json?
isActive Boolean @default(true)
marketingConsentAt DateTime? // Email Phase 2 (US-016/US-023): when this customer opted in to marketing. NULL = no consent, never mail them a campaign.
+ marketingConsentSource String? // BS-302: which registration path or settings action last changed consent (budstacks-consultation | budstacks-id-upload | budstacks-shop-register | budstacks-settings). Attribution only — marketingConsentAt stays the consent test.
+ title String? // BS-303: salutation the customer chose (Mr | Mrs | Ms | Mx | Dr | Prof); forwarded to Dr Green, never derived from an identity document.
reorderReminderAt DateTime? // US-028: when the reorder-reminder automation last mailed this customer. NULL = never. The once-per-window guard is a conditional write against this column.
reorderReminderToken String? @unique // US-028: this customer's permanent opt-out credential for the reorder reminder. Minted on the first reminder and never rotated — the link lives in an inbox and has to keep working.
consultations consultations[]
diff --git a/nextjs_space/tests/unit/customer-consent-constants.test.ts b/nextjs_space/tests/unit/customer-consent-constants.test.ts
new file mode 100644
index 00000000..92520f6c
--- /dev/null
+++ b/nextjs_space/tests/unit/customer-consent-constants.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ CUSTOMER_TITLES,
+ isCustomerTitle,
+ normaliseCustomerTitle,
+} from "@/lib/customers/titles";
+import {
+ CONSENT_SOURCE,
+ isExplicitConsent,
+ marketingConsentCopy,
+} from "@/lib/customers/marketing-consent";
+
+describe("customer titles (BS-303)", () => {
+ it("is the Dr Green list, in order", () => {
+ expect([...CUSTOMER_TITLES]).toEqual(["Mr", "Mrs", "Ms", "Mx", "Dr", "Prof"]);
+ });
+
+ it("normalises a submitted value to the list or null", () => {
+ expect(normaliseCustomerTitle("Dr")).toBe("Dr");
+ expect(normaliseCustomerTitle(" Ms ")).toBe("Ms");
+ expect(normaliseCustomerTitle("")).toBeNull();
+ expect(normaliseCustomerTitle("none")).toBeNull();
+ expect(normaliseCustomerTitle("Sir")).toBeNull();
+ expect(normaliseCustomerTitle(undefined)).toBeNull();
+ expect(normaliseCustomerTitle(42)).toBeNull();
+ expect(isCustomerTitle("Prof")).toBe(true);
+ expect(isCustomerTitle("prof")).toBe(false);
+ });
+});
+
+describe("marketing consent (BS-302)", () => {
+ it("names the four sources BudStacks attributes consent to", () => {
+ expect(Object.values(CONSENT_SOURCE)).toEqual([
+ "budstacks-consultation",
+ "budstacks-id-upload",
+ "budstacks-shop-register",
+ "budstacks-settings",
+ ]);
+ });
+
+ it("reads the store name into the copy, with a fallback", () => {
+ expect(marketingConsentCopy("Lekker Weed")).toBe(
+ "Keep me informed about products and offers from Lekker Weed by email or SMS",
+ );
+ expect(marketingConsentCopy(" ")).toBe(
+ "Keep me informed about products and offers from this store by email or SMS",
+ );
+ expect(marketingConsentCopy(undefined)).toMatch(/from this store/);
+ });
+
+ it("treats only an explicit true as consent", () => {
+ expect(isExplicitConsent(true)).toBe(true);
+ for (const v of [false, "true", 1, undefined, null, {}]) {
+ expect(isExplicitConsent(v)).toBe(false);
+ }
+ });
+});
diff --git a/nextjs_space/tests/unit/drgreen-create-client.test.ts b/nextjs_space/tests/unit/drgreen-create-client.test.ts
new file mode 100644
index 00000000..1574ab98
--- /dev/null
+++ b/nextjs_space/tests/unit/drgreen-create-client.test.ts
@@ -0,0 +1,92 @@
+import { describe, expect, it, vi, beforeEach } from "vitest";
+
+vi.mock("@/lib/drgreen/drgreen-api-client", () => ({
+ callDrGreenAPI: vi.fn(),
+}));
+vi.mock("@/lib/exchange-rates", () => ({ convertFromEUR: vi.fn(async (v: number) => v) }));
+vi.mock("@/lib/logger", () => ({
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
+}));
+
+import { callDrGreenAPI } from "@/lib/drgreen/drgreen-api-client";
+import { createClient, extractCreatedClient } from "@/lib/drgreen/doctor-green-api";
+
+/**
+ * The shop-register create path (BS-301). It used to POST to "/client", which
+ * no Dr Green controller serves; it now uses the same /dapp/clients route as
+ * the other two create paths and forwards consent + title.
+ */
+const clientData = {
+ firstName: "Ana",
+ lastName: "Silva",
+ email: "ana@example.com",
+ phoneCode: "+27",
+ phoneCountryCode: "ZA",
+ contactNumber: "821234567",
+ shipping: {
+ address1: "1 Long St",
+ city: "Cape Town",
+ state: "Western Cape",
+ country: "South Africa",
+ countryCode: "ZA",
+ postalCode: "8001",
+ },
+ medicalRecord: {} as any,
+};
+const config = { apiKey: "k", secretKey: "s", apiUrl: "https://stage/api/v1" };
+
+beforeEach(() => vi.clearAllMocks());
+
+describe("createClient", () => {
+ it("posts to /dapp/clients with consent + title and reads data.client.id", async () => {
+ (callDrGreenAPI as any).mockResolvedValue({
+ success: true,
+ data: { client: { id: "client-9", kycLink: "https://kyc/x" } },
+ });
+
+ const result = await createClient(
+ { ...clientData, title: "Dr", marketingConsent: true, consentSource: "budstacks-shop-register" },
+ config,
+ );
+
+ expect(result).toEqual({ clientId: "client-9", kycLink: "https://kyc/x" });
+ const [endpoint, opts] = (callDrGreenAPI as any).mock.calls[0];
+ expect(endpoint).toBe("/dapp/clients");
+ expect(opts.method).toBe("POST");
+ expect(opts.baseUrl).toBe("https://stage/api/v1");
+ expect(opts.body).toEqual(
+ expect.objectContaining({
+ title: "Dr",
+ marketingConsent: true,
+ consentSource: "budstacks-shop-register",
+ }),
+ );
+ });
+
+ it("sends marketingConsent:false and no title/source when nothing was given", async () => {
+ (callDrGreenAPI as any).mockResolvedValue({ data: { data: { clientId: "c-2" } } });
+ await createClient(clientData, config);
+ const body = (callDrGreenAPI as any).mock.calls[0][1].body;
+ expect(body.marketingConsent).toBe(false);
+ expect("title" in body).toBe(false);
+ expect("consentSource" in body).toBe(false);
+ });
+
+ it("throws when no client id comes back", async () => {
+ (callDrGreenAPI as any).mockResolvedValue({ success: true, message: "ok" });
+ await expect(createClient(clientData, config)).rejects.toThrow(/No ID returned/);
+ });
+});
+
+describe("extractCreatedClient", () => {
+ it.each([
+ [{ data: { client: { id: "a", kycLink: "l" } } }, { clientId: "a", kycLink: "l" }],
+ [{ data: { data: { clientId: "b", kycLink: "l2" } } }, { clientId: "b", kycLink: "l2" }],
+ [{ data: { clientId: "c" } }, { clientId: "c", kycLink: undefined }],
+ [{ client: { id: "d" } }, { clientId: "d", kycLink: undefined }],
+ [{ data: { id: "e" } }, { clientId: "e", kycLink: undefined }],
+ [{ nothing: true }, { clientId: undefined, kycLink: undefined }],
+ ])("tolerates envelope %j", (response, expected) => {
+ expect(extractCreatedClient(response)).toEqual(expected);
+ });
+});
diff --git a/nextjs_space/tests/unit/drgreen-sa-client.test.ts b/nextjs_space/tests/unit/drgreen-sa-client.test.ts
index 686cf775..8ce15b71 100644
--- a/nextjs_space/tests/unit/drgreen-sa-client.test.ts
+++ b/nextjs_space/tests/unit/drgreen-sa-client.test.ts
@@ -49,6 +49,29 @@ describe("createSaIdClient", () => {
expect(res.clientId).toBe("client-2");
});
+ it("forwards title, marketingConsent and consentSource (BS-301)", async () => {
+ (callDrGreenAPI as any).mockResolvedValue({ client: { id: "client-3" } });
+ await createSaIdClient({
+ ...baseParams,
+ title: "Ms",
+ marketingConsent: true,
+ consentSource: "budstacks-id-upload",
+ });
+ const body = (callDrGreenAPI as any).mock.calls[0][1].body;
+ expect(body.title).toBe("Ms");
+ expect(body.marketingConsent).toBe(true);
+ expect(body.consentSource).toBe("budstacks-id-upload");
+ });
+
+ it("sends marketingConsent:false and omits title/source when not given", async () => {
+ (callDrGreenAPI as any).mockResolvedValue({ client: { id: "client-4" } });
+ await createSaIdClient({ ...baseParams, title: null });
+ const body = (callDrGreenAPI as any).mock.calls[0][1].body;
+ expect(body.marketingConsent).toBe(false);
+ expect("title" in body).toBe(false);
+ expect("consentSource" in body).toBe(false);
+ });
+
it("throws MISSING_CREDENTIALS when keys are absent", async () => {
await expect(
createSaIdClient({ ...baseParams, config: { apiKey: "", secretKey: "" } }),
diff --git a/nextjs_space/tests/unit/gdpr-erasure.test.ts b/nextjs_space/tests/unit/gdpr-erasure.test.ts
index d6048084..36c660de 100644
--- a/nextjs_space/tests/unit/gdpr-erasure.test.ts
+++ b/nextjs_space/tests/unit/gdpr-erasure.test.ts
@@ -74,6 +74,10 @@ describe("buildAnonymizedUserData", () => {
// AC-4: Dr Green linkage severed.
expect(data.drGreenClientId).toBeNull();
expect(data.clerkUserId).toBeNull();
+ // BS-305: consent and salutation go with the identity.
+ expect(data.marketingConsentAt).toBeNull();
+ expect(data.marketingConsentSource).toBeNull();
+ expect(data.title).toBeNull();
});
it("returns a NEW object (no shared reference between calls)", () => {
diff --git a/nextjs_space/tests/unit/kyc-client-payload.test.ts b/nextjs_space/tests/unit/kyc-client-payload.test.ts
index a7189ddd..70ef4744 100644
--- a/nextjs_space/tests/unit/kyc-client-payload.test.ts
+++ b/nextjs_space/tests/unit/kyc-client-payload.test.ts
@@ -80,6 +80,23 @@ describe("buildKycClientPayload", () => {
).toBe("Migraine");
});
+ it("forwards title, marketingConsent and consentSource (BS-301); consent is false by default", () => {
+ const withConsent = buildKycClientPayload({
+ ...base,
+ title: "Mx",
+ marketingConsent: true,
+ consentSource: "budstacks-consultation",
+ });
+ expect(withConsent.title).toBe("Mx");
+ expect(withConsent.marketingConsent).toBe(true);
+ expect(withConsent.consentSource).toBe("budstacks-consultation");
+
+ const plain = buildKycClientPayload(base);
+ expect(plain.marketingConsent).toBe(false);
+ expect("title" in plain).toBe(false);
+ expect("consentSource" in plain).toBe(false);
+ });
+
it("includes clientBusiness only when both type and name are present", () => {
const withBusiness = buildKycClientPayload({
...base,
diff --git a/nextjs_space/tests/unit/store-consent-route.test.ts b/nextjs_space/tests/unit/store-consent-route.test.ts
new file mode 100644
index 00000000..299790d4
--- /dev/null
+++ b/nextjs_space/tests/unit/store-consent-route.test.ts
@@ -0,0 +1,259 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+// withAuth → identity wrapper so the handlers are the raw (req, {user}, {slug}) form.
+vi.mock("@/lib/api-auth", () => ({ withAuth: (h: any) => h }));
+vi.mock("@/lib/validation/parse-uuid", () => ({ parseSlug: vi.fn() }));
+vi.mock("@/lib/tenant/tenant", () => ({ getCurrentTenant: vi.fn() }));
+vi.mock("@/lib/tenant/tenant-config", () => ({
+ getTenantDrGreenConfig: vi.fn(async () => ({
+ apiKey: "k",
+ secretKey: "s",
+ apiUrl: "https://stage/api/v1",
+ })),
+}));
+vi.mock("@/lib/db", () => ({
+ prisma: { users: { findFirst: vi.fn(), update: vi.fn() } },
+}));
+vi.mock("@/lib/logger", () => ({
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
+}));
+vi.mock("@/lib/audit-log", () => ({
+ createAuditLog: vi.fn(async () => undefined),
+ getClientInfo: () => ({ ipAddress: "203.0.113.9", userAgent: "vitest" }),
+ AUDIT_ACTIONS: {
+ CUSTOMER_MARKETING_CONSENT_GRANTED: "customer.marketing_consent_granted",
+ CUSTOMER_MARKETING_CONSENT_REVOKED: "customer.marketing_consent_revoked",
+ },
+}));
+// The real apiError lets an ApiError (parseJsonBody's 400) keep its own status.
+vi.mock("@/lib/api-error", async (importOriginal) => {
+ const original = await importOriginal();
+ return {
+ ...original,
+ apiError: (e: any, o: any) =>
+ new Response(
+ JSON.stringify({ error: e?.safeForClient ? e.message : (o?.safeMessage ?? "error") }),
+ {
+ status: e?.safeForClient ? e.status : (o?.status ?? 500),
+ headers: { "content-type": "application/json" },
+ },
+ ),
+ };
+});
+// Keep the REAL mapDrGreenApiError (its mapping is part of what this tests);
+// stub only the network call.
+vi.mock("@/lib/drgreen-identity", async (importOriginal) => ({
+ ...(await importOriginal()),
+ updateClientMarketingConsent: vi.fn(),
+}));
+
+import { GET, PATCH } from "@/app/api/store/[slug]/consent/route";
+import { getCurrentTenant } from "@/lib/tenant/tenant";
+import { prisma } from "@/lib/db";
+import { createAuditLog } from "@/lib/audit-log";
+import { logger } from "@/lib/logger";
+import { updateClientMarketingConsent } from "@/lib/drgreen-identity";
+import { CONSENT_SOURCE } from "@/lib/customers/marketing-consent";
+
+/**
+ * BS-304 — the customer's own consent toggle. The local column is written
+ * first and always (it gates every BudStacks campaign); Dr Green is updated
+ * best-effort and the response says whether that landed.
+ */
+const TENANT = { id: "tenant-1", countryCode: "ZA", settings: {} };
+const CONSENTED_AT = new Date("2026-09-01T10:00:00Z");
+
+const patchReq = (body: unknown) =>
+ new Request("https://store.test/api/store/s/consent", {
+ method: "PATCH",
+ headers: { "content-type": "application/json" },
+ body: typeof body === "string" ? body : JSON.stringify(body),
+ }) as any;
+
+const getReq = () =>
+ new Request("https://store.test/api/store/s/consent", { method: "GET" }) as any;
+
+const ctx = { user: { id: "user_clerk", email: "t@example.com" } };
+const patch = (body: unknown, user = ctx.user) =>
+ (PATCH as any)(patchReq(body), { user }, { slug: "s" });
+const get = (user = ctx.user) => (GET as any)(getReq(), { user }, { slug: "s" });
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ (getCurrentTenant as any).mockResolvedValue(TENANT);
+ (prisma.users.findFirst as any).mockResolvedValue({
+ id: "u1",
+ email: "t@example.com",
+ drGreenClientId: "dg-1",
+ marketingConsentAt: null,
+ });
+ (prisma.users.update as any).mockResolvedValue({});
+ (updateClientMarketingConsent as any).mockResolvedValue(undefined);
+});
+
+describe("GET /api/store/[slug]/consent", () => {
+ it("returns the caller's own consent state", async () => {
+ (prisma.users.findFirst as any).mockResolvedValue({ marketingConsentAt: CONSENTED_AT });
+ const res = await get();
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({
+ marketingConsent: true,
+ marketingConsentAt: CONSENTED_AT.toISOString(),
+ });
+ });
+
+ it("reads consent as false when the column is null", async () => {
+ (prisma.users.findFirst as any).mockResolvedValue({ marketingConsentAt: null });
+ expect(await (await get()).json()).toEqual({
+ marketingConsent: false,
+ marketingConsentAt: null,
+ });
+ });
+
+ it("404s when the account has no local row", async () => {
+ (prisma.users.findFirst as any).mockResolvedValue(null);
+ expect((await get()).status).toBe(404);
+ });
+
+ it("401s without an email on the session", async () => {
+ expect((await get({ id: "x", email: null })).status).toBe(401);
+ });
+});
+
+describe("PATCH /api/store/[slug]/consent", () => {
+ it("grants: writes the column and source, audits, forwards to Dr Green", async () => {
+ const res = await patch({ consent: true });
+
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.marketingConsent).toBe(true);
+ expect(body.marketingConsentAt).toEqual(expect.any(String));
+ expect(body.forwarded).toBe(true);
+ expect(body.warning).toBeUndefined();
+
+ expect(prisma.users.update).toHaveBeenCalledWith({
+ where: { id: "u1" },
+ data: {
+ marketingConsentAt: expect.any(Date),
+ marketingConsentSource: CONSENT_SOURCE.STORE_SETTINGS,
+ updatedAt: expect.any(Date),
+ },
+ });
+ expect(updateClientMarketingConsent).toHaveBeenCalledWith({
+ clientId: "dg-1",
+ consent: true,
+ consentSource: CONSENT_SOURCE.STORE_SETTINGS,
+ config: { apiKey: "k", secretKey: "s" },
+ baseUrl: "https://stage/api/v1",
+ });
+ expect(createAuditLog).toHaveBeenCalledWith(
+ expect.objectContaining({
+ action: "customer.marketing_consent_granted",
+ entityId: "u1",
+ userId: "u1",
+ tenantId: "tenant-1",
+ metadata: expect.objectContaining({ source: CONSENT_SOURCE.STORE_SETTINGS }),
+ }),
+ );
+ });
+
+ it("withdraws: clears the column, audits the revocation, forwards consent:false", async () => {
+ (prisma.users.findFirst as any).mockResolvedValue({
+ id: "u1",
+ email: "t@example.com",
+ drGreenClientId: "dg-1",
+ marketingConsentAt: CONSENTED_AT,
+ });
+ const res = await patch({ consent: false });
+
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({
+ marketingConsent: false,
+ marketingConsentAt: null,
+ forwarded: true,
+ });
+ expect(prisma.users.update).toHaveBeenCalledWith({
+ where: { id: "u1" },
+ data: {
+ marketingConsentAt: null,
+ marketingConsentSource: CONSENT_SOURCE.STORE_SETTINGS,
+ updatedAt: expect.any(Date),
+ },
+ });
+ expect(updateClientMarketingConsent).toHaveBeenCalledWith(
+ expect.objectContaining({ consent: false }),
+ );
+ expect(createAuditLog).toHaveBeenCalledWith(
+ expect.objectContaining({
+ action: "customer.marketing_consent_revoked",
+ metadata: expect.objectContaining({
+ previousConsentAt: CONSENTED_AT.toISOString(),
+ newConsentAt: null,
+ }),
+ }),
+ );
+ });
+
+ it("keeps the local withdrawal when Dr Green 404s (route not released / client unknown)", async () => {
+ (updateClientMarketingConsent as any).mockRejectedValue(
+ new Error("Doctor Green API Error: 404 Not Found - {\"message\":\"Cannot PATCH /api/v1/dapp/clients/dg-1/marketing-consent\"}"),
+ );
+ const res = await patch({ consent: false });
+
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.marketingConsent).toBe(false);
+ expect(body.forwarded).toBe(false);
+ expect(body.warning).toMatch(/saved for this store/);
+ // The local write and the audit row happened regardless.
+ expect(prisma.users.update).toHaveBeenCalledTimes(1);
+ expect(createAuditLog).toHaveBeenCalledTimes(1);
+ expect(logger.warn).toHaveBeenCalledWith(
+ "[Consent] Dr Green did not take the consent change",
+ expect.objectContaining({ status: 404, consent: false }),
+ );
+ });
+
+ it("surfaces Dr Green's customer-safe 409 reason in the warning", async () => {
+ (updateClientMarketingConsent as any).mockRejectedValue(
+ new Error('Doctor Green API Error: 409 Conflict - {"success":false,"statusCode":409,"message":"Client is not active"}'),
+ );
+ const body = await (await patch({ consent: true })).json();
+ expect(body.forwarded).toBe(false);
+ expect(body.warning).toMatch(/Client is not active/);
+ });
+
+ it("is local-only for an account with no Dr Green client", async () => {
+ (prisma.users.findFirst as any).mockResolvedValue({
+ id: "u1",
+ email: "t@example.com",
+ drGreenClientId: null,
+ marketingConsentAt: null,
+ });
+ const body = await (await patch({ consent: true })).json();
+ expect(body.marketingConsent).toBe(true);
+ expect(body.forwarded).toBe(false);
+ expect(body.warning).toBeUndefined();
+ expect(updateClientMarketingConsent).not.toHaveBeenCalled();
+ expect(prisma.users.update).toHaveBeenCalledTimes(1);
+ });
+
+ it("400s on a body that is not { consent: boolean } and writes nothing", async () => {
+ for (const bad of [{ consent: "yes" }, {}, { consent: true, extra: 1 }, "not json"]) {
+ (prisma.users.update as any).mockClear();
+ const res = await patch(bad);
+ expect(res.status).toBe(400);
+ expect(prisma.users.update).not.toHaveBeenCalled();
+ }
+ });
+
+ it("404s when the tenant cannot be resolved", async () => {
+ (getCurrentTenant as any).mockResolvedValue(null);
+ expect((await patch({ consent: true })).status).toBe(404);
+ expect(prisma.users.update).not.toHaveBeenCalled();
+ });
+
+ it("401s without an email on the session", async () => {
+ expect((await patch({ consent: true }, { id: "x", email: null })).status).toBe(401);
+ });
+});
From 4f0bd38fdd91e7463419e67c1d1f5d965c7535b9 Mon Sep 17 00:00:00 2001
From: Gerard Kavanagh
Date: Fri, 18 Sep 2026 10:29:05 +0100
Subject: [PATCH 3/7] feat(catalogue): read the signed /dapp/strains route and
delist inactive strains (BS-401)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Phase 4 of the Dr Green September 2026 alignment.
- fetchProducts calls /dapp/strains with the same query; doctorGreenRequest
already signs with the tenant key, so no auth change. The anonymous
/strains is scheduled to be guarded on the Dr Green side (US-406).
- A strain-level isActive: false is delisted — absent from the listing and,
because the detail lookup reads the cached list, from the product page —
rather than shown as out of stock. normalizeProduct now gates both the
location and the legacy no-location branch on it (previously only the
latter looked at the strain flag), matching the order gate.
- Test: endpoint + query pinned; inactive-with-stock absent; no-stock active
strain listed but not purchasable; detail lookup refuses a delisted id.
---
nextjs_space/lib/drgreen/doctor-green-api.ts | 41 +++++--
.../unit/fetch-products-catalogue.test.ts | 108 ++++++++++++++++++
2 files changed, 142 insertions(+), 7 deletions(-)
create mode 100644 nextjs_space/tests/unit/fetch-products-catalogue.test.ts
diff --git a/nextjs_space/lib/drgreen/doctor-green-api.ts b/nextjs_space/lib/drgreen/doctor-green-api.ts
index 9c780200..b514665a 100644
--- a/nextjs_space/lib/drgreen/doctor-green-api.ts
+++ b/nextjs_space/lib/drgreen/doctor-green-api.ts
@@ -138,6 +138,7 @@ export interface DoctorGreenProduct {
stockQuantity?: number; // Optional - may be in strainLocations instead
popularity?: number;
isAvailable?: boolean; // Optional - may be in strainLocations instead
+ isActive?: boolean; // Strain-level flag; false = delisted (BS-401)
strainLocations?: Array<{
isActive?: boolean;
isAvailable?: boolean;
@@ -251,6 +252,12 @@ async function normalizeProduct(product: DoctorGreenProduct, country: string): P
// which is why inactive strains still showed and were orderable. The /strains
// response carries isActive per location regardless of auth, so we filter on
// it. (See PRD order-commission-stock-delivery-gas Defect C.)
+ //
+ // BS-401 (Dr Green Phase 4 US-405): the STRAIN-level `isActive` gates both
+ // branches. Dr Green's order gate refuses an inactive strain outright, so a
+ // location that is active under an inactive strain is still unorderable.
+ // Previously only the no-locations branch looked at it.
+ const strainActive = product.isActive !== false;
const locations = product.strainLocations || [];
const sellableLocations = locations.filter(
(loc: any) => loc.isActive === true && loc.isAvailable === true,
@@ -258,9 +265,11 @@ async function normalizeProduct(product: DoctorGreenProduct, country: string): P
const locationStock = sellableLocations.reduce((sum: number, loc: any) => sum + (loc.stockQuantity || 0), 0);
const isAvailableAtAnyLocation = sellableLocations.length > 0;
const totalStock = locationStock > 0 ? locationStock : (product.stockQuantity || 0);
- const isAvailable = locations.length > 0
- ? isAvailableAtAnyLocation
- : ((product as any).isActive !== false && product.isAvailable !== false && totalStock > 0);
+ const isAvailable =
+ strainActive &&
+ (locations.length > 0
+ ? isAvailableAtAnyLocation
+ : product.isAvailable !== false && totalStock > 0);
// Dr Green local pricing priority (from their implementation doc):
// 1. localRetailPrice + localCurrency (not yet deployed)
@@ -312,17 +321,29 @@ async function normalizeProduct(product: DoctorGreenProduct, country: string): P
};
}
+// CONTRACT: Dr Green — GET /dapp/strains (DualAuthGuard). The signed storefront
+// catalogue: the same query and response shape as the anonymous /strains, but
+// the service applies the active/available filters on this path (Phase 4
+// US-405), and /strains itself is scheduled to be guarded (US-406).
+const DAPP_STRAINS_ENDPOINT = '/dapp/strains';
+
+/** BS-401: a strain flagged inactive is delisted, not "out of stock". */
+function isListedStrain(product: DoctorGreenProduct): boolean {
+ return product.isActive !== false;
+}
+
export async function fetchProducts(
country: string = "ZA",
config: DoctorGreenConfig,
): Promise {
- // Use /strains endpoint with countryCode param.
+ // Signed catalogue with countryCode param (BS-401 moved this off the
+ // anonymous /strains; doctorGreenRequest already signs with the tenant key).
// Currently returns EUR prices — normalizeProduct converts via exchange rates.
// When Dr Green deploys localRetailPrice, it will be used automatically.
const alpha3 = toAlpha3(country);
logger.info(`[fetchProducts] country=${country} alpha3=${alpha3}`);
- const response = await doctorGreenRequest('/strains', {
+ const response = await doctorGreenRequest(DAPP_STRAINS_ENDPOINT, {
config,
queryParams: {
countryCode: alpha3,
@@ -337,14 +358,20 @@ export async function fetchProducts(
const dataKeys = response?.data ? Object.keys(response.data) : [];
logger.info(`[fetchProducts] Response keys: [${responseKeys}], data keys: [${dataKeys}]`);
- const products = response?.data?.strains || response?.strains || [];
+ const returned: DoctorGreenProduct[] = response?.data?.strains || response?.strains || [];
+ // Absent from the listing AND from the detail lookup below (which reads
+ // this list), so a delisted strain can never reach a cart.
+ const products = returned.filter(isListedStrain);
if (products.length > 0) {
const p = products[0];
- logger.info(`[fetchProducts] First product: "${p.name}" retailPrice=${p.retailPrice} localRetailPrice=${p.localRetailPrice ?? 'N/A'} localCurrency=${p.localCurrency ?? 'N/A'}`);
+ logger.info(`[fetchProducts] First product: "${p.name}" retailPrice=${p.retailPrice} localRetailPrice=${(p as any).localRetailPrice ?? 'N/A'} localCurrency=${(p as any).localCurrency ?? 'N/A'}`);
} else {
logger.info(`[fetchProducts] No products returned`);
}
+ if (products.length !== returned.length) {
+ logger.info(`[fetchProducts] ${returned.length - products.length} inactive strain(s) delisted`);
+ }
return Promise.all(products.map((product: DoctorGreenProduct) => normalizeProduct(product, country)));
}
diff --git a/nextjs_space/tests/unit/fetch-products-catalogue.test.ts b/nextjs_space/tests/unit/fetch-products-catalogue.test.ts
new file mode 100644
index 00000000..02acb37b
--- /dev/null
+++ b/nextjs_space/tests/unit/fetch-products-catalogue.test.ts
@@ -0,0 +1,108 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@/lib/drgreen/drgreen-api-client", () => ({ callDrGreenAPI: vi.fn() }));
+vi.mock("@/lib/exchange-rates", () => ({
+ convertFromEUR: vi.fn(async (value: number) => value),
+}));
+vi.mock("@/lib/logger", () => ({
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
+}));
+
+import { callDrGreenAPI } from "@/lib/drgreen/drgreen-api-client";
+import {
+ fetchProduct,
+ fetchProducts,
+ invalidateProductCache,
+} from "@/lib/drgreen/doctor-green-api";
+
+/**
+ * BS-401 (Dr Green Phase 4 US-405): the storefront reads the SIGNED catalogue
+ * and never shows a product the order gate would refuse.
+ */
+const config = { apiKey: "k", secretKey: "s", apiUrl: "https://stage/api/v1" };
+
+const active = {
+ id: "s-active",
+ name: "Active",
+ description: "",
+ thc: 20,
+ cbd: 1,
+ type: "Indica",
+ retailPrice: 10,
+ isActive: true,
+ strainLocations: [{ isActive: true, isAvailable: true, stockQuantity: 5 }],
+};
+const inactiveWithStock = {
+ ...active,
+ id: "s-inactive",
+ name: "Inactive",
+ isActive: false, // delisted at strain level, even though a location has stock
+};
+const noStock = {
+ ...active,
+ id: "s-nostock",
+ name: "No stock",
+ strainLocations: [{ isActive: true, isAvailable: true, stockQuantity: 0 }],
+};
+const legacyNoLocations = {
+ ...active,
+ id: "s-legacy",
+ name: "Legacy",
+ strainLocations: [],
+ stockQuantity: 3,
+ isAvailable: true,
+ isActive: false,
+};
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ invalidateProductCache();
+ (callDrGreenAPI as any).mockResolvedValue({
+ data: { strains: [active, inactiveWithStock, noStock, legacyNoLocations] },
+ });
+});
+
+describe("fetchProducts", () => {
+ it("calls the signed /dapp/strains route with the same query as before", async () => {
+ await fetchProducts("ZA", config);
+ expect(callDrGreenAPI).toHaveBeenCalledTimes(1);
+ const [endpoint, opts] = (callDrGreenAPI as any).mock.calls[0];
+ expect(endpoint).toBe("/dapp/strains");
+ expect(opts.apiKey).toBe("k");
+ expect(opts.secretKey).toBe("s");
+ expect(opts.baseUrl).toBe("https://stage/api/v1");
+ expect(opts.queryParams).toEqual({
+ countryCode: "ZAF",
+ orderBy: "desc",
+ take: 100,
+ page: 1,
+ });
+ });
+
+ it("delists a strain flagged inactive, even when a location still holds stock", async () => {
+ const products = await fetchProducts("ZA", config);
+ const ids = products.map((p) => p.id);
+ expect(ids).toEqual(["s-active", "s-nostock"]);
+ expect(ids).not.toContain("s-inactive");
+ expect(ids).not.toContain("s-legacy");
+ });
+
+ it("keeps an active strain with no stock listed but not purchasable", async () => {
+ const products = await fetchProducts("ZA", config);
+ const byId = Object.fromEntries(products.map((p) => [p.id, p]));
+ expect(byId["s-active"].in_stock).toBe(true);
+ expect(byId["s-active"].stock_quantity).toBe(5);
+ expect(byId["s-nostock"].in_stock).toBe(false);
+ expect(byId["s-nostock"].isAvailable).toBe(false);
+ });
+});
+
+describe("fetchProduct (detail via the cached list)", () => {
+ it("resolves an active strain and refuses a delisted one", async () => {
+ const found = await fetchProduct("s-active", "ZA", config);
+ expect(found.id).toBe("s-active");
+ await expect(fetchProduct("s-inactive", "ZA", config)).rejects.toThrow(/not found/);
+ // Both lookups came from one cached listing.
+ expect(callDrGreenAPI).toHaveBeenCalledTimes(1);
+ });
+});
From 9a845e9a8dd9a24432e4936af702d0e08c2f06f7 Mon Sep 17 00:00:00 2001
From: Gerard Kavanagh
Date: Fri, 18 Sep 2026 10:31:42 +0100
Subject: [PATCH 4/7] test(sa-id): widen the JSON vector import before
narrowing
---
nextjs_space/tests/unit/sa-id.test.ts | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/nextjs_space/tests/unit/sa-id.test.ts b/nextjs_space/tests/unit/sa-id.test.ts
index 3426af4d..b1590fa8 100644
--- a/nextjs_space/tests/unit/sa-id.test.ts
+++ b/nextjs_space/tests/unit/sa-id.test.ts
@@ -22,7 +22,8 @@ interface SaIdVector {
note?: string;
}
-const VECTORS = (vectorFile as { vectors: SaIdVector[] }).vectors;
+// The JSON module's inferred element type is a union of shapes; widen first.
+const VECTORS = (vectorFile as unknown 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");
From aef4d4e98c34da80dedf1e3ce492b69e0a2e595b Mon Sep 17 00:00:00 2001
From: Gerard Kavanagh
Date: Fri, 18 Sep 2026 10:33:03 +0100
Subject: [PATCH 5/7] =?UTF-8?q?docs(prd):=20Dr=20Green=20phase=20alignment?=
=?UTF-8?q?=20=E2=80=94=20v2=20status,=20corrections=20found=20in=20code,?=
=?UTF-8?q?=20decisions?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
tasks/prd-drgreen-phase-alignment-2026-09.md | 198 +++++++++++++++++++
1 file changed, 198 insertions(+)
create mode 100644 tasks/prd-drgreen-phase-alignment-2026-09.md
diff --git a/tasks/prd-drgreen-phase-alignment-2026-09.md b/tasks/prd-drgreen-phase-alignment-2026-09.md
new file mode 100644
index 00000000..d95a0723
--- /dev/null
+++ b/tasks/prd-drgreen-phase-alignment-2026-09.md
@@ -0,0 +1,198 @@
+# PRD — BudStacks alignment with the Dr Green September 2026 phases
+
+| | |
+|---|---|
+| **Version** | v2 — 2026-09-18 (v1 draft 2026-09-17) |
+| **Owner** | Gerard Kavanagh (CTO; owns and releases BudStacks) |
+| **Surface** | `budstack-saas` (Next.js multi-tenant storefront, `nextjs_space/`) |
+| **Related** | Dr Green phased plan `dr-green-backend/docs/prd/2026-09-phased-plan.md`; Phase 2 `phase-2-identity-validation-comms.prd.md`; Phase 3 `phase-3-consent-salutation.prd.md`; Phase 4 `phase-4-order-integrity.prd.md` |
+| **Depends on** | Dr Green backend releases per phase (see each story's "needs backend" note). BudStacks stories marked *independent* can ship first. |
+| **Compatibility** | BudStacks is a single deployment Gerard controls, so no version skew. The Dr Green backend strips unknown DTO fields (`ValidationPipe({ whitelist: true })`, `src/main.ts:75-78`), so new optional fields sent early are ignored, never rejected. |
+| **Branch** | `feat/drgreen-phase-alignment-2026-09` (from `origin/main` @ `edb35005`): `9659646e` Phase 2 · `ba56f136` Phase 3 · `4f0bd38f` Phase 4 |
+
+---
+
+## 0. Status (2026-09-18)
+
+All eleven stories are built on the branch above, one commit per phase, with unit tests. Not yet done, and why:
+
+- **Typecheck / lint / unit tests have not been run.** Nothing is executed on the workstation (house rule); CI on this repo runs only on a PR to `main`, and a PR here merges instantly, so opening one is a production deploy. Run before opening the PR: `pnpm -C nextjs_space exec tsc --noEmit && pnpm -C nextjs_space lint && pnpm -C nextjs_space test`.
+- **Browser verification** (every "Verify in browser" AC) needs a deployed build; BudStacks has no staging, so it happens on the LekkerWeed/HealingBuds tenants after the deploy, against a test account.
+- **BS-205 staging confirmation** needs Dr Green Phase 2 US-208 (link resolution) on Dr Green staging; not released.
+- **BS-304 end-to-end** needs Dr Green Phase 3 US-302 (`PATCH /dapp/clients/:id/marketing-consent`); until then the toggle is local-first and reports `forwarded: false`.
+- **Consent copy** is the legal placeholder (`lib/customers/marketing-consent.ts`), one constant to change when Ricardo/legal confirm.
+
+### Corrections found in code while building (v1 → v2)
+
+1. **Dr Green's vector file did not exist** (`docs/design/sa-id-test-vectors.json`) and the backend has no validator yet. BudStacks authored the 29 synthetic vectors; the canonical copy is written to `dr-green-backend/docs/design/sa-id-test-vectors.json` (uncommitted on `develop`, beside the phase PRDs) and Dr Green US-201 / plugin 1.3.0 must adopt it, not the other way round.
+2. **`users.marketingConsentAt` already existed** (Email Phase 2, US-023) and is the consent test for four readers — campaign audience, saved segments, newsletter unsubscribe, tenant-admin toggle. `consultation_questionnaires` never had a consent column, so there was nothing to backfill. The v1 AC's separate `marketingConsent Boolean` was **not** added (two columns for one fact is how a withdrawn customer gets mailed); Phase 3 adds `marketingConsentSource` and `title` only.
+3. **Campaign sending was already consent-only** (`lib/email/campaign-audience-query.ts` → `marketingConsentAt: { not: null }`, `segment-query.ts` the same, the recipients export lists rows materialised from that audience; newsletter subscribers are a separate double-opt-in). BS-305's "campaign sending" clause needed documentation, not code.
+4. **`IdUploadRegistration.tsx` does not exist.** The shop path is `components/shop/ClientOnboarding.tsx` → `POST /api/shop/register`, which posted to Dr Green `/client` — a route no controller serves — and is not rendered by any page. It is fixed to `/dapp/clients` and carries consent/title, but it is dead UI today.
+5. **Tenant country is not in `getTenantDrGreenConfig`** (keys + URL only). The SA rule keys off `isSaIdEligibleTenant(tenant)` (`tenant.countryCode === "ZA"`), which is what PRD §6 asked for anyway.
+6. **`package.json` had no `version`.** One was added (`1.0.0`) for the capability header; `APP_VERSION` overrides.
+7. **The consultation submit route was 793 lines against an 800-line lint ceiling.** The KYC client-payload builder was lifted to `lib/drgreen/kyc-client-payload.ts` (behaviour-preserving, unit-tested) to make room.
+
+## 1. Introduction / Overview
+
+The Dr Green September 2026 review produced six phases. Three of them touch the storefront: identity validation and communications (Phase 2), marketing consent and salutation (Phase 3), and catalogue integrity (Phase 4). This PRD is the BudStacks counterpart for those three. Phases 0 and 1 are WordPress, dApp and backend only; Phase 5 is paused.
+
+BudStacks stays a pure pass-through for identity documents: it validates, forwards, and stores nothing about the document. Nothing in this PRD changes that.
+
+### What changes for the customer
+
+- Typing a South African ID number that cannot be valid is caught on the form, before anything is uploaded, with one clear message.
+- Registration asks how they wish to be addressed and whether they want marketing, unticked by default. They can change the marketing choice in their store settings.
+- Products that cannot be ordered no longer appear on the storefront.
+
+### What changes for the tenant admin
+
+- The customers table and its CSV export show consent, with a "Consented only" filter. Campaign recipients are limited to consented customers (already the case; now documented and visible).
+
+### Verified in code 2026-09-17 (v1), corrected 2026-09-18 where noted
+
+- Document uploads originate from `components/consultation/steps/id-upload-step.tsx` (via `id-upload-form.tsx`), `components/shop/IdDocumentUpload.tsx` (orphaned — no page renders it) and `components/shop/ReUploadIdDocument.tsx`; they reach Dr Green through `app/api/store/[slug]/verify/id-document/route.ts` and the inline `idDocument` object in `app/api/consultation/submit/route.ts`. `lib/drgreen-identity.ts` builds the upload headers by hand.
+- BudStacks sends no email on ID upload, so Dr Green's new "ID received" email will be the only one.
+- The consultation contact step already had an unticked marketing-consent checkbox stored on **`users.marketingConsentAt`** (not on the questionnaire — v1 was wrong) and never forwarded. The shop onboarding schema had only a terms `consent` field; the shop register route created the Dr Green client with `createClient` against a dead endpoint and forwarded no consent.
+- `fetchProducts` called `/strains` through `doctorGreenRequest`, which already signs with the tenant key; `normalizeProduct` checked the strain-level `isActive` only when a strain had no locations.
+- `orders.shippingInfo Json?` already snapshots the delivery address at submit, so Dr Green's Phase 4 address snapshot needs no BudStacks change.
+
+## 2. Goals
+
+- Same SA ID rules and the same error copy as Dr Green and the WordPress plugin, proven by one shared vector file.
+- BudStacks always gets Dr Green's strict validation by declaring itself with the capability header.
+- Consent and title captured on every registration path and forwarded to Dr Green; withdrawable by the customer.
+- Tenant marketing tooling respects consent.
+- Storefront catalogue matches what Dr Green will accept at order time.
+
+## 3. User stories
+
+### Phase 2 alignment — identity
+
+#### BS-201: SA ID validator and shared vectors *(independent)* — ✅ built
+**Description:** As a developer, I need one validator so both upload routes and all three upload components apply the same rules.
+
+**Acceptance Criteria:**
+- [x] `lib/verification/sa-id.ts` exports `validateSouthAfricanId(input)` returning `{ valid: true, normalised }` or `{ valid: false, reason: 'length' | 'digits' | 'date' | 'citizenship' | 'checksum' }`. Rules: strip spaces; 13 digits; digits 1–6 a real date in the 1900s or 2000s not in the future (either century accepted); digit 11 in {0,1,2}; Luhn over all 13. Digit 12 not enforced. Checks run in that order; the first failure is the reason.
+- [x] `lib/verification/__tests__/sa-id-vectors.json` — 29 synthetic vectors (9 valid, 20 invalid, every reason and the check order covered); `tests/unit/sa-id.test.ts` runs every vector and fails if any disagrees. Canonical copy written to `dr-green-backend/docs/design/sa-id-test-vectors.json` for US-201/US-203 to adopt. No real number in either repo.
+- [ ] Typecheck/lint passes — *pending: run before the PR (see §0).*
+
+#### BS-202: Enforce in both upload routes *(independent)* — ✅ built
+**Description:** As the storefront, I refuse an impossible ID number before uploading it.
+
+**Acceptance Criteria:**
+- [x] The verify proxy's meta schema is built per request with `superRefine(saIdDocumentRefinement(enforce))`; the consultation submit applies the same refinement to the parsed `idDocument` once the tenant is known, **before** any account, questionnaire or Dr Green client exists. `enforce` = `isSaIdEligibleTenant(tenant)` and `documentType === "ID"`.
+- [x] Failure returns 400 `{ code: "SA_ID_INVALID", error: "That does not look like a valid South African ID number. Check the 13 digits and try again." }` and records **no** `UPLOAD_FAILED` outcome (tests).
+- [x] Passport and driving licence, and non-SA tenants, are unaffected (tests on both routes). A valid number is forwarded space-stripped.
+- [ ] Typecheck/lint passes — *pending.*
+
+#### BS-203: Inline form validation and error display *(independent)* — ✅ built, browser check pending
+**Acceptance Criteria:**
+- [x] `id-upload-step.tsx`, `IdDocumentUpload.tsx` and `ReUploadIdDocument.tsx` validate on blur and on submit when the selected type is ID (`saIdFieldError`), showing the same copy inline on the number field.
+- [x] The ID option is labelled "South African ID" (`validateSaId` prop, default on — these components only mount on ID-upload tenants, which are ZA-only by construction).
+- [x] A 400 with `code: "SA_ID_INVALID"` from either route, or from Dr Green through the proxy, is shown inline on the number field rather than as the generic failed-upload banner (`id-upload-form.tsx` keeps the customer on step 3).
+- [ ] Verify in browser (LekkerWeed/HealingBuds after deploy; no staging).
+- [ ] Typecheck/lint passes — *pending.*
+
+#### BS-204: Capability header on Dr Green calls *(independent; needed for Dr Green strict mode)* — ✅ built
+**Acceptance Criteria:**
+- [x] `lib/drgreen/client-version.ts` → `X-DRG-Client: budstacks/` on every call: `callDrGreenAPI` (all JSON/GET calls, so `doctorGreenRequest`, `createSaIdClient`, `switchClientToIdVerification`, consent) and the multipart `uploadIdentityDocument`. Version from `package.json` (`APP_VERSION` overrides), sanitised to Dr Green's `[A-Za-z0-9./_-]`, ≤64 chars.
+- [x] Outside the signed payload: `tests/unit/drgreen-client-header.test.ts` verifies the JSON, query-string and multipart signatures against the payload Dr Green reconstructs, with the header present.
+- [x] Dr Green's `SA_ID_INVALID` from the proxy is mapped to the same 400 and recorded as `UPLOAD_FAILED` with the customer copy; `kyc-check` surfaces it (allow-listed copy only) and the dashboard shows "Reason: …" beside the re-upload card.
+- [ ] Typecheck/lint passes — *pending.*
+
+#### BS-205: Re-upload link lands on the tenant dashboard *(config, no code)* — 🟡 documented, staging pending
+**Acceptance Criteria:**
+- [x] `docs/guides/SUPER_ADMIN_MANUAL.md` → "South African ID-upload tenants — Dr Green key checklist": the key must list the storefront host (and custom domain) in its allowed return hosts on the Dr Green dApp Keys page; wildcards are skipped by the resolver.
+- [ ] Confirmed on staging: a rejected customer on a tenant with no branding lands on `https:///dashboard` — *needs Dr Green Phase 2 US-208 on staging (not released).*
+
+### Phase 3 alignment — consent and salutation
+
+#### BS-301: Forward consent, source and title to Dr Green *(safe to ship early)* — ✅ built
+**Acceptance Criteria:**
+- [x] All three client-create paths send `title`, `marketingConsent` and `consentSource`: consultation submit KYC branch (`buildKycClientPayload`, `"budstacks-consultation"`), `createSaIdClient` (`"budstacks-id-upload"`), shop register `createClient` (`"budstacks-shop-register"`). `marketingConsent` is always a boolean (explicit true only); `title`/`consentSource` only when set.
+- [x] No conditional code; Dr Green strips the fields until US-301/302 is released.
+- [ ] Typecheck/lint passes — *pending.*
+
+#### BS-302: Marketing consent on every registration path *(independent; copy gated on legal)* — ✅ built (AC corrected)
+**Acceptance Criteria:**
+- [x] Shop onboarding (`onboarding-schema.ts` medical step, `MedicalStep.tsx`) gains an optional `marketingConsent` beside the required terms `consent`; the consultation path keeps its checkbox.
+- [x] ~~`users.marketingConsent Boolean`~~ **Corrected:** `users.marketingConsentAt` already is the consent test; added `users.marketingConsentSource String?` (migration `20260918000000_phase3_title_consent_source`, idempotent). No backfill needed.
+- [x] Copy = `marketingConsentCopy(storeName)` — "Keep me informed about products and offers from {store name} by email or SMS" (placeholder until legal confirms; SMS inclusion open).
+- [ ] Verify in browser.
+- [ ] Typecheck/lint passes — *pending.*
+
+#### BS-303: Title field *(independent)* — ✅ built
+**Acceptance Criteria:**
+- [x] Optional title select (Mr, Mrs, Ms, Mx, Dr, Prof — `lib/customers/titles.ts`, one constant) on the consultation contact step and the shop personal-details step; stored on `users.title`; forwarded per BS-301.
+- [ ] Verify in browser.
+- [ ] Typecheck/lint passes — *pending.*
+
+#### BS-304: Customer consent toggle in store settings *(needs backend Phase 3 US-302)* — ✅ built (local-first)
+**Acceptance Criteria:**
+- [x] `app/store/[slug]/settings/page.tsx` shows "Marketing emails and SMS" with the current state and a Switch.
+- [x] `GET`/`PATCH /api/store/[slug]/consent` (`withAuth`) resolves the caller's own row and `drGreenClientId`. **Decision:** the local column is written first and unconditionally (a withdrawal never depends on a partner API), the audit row is written, then Dr Green `PATCH /dapp/clients/:clientId/marketing-consent` is called best-effort; 404/409 are mapped to a customer-safe `warning` in a 200 response with `forwarded: false` (the route is not on Dr Green production yet, and BudStacks campaigns are BudStacks' own exposure).
+- [x] Route tests mirror the switch-to-id tests (grant, withdraw, Dr Green 404 and 409, local-only account, bad body, 401/404).
+- [ ] Verify in browser.
+- [ ] Typecheck/lint passes — *pending.*
+
+#### BS-305: Tenant admin respects consent *(independent)* — ✅ built
+**Acceptance Criteria:**
+- [x] `customers-table.tsx`: Marketing column, "Consented only" filter (`?consent=yes`, server-side `marketingConsentAt: { not: null }`), consented-count pill; the CSV export includes "Marketing consent" (yes/no) and "Consent given" and, because it exports the on-screen page, honours the filter.
+- [x] Campaign sending and `campaigns/[id]/recipients/export` — **verified already consent-only** (`campaign-audience-query.ts` `consentedCustomers`, `segment-query.ts`, recipients materialised from that audience; `tests/unit/campaign-audience.test.ts` "reads consented customers only"). No change; the help guide (`lib/documents/guides/customers.ts`) now says so.
+- [x] Anonymised (erased) customers: `buildAnonymizedUserData` nulls `marketingConsentAt`, `marketingConsentSource`, `title` (test).
+- [ ] Verify in browser (tenant admin).
+- [ ] Typecheck/lint passes — *pending.*
+
+### Phase 4 alignment — catalogue
+
+#### BS-401: Storefront reads the filtered catalogue *(independent)* — ✅ built
+**Acceptance Criteria:**
+- [x] `fetchProducts` calls `/dapp/strains` with the same query (`countryCode` alpha-3, `orderBy`, `take`, `page`); `doctorGreenRequest` already signs.
+- [x] A strain-level `isActive === false` is **delisted** (filtered out of the list), and `normalizeProduct` gates both the location and the legacy no-location branch on it.
+- [x] The product-detail fallback (cached list) still resolves; an inactive strain is absent from listing and detail (`tests/unit/fetch-products-catalogue.test.ts`).
+- [ ] Verify in browser.
+- [ ] Typecheck/lint passes — *pending.*
+
+No BudStacks change for Dr Green's order address snapshot (BudStacks already stores `orders.shippingInfo` at submit) or for the order-rejection email (Dr Green emails the customer directly).
+
+## 4. Functional requirements
+
+- FR-1: SA ID validation applies only to document type ID on South African tenants, with the shared rules and copy.
+- FR-2: Every Dr Green call from BudStacks carries `X-DRG-Client`.
+- FR-3: Every registration path captures consent (default false) and title and forwards them.
+- FR-4: Customers can change consent from store settings; the change reaches Dr Green when its endpoint exists, and always takes effect locally.
+- FR-5: Tenant marketing tools and exports are limited to consented customers.
+- FR-6: The storefront catalogue excludes inactive and unavailable products.
+
+## 5. Non-goals
+
+- Storing document images or numbers on BudStacks (unchanged pass-through).
+- Deriving anything from the ID number; no date of birth, sex or citizenship.
+- Exposing ID numbers in any tenant export.
+- Courier tracking (Dr Green Phase 5 is paused).
+- A retrospective consent campaign for existing customers (operations decision).
+- Reviving the orphaned shop onboarding UI (`ClientOnboarding.tsx`); it is fixed and consent-aware but no page renders it.
+
+## 6. Technical considerations
+
+- The `/identity/documents` signature is computed over the multipart fields; the header is added to the fetch headers only and never enters that payload (tested).
+- Tenant country comes from the tenant row (`isSaIdEligibleTenant`), not from the customer's address.
+- Dr Green strips unknown fields, so BS-301 deploys before the backend without errors; consent only persists on Dr Green once Phase 3 US-301/302 are on production.
+- The title list is one constant (`lib/customers/titles.ts`) reused by both forms and both routes; the consent copy and sources are one module (`lib/customers/marketing-consent.ts`).
+- `marketingConsentAt` stays the single consent test; `marketingConsentSource` is attribution only.
+- The consultation submit route sits at 758 lines against the 800-line lint ceiling; put new logic in lib modules, not the route.
+
+## 7. Success metrics
+
+- Zero `SA_ID_INVALID` responses from Dr Green for BudStacks uploads after BS-202 ships (all caught on the form).
+- Every new registration carries an explicit consent value.
+- Campaign recipient counts equal consented counts.
+- Zero "not active" 409s at order submit from storefront carts.
+
+## 8. Open questions
+
+- Final consent copy and whether SMS is included (Ricardo/legal) — one constant to change.
+- Whether existing customers are asked for consent on next login.
+- ~~Current behaviour of campaign sending with respect to consent~~ — verified consent-only (BS-305).
+- Whether the "ID received" email should also be mirrored as a dashboard status on BudStacks (today the local `UPLOADED` flag already covers it).
+- Whether BS-304 should retry a failed Dr Green forward (a sweep) once US-302 is released, or whether the next customer toggle is enough.
From 9c72619865fa11fc5fcd305b2d379f48c2b3f069 Mon Sep 17 00:00:00 2001
From: Gerard Kavanagh
Date: Fri, 18 Sep 2026 17:13:50 +0100
Subject: [PATCH 6/7] fix(types): clear the nine tsc errors CI found in the Dr
Green alignment tests
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- resolveAppVersion/drgClientHeaderValue take Partial: they
read only APP_VERSION, and the repo's ProcessEnv makes NODE_ENV mandatory,
so every test caller needed a cast. No runtime change.
- kyc-client-payload tests read conditionally-spread keys through a loose view
(the builder's return type is a union by design).
- store-consent-route tests type the session user with email: string | null —
the route answers 401 for exactly that case.
---
nextjs_space/lib/drgreen/client-version.ts | 8 ++++++--
.../tests/unit/kyc-client-payload.test.ts | 18 +++++++++++++-----
.../tests/unit/store-consent-route.test.ts | 5 ++++-
3 files changed, 23 insertions(+), 8 deletions(-)
diff --git a/nextjs_space/lib/drgreen/client-version.ts b/nextjs_space/lib/drgreen/client-version.ts
index 1818029c..efcec35f 100644
--- a/nextjs_space/lib/drgreen/client-version.ts
+++ b/nextjs_space/lib/drgreen/client-version.ts
@@ -19,13 +19,17 @@ 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 {
+export function resolveAppVersion(
+ env: Partial = 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 {
+export function drgClientHeaderValue(
+ env: Partial = process.env,
+): string {
const raw = `${DRG_CLIENT_NAME}/${resolveAppVersion(env)}`;
return raw.replace(UNSAFE_CHARS, "-").slice(0, DRG_CLIENT_MAX_LENGTH);
}
diff --git a/nextjs_space/tests/unit/kyc-client-payload.test.ts b/nextjs_space/tests/unit/kyc-client-payload.test.ts
index 70ef4744..e0a105bc 100644
--- a/nextjs_space/tests/unit/kyc-client-payload.test.ts
+++ b/nextjs_space/tests/unit/kyc-client-payload.test.ts
@@ -69,14 +69,22 @@ describe("buildKycClientPayload", () => {
expect("otherMedicalCondition" in payload.medicalRecord).toBe(false);
});
+ // The builder adds optional keys conditionally (so an absent key is absent,
+ // not undefined), which makes its return type a union. Assertions about those
+ // keys read through a loose view.
+ const loose = (value: unknown) => value as Record;
+
it("fills otherMedicalCondition from the mapped keys, then free text, then the fallback", () => {
expect(
- buildKycClientPayload({ ...base, medicalConditions: ["asthma", "other"] }).medicalRecord
- .otherMedicalCondition,
+ loose(
+ buildKycClientPayload({ ...base, medicalConditions: ["asthma", "other"] }).medicalRecord,
+ ).otherMedicalCondition,
).toBe("Asthma, Other");
expect(
- buildKycClientPayload({ ...base, medicalConditions: [], otherCondition: "Migraine" })
- .medicalRecord.otherMedicalCondition,
+ loose(
+ buildKycClientPayload({ ...base, medicalConditions: [], otherCondition: "Migraine" })
+ .medicalRecord,
+ ).otherMedicalCondition,
).toBe("Migraine");
});
@@ -103,7 +111,7 @@ describe("buildKycClientPayload", () => {
businessType: "pharmacy",
businessName: "Farmácia A",
});
- expect(withBusiness.clientBusiness).toEqual(
+ expect(loose(withBusiness).clientBusiness).toEqual(
expect.objectContaining({ businessType: "pharmacy", name: "Farmácia A", countryCode: "" }),
);
expect(
diff --git a/nextjs_space/tests/unit/store-consent-route.test.ts b/nextjs_space/tests/unit/store-consent-route.test.ts
index 299790d4..020e7c40 100644
--- a/nextjs_space/tests/unit/store-consent-route.test.ts
+++ b/nextjs_space/tests/unit/store-consent-route.test.ts
@@ -73,7 +73,10 @@ const patchReq = (body: unknown) =>
const getReq = () =>
new Request("https://store.test/api/store/s/consent", { method: "GET" }) as any;
-const ctx = { user: { id: "user_clerk", email: "t@example.com" } };
+type SessionUser = { id: string; email: string | null };
+const ctx: { user: SessionUser } = {
+ user: { id: "user_clerk", email: "t@example.com" },
+};
const patch = (body: unknown, user = ctx.user) =>
(PATCH as any)(patchReq(body), { user }, { slug: "s" });
const get = (user = ctx.user) => (GET as any)(getReq(), { user }, { slug: "s" });
From 51186d7c95780a60463ed9dd5de9ec3e6750b8db Mon Sep 17 00:00:00 2001
From: Gerard Kavanagh
Date: Fri, 18 Sep 2026 17:23:17 +0100
Subject: [PATCH 7/7] test(verify-id-document): use a valid SA ID in the
fixtures that are not about the ID rules
With BS-201 enforcing the South African ID rules, 'A123' made five tests 400 on
the number instead of on the condition each one names (tenant KYC mode, the
global flag, a missing Dr Green client, an unsupported file type, and the happy
path). The happy path was failing outright; the other four were passing for the
wrong reason.
---
.../tests/unit/verify-id-document-route.test.ts | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
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 939e0fda..b53facd2 100644
--- a/nextjs_space/tests/unit/verify-id-document-route.test.ts
+++ b/nextjs_space/tests/unit/verify-id-document-route.test.ts
@@ -84,7 +84,7 @@ beforeEach(() => {
describe("POST /api/store/[slug]/verify/id-document", () => {
it("forwards a valid upload and returns PENDING", async () => {
const res = await call(
- makeReq({ file: jpeg(), documentType: "ID", documentNumber: "A123" }),
+ makeReq({ file: jpeg(), documentType: "ID", documentNumber: VALID_SA_ID }),
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ status: "PENDING" });
@@ -101,7 +101,7 @@ describe("POST /api/store/[slug]/verify/id-document", () => {
settings: { verificationMode: "KYC" },
});
const res = await call(
- makeReq({ file: jpeg(), documentType: "ID", documentNumber: "A123" }),
+ makeReq({ file: jpeg(), documentType: "ID", documentNumber: VALID_SA_ID }),
);
expect(res.status).toBe(403);
expect(uploadIdentityDocument).not.toHaveBeenCalled();
@@ -110,7 +110,7 @@ describe("POST /api/store/[slug]/verify/id-document", () => {
it("403s when the global flag is off", async () => {
process.env.SA_ID_UPLOAD_ENABLED = "false";
const res = await call(
- makeReq({ file: jpeg(), documentType: "ID", documentNumber: "A123" }),
+ makeReq({ file: jpeg(), documentType: "ID", documentNumber: VALID_SA_ID }),
);
expect(res.status).toBe(403);
expect(uploadIdentityDocument).not.toHaveBeenCalled();
@@ -122,7 +122,7 @@ describe("POST /api/store/[slug]/verify/id-document", () => {
drGreenClientId: null,
});
const res = await call(
- makeReq({ file: jpeg(), documentType: "ID", documentNumber: "A123" }),
+ makeReq({ file: jpeg(), documentType: "ID", documentNumber: VALID_SA_ID }),
);
expect(res.status).toBe(400);
expect(uploadIdentityDocument).not.toHaveBeenCalled();
@@ -131,7 +131,7 @@ describe("POST /api/store/[slug]/verify/id-document", () => {
it("400s on an unsupported file type", async () => {
const txt = new Blob([Buffer.from("hi")], { type: "text/plain" });
const res = await call(
- makeReq({ file: txt, documentType: "ID", documentNumber: "A123" }),
+ makeReq({ file: txt, documentType: "ID", documentNumber: VALID_SA_ID }),
);
expect(res.status).toBe(400);
expect(uploadIdentityDocument).not.toHaveBeenCalled();