Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
96 changes: 96 additions & 0 deletions app/api/driver/payments/mock-repay/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
}
3 changes: 3 additions & 0 deletions app/api/driver/virtual-account/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
})

Expand Down
7 changes: 7 additions & 0 deletions app/dashboard/driver/repayment/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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({
Expand All @@ -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) {
Expand Down Expand Up @@ -151,6 +157,7 @@ export default async function DriverRepaymentPage() {
errorMessage={virtualAccountError}
remainingBalanceNgn={contract.remainingBalanceNgn}
nextPaymentAmountNgn={contract.nextPaymentAmountNgn || contract.weeklyPaymentNgn}
showMockSimulator={showMockSimulator}
/>
</section>

Expand Down
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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) {
Expand All @@ -37,6 +41,7 @@ export function DriverVirtualAccountCard({
errorMessage,
remainingBalanceNgn,
nextPaymentAmountNgn,
showMockSimulator = false,
}: DriverVirtualAccountCardProps) {
const { toast } = useToast()
const [isCopying, setIsCopying] = useState(false)
Expand Down Expand Up @@ -73,12 +78,24 @@ export function DriverVirtualAccountCard({
</CardDescription>
</div>
<Badge variant={resolveStatusBadgeVariant(account?.status || (errorMessage ? "FAILED" : "PENDING"))}>
{account?.status || (errorMessage ? "Unavailable" : "Provisioning")}
{account?.isMock ? "Mock Test Account" : account?.status || (errorMessage ? "Unavailable" : "Provisioning")}
</Badge>
</div>
</CardHeader>

<CardContent className="space-y-4 p-4 md:p-5">
{account?.isMock ? (
<Alert className="border-dashed border-amber-400/80 bg-amber-50/70 dark:bg-amber-950/20">
<FlaskConical className="h-4 w-4 text-amber-700 dark:text-amber-300" />
<AlertTitle>Test-only mock Paystack account</AlertTitle>
<AlertDescription>
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}
</AlertDescription>
</Alert>
) : null}

{account ? (
<>
<div className="grid gap-4 rounded-lg border border-border/60 bg-background p-4 md:grid-cols-[1.2fr_0.8fr]">
Expand Down Expand Up @@ -142,10 +159,21 @@ export function DriverVirtualAccountCard({
Provider
</div>
<p className="mt-2">
{account.providerSlug ? account.providerSlug : "Paystack dedicated virtual account"}
{account.isMock
? "mock-paystack (local test provider)"
: account.providerSlug
? account.providerSlug
: "Paystack dedicated virtual account"}
</p>
</div>
</div>

{showMockSimulator && account.isMock ? (
<MockRepaymentSimulator
defaultAmountNgn={nextPaymentAmountNgn}
maxAmountNgn={remainingBalanceNgn}
/>
) : null}
</>
) : (
<Alert variant="destructive">
Expand Down
103 changes: 103 additions & 0 deletions components/dashboard/driver-hire-purchase/mock-repayment-simulator.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Alert className="border-amber-300/80 bg-amber-50/90 dark:border-amber-900/70 dark:bg-amber-950/30">
<FlaskConical className="h-4 w-4 text-amber-700 dark:text-amber-300" />
<AlertTitle>Local mock repayment mode</AlertTitle>
<AlertDescription className="space-y-3">
<p>
This dedicated account is fake test data. No Paystack API calls are made while mock payments are enabled.
</p>
<div className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-end">
<div className="space-y-2">
<Label htmlFor="mock-repayment-amount">Simulate incoming transfer (NGN)</Label>
<Input
id="mock-repayment-amount"
type="number"
min={1}
max={maxAmountNgn}
step="0.01"
value={amountNgn}
onChange={(event) => setAmountNgn(event.target.value)}
disabled={isSubmitting}
/>
</div>
<Button type="button" variant="secondary" onClick={handleSimulate} disabled={isSubmitting}>
{isSubmitting ? "Applying..." : "Simulate Mock Transfer"}
</Button>
</div>
</AlertDescription>
</Alert>
)
}
Loading
Loading