+ {account?.isMock ? (
+
+
+ Test-only mock Paystack account
+
+ Bank details below are fake local data for contributor testing. Production Paystack behavior is unchanged
+ when mock mode is disabled.
+ {account.mockReference ? ` Reference: ${account.mockReference}` : null}
+
+
+ ) : null}
+
{account ? (
<>
@@ -142,10 +159,21 @@ export function DriverVirtualAccountCard({
Provider
- {account.providerSlug ? account.providerSlug : "Paystack dedicated virtual account"}
+ {account.isMock
+ ? "mock-paystack (local test provider)"
+ : account.providerSlug
+ ? account.providerSlug
+ : "Paystack dedicated virtual account"}
+
+ {showMockSimulator && account.isMock ? (
+
+ ) : null}
>
) : (
diff --git a/components/dashboard/driver-hire-purchase/mock-repayment-simulator.tsx b/components/dashboard/driver-hire-purchase/mock-repayment-simulator.tsx
new file mode 100644
index 0000000..e146a5d
--- /dev/null
+++ b/components/dashboard/driver-hire-purchase/mock-repayment-simulator.tsx
@@ -0,0 +1,103 @@
+"use client"
+
+import { useRouter } from "next/navigation"
+import { useState } from "react"
+import { FlaskConical } from "lucide-react"
+
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { formatNaira } from "@/lib/currency"
+import { useToast } from "@/hooks/use-toast"
+
+interface MockRepaymentSimulatorProps {
+ defaultAmountNgn: number
+ maxAmountNgn: number
+}
+
+export function MockRepaymentSimulator({ defaultAmountNgn, maxAmountNgn }: MockRepaymentSimulatorProps) {
+ const router = useRouter()
+ const { toast } = useToast()
+ const [amountNgn, setAmountNgn] = useState(String(defaultAmountNgn))
+ const [isSubmitting, setIsSubmitting] = useState(false)
+
+ const handleSimulate = async () => {
+ const parsedAmount = Number(amountNgn)
+ if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) {
+ toast({
+ title: "Invalid amount",
+ description: "Enter a positive NGN amount to simulate a mock bank transfer.",
+ variant: "destructive",
+ })
+ return
+ }
+
+ if (parsedAmount > maxAmountNgn) {
+ toast({
+ title: "Amount too high",
+ description: `Mock repayments cannot exceed the remaining balance of ${formatNaira(maxAmountNgn)}.`,
+ variant: "destructive",
+ })
+ return
+ }
+
+ setIsSubmitting(true)
+ try {
+ const response = await fetch("/api/driver/payments/mock-repay", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ amountNgn: parsedAmount }),
+ })
+ const payload = await response.json().catch(() => ({}))
+
+ if (!response.ok) {
+ throw new Error(typeof payload.message === "string" ? payload.message : "Mock repayment failed.")
+ }
+
+ toast({
+ title: "Mock repayment applied",
+ description: `${formatNaira(parsedAmount)} was credited to your contract using local test mode.`,
+ })
+ router.refresh()
+ } catch (error) {
+ toast({
+ title: "Simulation failed",
+ description: error instanceof Error ? error.message : "Unable to simulate a mock repayment.",
+ variant: "destructive",
+ })
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ return (
+
+
+ Local mock repayment mode
+
+
+ This dedicated account is fake test data. No Paystack API calls are made while mock payments are enabled.
+
+
+
+
+ setAmountNgn(event.target.value)}
+ disabled={isSubmitting}
+ />
+
+
+
+
+
+ )
+}
diff --git a/lib/services/__tests__/paystack-mock.service.test.ts b/lib/services/__tests__/paystack-mock.service.test.ts
new file mode 100644
index 0000000..712c3c2
--- /dev/null
+++ b/lib/services/__tests__/paystack-mock.service.test.ts
@@ -0,0 +1,74 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+
+import {
+ createMockDriverDvaDetails,
+ createMockDvaRepaymentReference,
+ isMockPaymentsEnabled,
+ isMockPaymentsRuntimeAllowed,
+ isMockVirtualAccountRecord,
+ MOCK_PAYSTACK_ACCOUNT_NAME,
+ MOCK_PAYSTACK_BANK_NAME,
+ MOCK_PAYSTACK_PROVIDER,
+} from "../paystack-mock.service"
+
+describe("paystack-mock.service", () => {
+ beforeEach(() => {
+ vi.unstubAllEnvs()
+ })
+
+ afterEach(() => {
+ vi.unstubAllEnvs()
+ })
+
+ it("enables mock payments only when ENABLE_MOCK_PAYMENTS is true", () => {
+ expect(isMockPaymentsEnabled()).toBe(false)
+
+ vi.stubEnv("ENABLE_MOCK_PAYMENTS", "true")
+ expect(isMockPaymentsEnabled()).toBe(true)
+ })
+
+ it("blocks mock runtime actions in production", () => {
+ vi.stubEnv("ENABLE_MOCK_PAYMENTS", "true")
+ vi.stubEnv("NODE_ENV", "production")
+ expect(isMockPaymentsRuntimeAllowed()).toBe(false)
+
+ vi.stubEnv("NODE_ENV", "development")
+ expect(isMockPaymentsRuntimeAllowed()).toBe(true)
+ })
+
+ it("returns visibly fake deterministic mock DVA details", () => {
+ const first = createMockDriverDvaDetails({
+ driverUserId: "64b1f2a3c4d5e6f7a8b9c0d1",
+ contractId: "74c1f2a3c4d5e6f7a8b9c0d2",
+ })
+ const second = createMockDriverDvaDetails({
+ driverUserId: "64b1f2a3c4d5e6f7a8b9c0d1",
+ contractId: "74c1f2a3c4d5e6f7a8b9c0d2",
+ })
+ const other = createMockDriverDvaDetails({
+ driverUserId: "84c1f2a3c4d5e6f7a8b9c0d3",
+ contractId: "74c1f2a3c4d5e6f7a8b9c0d2",
+ })
+
+ expect(first.provider).toBe(MOCK_PAYSTACK_PROVIDER)
+ expect(first.bankName).toBe(MOCK_PAYSTACK_BANK_NAME)
+ expect(first.accountName).toContain(MOCK_PAYSTACK_ACCOUNT_NAME)
+ expect(first.accountNumber).toMatch(/^0000[0-9a-f]{6}$/)
+ expect(first.mock).toBe(true)
+ expect(first.testOnly).toBe(true)
+ expect(first.accountNumber).toBe(second.accountNumber)
+ expect(first.accountNumber).not.toBe(other.accountNumber)
+ expect(first.reference).toMatch(/^mock_dva_\d+$/)
+ })
+
+ it("creates mock repayment references with the expected prefix", () => {
+ expect(createMockDvaRepaymentReference()).toMatch(/^mock_dva_repay_\d+$/)
+ })
+
+ it("detects mock virtual account records from raw response metadata", () => {
+ expect(isMockVirtualAccountRecord({ mock: true })).toBe(true)
+ expect(isMockVirtualAccountRecord({ provider: MOCK_PAYSTACK_PROVIDER })).toBe(true)
+ expect(isMockVirtualAccountRecord({ provider: "PAYSTACK" })).toBe(false)
+ expect(isMockVirtualAccountRecord(null)).toBe(false)
+ })
+})
diff --git a/lib/services/paystack-dva.service.ts b/lib/services/paystack-dva.service.ts
index 566462a..9eaa790 100644
--- a/lib/services/paystack-dva.service.ts
+++ b/lib/services/paystack-dva.service.ts
@@ -4,6 +4,11 @@ import dbConnect from "@/lib/dbConnect"
import HirePurchaseContract from "@/models/HirePurchaseContract"
import DriverVirtualAccount from "@/models/DriverVirtualAccount"
import { resolveDvaUserIdentity } from "@/lib/services/dva-user-identity.service"
+import {
+ createMockDriverDvaDetails,
+ isMockPaymentsEnabled,
+ isMockVirtualAccountRecord,
+} from "@/lib/services/paystack-mock.service"
export interface DriverVirtualAccountSnapshot {
id: string
@@ -21,6 +26,8 @@ export interface DriverVirtualAccountSnapshot {
currency: string | null
failureReason: string | null
rawResponse: Record | null
+ isMock: boolean
+ mockReference: string | null
createdAt: string
updatedAt: string
}
@@ -87,6 +94,11 @@ function toIsoDate(value: Date | string | null | undefined) {
}
function mapDriverVirtualAccountSnapshot(doc: any): DriverVirtualAccountSnapshot {
+ const rawResponse = doc.rawResponse && typeof doc.rawResponse === "object" ? doc.rawResponse : null
+ const isMock = isMockVirtualAccountRecord(rawResponse) || doc.providerSlug === "mock-paystack"
+ const mockReference =
+ rawResponse && typeof rawResponse.reference === "string" ? rawResponse.reference : null
+
return {
id: doc._id.toString(),
driverUserId: doc.driverUserId.toString(),
@@ -102,7 +114,9 @@ function mapDriverVirtualAccountSnapshot(doc: any): DriverVirtualAccountSnapshot
providerSlug: doc.providerSlug || null,
currency: doc.currency || null,
failureReason: doc.failureReason || null,
- rawResponse: doc.rawResponse && typeof doc.rawResponse === "object" ? doc.rawResponse : null,
+ rawResponse,
+ isMock,
+ mockReference,
createdAt: toIsoDate(doc.createdAt),
updatedAt: toIsoDate(doc.updatedAt),
}
@@ -337,6 +351,45 @@ async function resolveActiveContract(driverUserId: string, contractId?: string)
return contract
}
+async function provisionMockDriverVirtualAccount(input: ProvisionDriverVirtualAccountInput) {
+ const contract = await resolveActiveContract(input.driverUserId, input.contractId)
+ const identity = await resolveDvaUserIdentity(contract.driverUserId.toString(), {
+ requiredRole: "driver",
+ })
+
+ const mockDetails = createMockDriverDvaDetails({
+ driverUserId: contract.driverUserId.toString(),
+ contractId: contract._id.toString(),
+ displayName: identity.fullName || identity.user?.name || null,
+ })
+
+ const savedDoc = await DriverVirtualAccount.findOneAndUpdate(
+ {
+ driverUserId: contract.driverUserId,
+ provider: "PAYSTACK",
+ },
+ {
+ $set: {
+ contractId: contract._id,
+ status: "ACTIVE",
+ paystackCustomerCode: `mock_customer_${contract.driverUserId.toString().slice(-8)}`,
+ paystackCustomerId: null,
+ dedicatedAccountId: null,
+ accountNumber: mockDetails.accountNumber,
+ accountName: mockDetails.accountName,
+ bankName: mockDetails.bankName,
+ providerSlug: mockDetails.providerSlug,
+ currency: mockDetails.currency,
+ rawResponse: mockDetails,
+ failureReason: null,
+ },
+ },
+ { upsert: true, new: true, setDefaultsOnInsert: true },
+ )
+
+ return mapDriverVirtualAccountSnapshot(savedDoc)
+}
+
export async function getDriverVirtualAccount(input: ProvisionDriverVirtualAccountInput) {
await dbConnect()
const driverObjectId = toObjectId(input.driverUserId, "driver user id")
@@ -371,6 +424,10 @@ export async function getDriverVirtualAccountByAccountNumber(accountNumber: stri
export async function provisionDriverVirtualAccount(input: ProvisionDriverVirtualAccountInput) {
await dbConnect()
+ if (isMockPaymentsEnabled()) {
+ return provisionMockDriverVirtualAccount(input)
+ }
+
const secretKey = getPaystackSecretKey()
const preferredBank = resolvePreferredBank(secretKey)
const contract = await resolveActiveContract(input.driverUserId, input.contractId)
diff --git a/lib/services/paystack-mock.service.ts b/lib/services/paystack-mock.service.ts
new file mode 100644
index 0000000..d0528ae
--- /dev/null
+++ b/lib/services/paystack-mock.service.ts
@@ -0,0 +1,95 @@
+import { createHash } from "crypto"
+
+export const MOCK_PAYSTACK_PROVIDER = "mock-paystack" as const
+export const MOCK_PAYSTACK_BANK_NAME = "Mock Test Bank"
+export const MOCK_PAYSTACK_ACCOUNT_NAME = "ChainMove Test Driver"
+export const MOCK_PAYSTACK_PROVIDER_SLUG = "mock-paystack"
+
+export interface MockDriverDvaDetails {
+ provider: typeof MOCK_PAYSTACK_PROVIDER
+ accountNumber: string
+ accountName: string
+ bankName: string
+ providerSlug: string
+ reference: string
+ currency: "NGN"
+ mock: true
+ testOnly: true
+}
+
+export function isMockPaymentsEnabled() {
+ return process.env.ENABLE_MOCK_PAYMENTS === "true"
+}
+
+export function isMockPaymentsRuntimeAllowed() {
+ return isMockPaymentsEnabled() && process.env.NODE_ENV !== "production"
+}
+
+function buildDeterministicAccountNumber(seed: string) {
+ const hash = createHash("sha256").update(seed).digest("hex")
+ return `0000${hash.slice(0, 6)}`
+}
+
+export function createMockDriverDvaDetails(input: {
+ driverUserId: string
+ contractId: string
+ displayName?: string | null
+}) {
+ const accountNumber = buildDeterministicAccountNumber(`${input.driverUserId}:${input.contractId}`)
+ const accountName = input.displayName?.trim()
+ ? `${MOCK_PAYSTACK_ACCOUNT_NAME} (${input.displayName.trim()})`
+ : MOCK_PAYSTACK_ACCOUNT_NAME
+
+ return {
+ provider: MOCK_PAYSTACK_PROVIDER,
+ accountNumber,
+ accountName,
+ bankName: MOCK_PAYSTACK_BANK_NAME,
+ providerSlug: MOCK_PAYSTACK_PROVIDER_SLUG,
+ reference: `mock_dva_${Date.now()}`,
+ currency: "NGN" as const,
+ mock: true as const,
+ testOnly: true as const,
+ } satisfies MockDriverDvaDetails
+}
+
+export function createMockDvaRepaymentReference() {
+ return `mock_dva_repay_${Date.now()}`
+}
+
+export function buildMockDedicatedNubanWebhookPayload(input: {
+ accountNumber: string
+ amountNgn: number
+ payerEmail?: string | null
+ reference?: string
+}) {
+ const reference = input.reference || createMockDvaRepaymentReference()
+ const amountKobo = Math.round(input.amountNgn * 100)
+
+ return {
+ event: "charge.success",
+ data: {
+ reference,
+ amount: amountKobo,
+ customer: {
+ email: input.payerEmail || "mock-driver@chainmove.test",
+ },
+ metadata: {
+ source: "mock_paystack_dva",
+ paymentType: "driver_repayment",
+ },
+ authorization: {
+ channel: "dedicated_nuban",
+ receiver_bank_account_number: input.accountNumber,
+ receiver_bank: MOCK_PAYSTACK_BANK_NAME,
+ sender_bank: "Mock Sender Bank",
+ sender_bank_account_number: "1111111111",
+ sender_name: "Mock Transfer Sender",
+ },
+ },
+ }
+}
+
+export function isMockVirtualAccountRecord(rawResponse: Record | null | undefined) {
+ return rawResponse?.mock === true || rawResponse?.provider === MOCK_PAYSTACK_PROVIDER
+}