diff --git a/.env.example b/.env.example index 676ead3..d807d92 100644 --- a/.env.example +++ b/.env.example @@ -49,6 +49,7 @@ TREASURY_ADDRESS=replace_with_public_treasury_address_only THIRDWEB_CLIENT_ID=replace_with_test_client_id_only_if_legacy_code_requires_it # Contributor Mock Mode +# ENABLE_MOCK_PAYMENTS=true skips live Paystack DVA provisioning and enables local mock repayment simulation. ENABLE_MOCK_PAYMENTS=true ENABLE_MOCK_EMAILS=true ENABLE_MOCK_STELLAR=true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c3ef153..5af6555 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,7 +35,7 @@ ENABLE_MOCK_STELLAR=true Recommended behavior for contributors implementing mock support: - Paystack checkout: return a fake successful initialization response with a local authorization URL and generated reference. -- Paystack dedicated virtual accounts: return a deterministic mock bank account for driver/investor repayment testing. +- Paystack dedicated virtual accounts: return a deterministic mock bank account for driver/investor repayment testing. When `ENABLE_MOCK_PAYMENTS=true`, driver repayment provisioning uses `lib/services/paystack-mock.service.ts` and the Repayment Center exposes a local-only "Simulate Mock Transfer" action. - Resend email: log a safe mock email result without calling Resend. - Stellar: return deterministic testnet-shaped account, asset, payment, and contract responses without requiring sensitive credentials. diff --git a/app/api/driver/payments/mock-repay/route.ts b/app/api/driver/payments/mock-repay/route.ts new file mode 100644 index 0000000..0e13e88 --- /dev/null +++ b/app/api/driver/payments/mock-repay/route.ts @@ -0,0 +1,96 @@ +import { NextResponse } from "next/server" + +import { getAuthenticatedUser, withSessionRefresh } from "@/lib/auth/current-user" +import { getDriverContract, createAndConfirmDriverTransferPayment } from "@/lib/services/driver-contracts.service" +import { + createMockDvaRepaymentReference, + isMockPaymentsRuntimeAllowed, +} from "@/lib/services/paystack-mock.service" +import { getDriverVirtualAccount } from "@/lib/services/paystack-dva.service" + +export async function POST(request: Request) { + if (!isMockPaymentsRuntimeAllowed()) { + return NextResponse.json( + { + success: false, + code: "MOCK_PAYMENTS_DISABLED", + message: "Mock repayment simulation is only available in local/test mode.", + }, + { status: 403 }, + ) + } + + try { + const { user, shouldRefreshSession } = await getAuthenticatedUser(request) + if (!user) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + if (user.role !== "driver") { + return NextResponse.json({ message: "Only drivers can simulate mock repayments." }, { status: 403 }) + } + + const body = await request.json().catch(() => ({})) + const amountNgn = Number(body?.amountNgn) + if (!Number.isFinite(amountNgn) || amountNgn <= 0) { + return NextResponse.json({ message: "A positive amountNgn value is required." }, { status: 400 }) + } + + const contract = await getDriverContract(user._id.toString()) + if (!contract || contract.status !== "ACTIVE") { + return NextResponse.json( + { message: "An active hire-purchase contract is required before simulating a repayment." }, + { status: 404 }, + ) + } + + const virtualAccount = await getDriverVirtualAccount({ + driverUserId: user._id.toString(), + contractId: contract.id, + }) + + if (!virtualAccount?.accountNumber || !virtualAccount.isMock) { + return NextResponse.json( + { + message: "A mock dedicated repayment account must be provisioned before simulating a transfer.", + }, + { status: 404 }, + ) + } + + const paystackRef = createMockDvaRepaymentReference() + const settlementResult = await createAndConfirmDriverTransferPayment({ + contractId: contract.id, + driverUserId: user._id.toString(), + amountNgn, + payerEmail: user.email || "mock-driver@chainmove.test", + paystackRef, + channel: "dedicated_nuban", + metadata: { + source: "mock_paystack_dva_simulation", + paymentType: "driver_repayment", + receiverBankAccountNumber: virtualAccount.accountNumber, + mock: true, + testOnly: true, + }, + }) + + const response = NextResponse.json({ + success: true, + data: { + reference: paystackRef, + amountNgn, + alreadyProcessed: settlementResult.alreadyProcessed, + contract: settlementResult.contract, + payment: settlementResult.payment, + mock: true, + testOnly: true, + }, + }) + + return shouldRefreshSession ? withSessionRefresh(response, user) : response + } catch (error) { + const message = error instanceof Error ? error.message : "Unable to simulate mock repayment." + return NextResponse.json({ success: false, message }, { status: 500 }) + } +} diff --git a/app/api/driver/virtual-account/route.ts b/app/api/driver/virtual-account/route.ts index cfc7d30..c1270f8 100644 --- a/app/api/driver/virtual-account/route.ts +++ b/app/api/driver/virtual-account/route.ts @@ -42,6 +42,9 @@ export async function GET(request: Request) { contractId: contract.id, remainingBalanceNgn: contract.remainingBalanceNgn, nextPaymentAmountNgn: contract.nextPaymentAmountNgn, + isMock: virtualAccount.isMock, + mockReference: virtualAccount.mockReference, + testOnly: virtualAccount.isMock, }, }) diff --git a/app/dashboard/driver/repayment/page.tsx b/app/dashboard/driver/repayment/page.tsx index 90eaca6..8d0c47c 100644 --- a/app/dashboard/driver/repayment/page.tsx +++ b/app/dashboard/driver/repayment/page.tsx @@ -14,6 +14,7 @@ import dbConnect from "@/lib/dbConnect" import { getSessionFromCookies } from "@/lib/auth/session" import { getDriverContract, getDriverPayments } from "@/lib/services/driver-contracts.service" import { getOrProvisionDriverVirtualAccount } from "@/lib/services/paystack-dva.service" +import { isMockPaymentsRuntimeAllowed } from "@/lib/services/paystack-mock.service" import User from "@/models/User" export const dynamic = "force-dynamic" @@ -88,9 +89,12 @@ export default async function DriverRepaymentPage() { bankName: string providerSlug?: string | null status: "PENDING" | "ACTIVE" | "FAILED" | "INACTIVE" + isMock?: boolean + mockReference?: string | null } | null = null let virtualAccountError: string | null = null + const showMockSimulator = isMockPaymentsRuntimeAllowed() try { const provisionedAccount = await getOrProvisionDriverVirtualAccount({ @@ -105,6 +109,8 @@ export default async function DriverRepaymentPage() { bankName: provisionedAccount.bankName, providerSlug: provisionedAccount.providerSlug, status: provisionedAccount.status, + isMock: provisionedAccount.isMock, + mockReference: provisionedAccount.mockReference, } } } catch (error) { @@ -151,6 +157,7 @@ export default async function DriverRepaymentPage() { errorMessage={virtualAccountError} remainingBalanceNgn={contract.remainingBalanceNgn} nextPaymentAmountNgn={contract.nextPaymentAmountNgn || contract.weeklyPaymentNgn} + showMockSimulator={showMockSimulator} /> diff --git a/components/dashboard/driver-hire-purchase/driver-virtual-account-card.tsx b/components/dashboard/driver-hire-purchase/driver-virtual-account-card.tsx index d9cefdc..110bd02 100644 --- a/components/dashboard/driver-hire-purchase/driver-virtual-account-card.tsx +++ b/components/dashboard/driver-hire-purchase/driver-virtual-account-card.tsx @@ -1,12 +1,13 @@ "use client" import { useState } from "react" -import { AlertCircle, Building2, CheckCircle2, Copy, Landmark, Wallet } from "lucide-react" +import { AlertCircle, Building2, CheckCircle2, Copy, FlaskConical, Landmark, Wallet } from "lucide-react" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" +import { MockRepaymentSimulator } from "@/components/dashboard/driver-hire-purchase/mock-repayment-simulator" import { formatNaira } from "@/lib/currency" import { useToast } from "@/hooks/use-toast" @@ -18,11 +19,14 @@ interface DriverVirtualAccountCardProps { bankName: string providerSlug?: string | null status: "PENDING" | "ACTIVE" | "FAILED" | "INACTIVE" + isMock?: boolean + mockReference?: string | null } | null errorMessage?: string | null remainingBalanceNgn: number nextPaymentAmountNgn: number + showMockSimulator?: boolean } function resolveStatusBadgeVariant(status?: string | null) { @@ -37,6 +41,7 @@ export function DriverVirtualAccountCard({ errorMessage, remainingBalanceNgn, nextPaymentAmountNgn, + showMockSimulator = false, }: DriverVirtualAccountCardProps) { const { toast } = useToast() const [isCopying, setIsCopying] = useState(false) @@ -73,12 +78,24 @@ export function DriverVirtualAccountCard({ - {account?.status || (errorMessage ? "Unavailable" : "Provisioning")} + {account?.isMock ? "Mock Test Account" : account?.status || (errorMessage ? "Unavailable" : "Provisioning")} + {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 +}