Overview
The payout feature enables riders to withdraw their earnings from the wallet to mobile money accounts or bank accounts. The system includes real-time account verification for mobile money using Paystack integration.
Request Payout Screen
Main Component
The payout request form supports both mobile money and bank transfer methods.
src/app/(parcel)/(stack)/request-payment.tsx
import { FormField } from "@/components/form-field" ;
import { SelectField } from "@/components/select-field" ;
import { getUserWalletQueryOptions } from "@/lib/tanstack-query/query-options/users" ;
import { MobileMoneyField } from "@/modules/dashboard/parcels/riders/request-payout/mobile-money-field" ;
import { BankTransferFields } from "@/modules/dashboard/parcels/riders/request-payout/bank-transfer-fields" ;
import { payoutRequestSchema } from "@/modules/dashboard/parcels/validations/payout.validation" ;
import { api } from "@/services/api" ;
import { PayoutMethod } from "@/types/enums/payout-enums" ;
import { formatCurrency } from "@/utils/currency" ;
import { zodResolver } from "@hookform/resolvers/zod" ;
import { useMutation , useQuery } from "@tanstack/react-query" ;
import { useRouter } from "expo-router" ;
import { Button , useToast } from "heroui-native" ;
import { useForm } from "react-hook-form" ;
export default function RequestPaymentScreen () {
const router = useRouter ();
const [ success , setSuccess ] = useState ( false );
const { toast } = useToast ();
const { data : walletResponse , isLoading : isWalletLoading } = useQuery (
getUserWalletQueryOptions (),
);
const wallet = walletResponse ?. data ;
const balance = wallet ?. balance ? parseFloat ( wallet . balance ) : 0 ;
const { control , handleSubmit , watch , setValue , reset } =
useForm < PayoutRequestFormData >({
resolver: zodResolver ( payoutRequestSchema ),
defaultValues: {
amount: 0 ,
payoutMethod: PayoutMethod . MOBILE_MONEY ,
},
});
const payoutMethod = watch ( "payoutMethod" );
const amount = watch ( "amount" );
const requestPayoutMutation = useMutation ({
mutationFn: api . users . createPayoutRequest ,
onSuccess : () => {
toast . show ({
variant: "success" ,
label: "Payout request submitted successfully" ,
});
setSuccess ( true );
reset ();
router . back ();
},
});
const handleWithdrawAll = () => {
setValue ( "amount" , balance . toString ());
};
const onSubmit = ( data : PayoutRequestFormData ) => {
requestPayoutMutation . mutate ( data );
};
return (
< KeyboardAwareScrollView >
< View className = "p-5" >
{ /* Balance Card */ }
< View className = "bg-accent/10 border border-accent/20 rounded-2xl p-4 mb-6" >
< Text className = "text-xs text-gray-600 font-medium mb-1" >
Available Balance
</ Text >
{ isWalletLoading ? (
< Skeleton className = "w-32 h-8 rounded-lg" />
) : (
< Text className = "text-3xl font-bold text-secondary" >
{ AppConfig . currency . symbol } { formatCurrency ( balance ) }
</ Text >
) }
</ View >
{ /* Amount Field */ }
< View className = "gap-5" >
< View >
< View className = "flex-row gap-2" >
< View className = "flex-1" >
< FormField
control = { control }
name = "amount"
label = "Amount *"
placeholder = "Enter amount"
keyboardType = "numeric"
/>
</ View >
< Button
size = "sm"
variant = "ghost"
onPress = { handleWithdrawAll }
className = "mt-1 border border-accent"
>
< Text className = "font-medium text-accent" > All </ Text >
</ Button >
</ View >
</ View >
{ /* Payout Method */ }
< SelectField
control = { control }
name = "payoutMethod"
label = "Payout Method *"
placeholder = "Select payout method"
options = { [
{ label: "Mobile Money" , value: PayoutMethod . MOBILE_MONEY },
{ label: "Bank Transfer" , value: PayoutMethod . BANK_TRANSFER },
] }
/>
{ /* Method-specific Fields */ }
{ payoutMethod === PayoutMethod . MOBILE_MONEY && (
< MobileMoneyField control = { control } />
) }
{ payoutMethod === PayoutMethod . BANK_TRANSFER && (
< BankTransferFields control = { control } />
) }
{ /* Notes Field */ }
< FormField
control = { control }
name = "notes"
label = "Notes (Optional)"
placeholder = "Add any additional notes"
multiline
numberOfLines = { 3 }
/>
{ /* Submit Button */ }
< Button
size = "lg"
onPress = { handleSubmit ( onSubmit ) }
isDisabled = { amount <= 0 || amount > balance }
>
< Text className = "text-white font-medium" > Submit Request </ Text >
</ Button >
</ View >
</ View >
</ KeyboardAwareScrollView >
);
}
Payout Methods
The app supports two primary payout methods.
Mobile Money
Bank Transfer
Withdraw funds to mobile money accounts (MTN, Vodafone, AirtelTigo) with real-time verification.
Transfer earnings directly to bank accounts with standard processing times.
Payout Method Enum
src/types/enums/payout-enums.ts
export enum PayoutMethod {
MOBILE_MONEY = "mobile_money" ,
BANK_TRANSFER = "bank_transfer" ,
}
export enum PayoutRequestStatus {
PENDING = "pending" ,
APPROVED = "approved" ,
COMPLETED = "completed" ,
REJECTED = "rejected" ,
CANCELLED = "cancelled" ,
FAILED = "failed" ,
}
Mobile Money Verification
Mobile Money Field Component
The mobile money form includes real-time account verification.
src/modules/dashboard/parcels/riders/request-payout/mobile-money-field.tsx
import { FormField } from "@/components/form-field" ;
import { SelectField } from "@/components/select-field" ;
import { MOBILE_MONEY_PROVIDERS } from "@/constants/data" ;
import { Control } from "react-hook-form" ;
import { ActivityIndicator , Text , View } from "react-native" ;
export function MobileMoneyField ({ control , isVerifying } : MobileMoneyFieldProps ) {
return (
< View className = "gap-4" >
< SelectField
control = { control }
name = "mobileMoneyProvider"
label = "Mobile Money Provider *"
placeholder = "Select provider"
options = { MOBILE_MONEY_PROVIDERS }
/>
< View >
< Text className = "text-sm font-medium text-secondary mb-2" >
Mobile Money Number *
</ Text >
< View className = "relative" >
< FormField
control = { control }
name = "mobileMoneyNumber"
placeholder = "e.g., 0241234567"
keyboardType = "phone-pad"
/>
{ isVerifying && (
< View className = "absolute right-3 top-3" >
< ActivityIndicator size = "small" color = "#3b82f6" />
</ View >
) }
</ View >
{ isVerifying && (
< Text className = "text-xs text-blue-600 mt-1" >
Verifying account...
</ Text >
) }
</ View >
</ View >
);
}
Auto-Verification
The app automatically verifies mobile money accounts when both provider and number are entered.
const { isVerifying , verificationError , verifiedAccountName , handleVerifyMobileNumber } =
useVerifyPhoneNumber ();
// Auto-verify when provider and number are available
useEffect (() => {
if (
payoutMethod === PayoutMethod . MOBILE_MONEY &&
mobileMoneyProvider &&
mobileMoneyNumber &&
mobileMoneyNumber . length >= 10
) {
const timeoutId = setTimeout (() => {
handleVerifyMobileNumber ( mobileMoneyProvider , mobileMoneyNumber );
}, 1000 );
return () => clearTimeout ( timeoutId );
}
}, [ payoutMethod , mobileMoneyProvider , mobileMoneyNumber ]);
Account verification uses a debounced approach to avoid excessive API calls while the user is still typing.
Verification Success
When verification succeeds, the account name is displayed.
{ verifiedAccountName && (
< Alert
variant = "success"
title = "Account Verified Successfully!"
message = { `Account Name: ${ verifiedAccountName } ` }
/>
)}
Paystack Integration
The mobile money verification uses Paystack’s API.
src/lib/payment-gateways/paystack/verify-mobile-money.ts
export async function verifyMobileMoneyAccount (
accountNumber : string ,
provider : string ,
) : Promise < ApiResponse < VerifyMobileMoneyResponse >> {
try {
// Clean the phone number
const cleanedNumber = accountNumber . replace ( / [ \s- ] / g , "" );
// Validate phone number format
const phoneRegex = / ^ [ 0-9 ] {10,15} $ / ;
if ( ! phoneRegex . test ( cleanedNumber )) {
throw new Error ( "Invalid phone number format" );
}
// TODO: Implement actual Paystack API integration
// For now, simulate API call
await new Promise (( resolve ) => setTimeout ( resolve , 1000 ));
const response : VerifyMobileMoneyResponse = {
accountName: `Verified Account ${ cleanedNumber . slice ( - 4 ) } ` ,
};
return {
data: response ,
message: "Account verified successfully" ,
status: 200 ,
};
} catch ( error ) {
throw new Error (
error instanceof Error
? error . message
: "Failed to verify mobile money account" ,
);
}
}
The current implementation is a placeholder. Production code should integrate with the actual Paystack API endpoint for mobile money verification.
Bank Transfer Fields
For bank transfers, the app collects standard banking information.
src/modules/dashboard/parcels/riders/request-payout/bank-transfer-fields.tsx
import { FormField } from "@/components/form-field" ;
import { Control } from "react-hook-form" ;
import { View } from "react-native" ;
export function BankTransferFields ({ control } : BankTransferFieldsProps ) {
return (
< View className = "gap-4" >
< View className = "flex-row gap-3" >
< View className = "flex-1" >
< FormField
control = { control }
name = "accountNumber"
label = "Account Number *"
placeholder = "Enter account number"
keyboardType = "numeric"
/>
</ View >
< View className = "flex-1" >
< FormField
control = { control }
name = "accountName"
label = "Account Name *"
placeholder = "Enter account name"
/>
</ View >
</ View >
< View className = "flex-row gap-3" >
< View className = "flex-1" >
< FormField
control = { control }
name = "bankName"
label = "Bank Name *"
placeholder = "e.g., Standard Bank"
/>
</ View >
< View className = "flex-1" >
< FormField
control = { control }
name = "bankCode"
label = "Bank Code (Optional)"
placeholder = "e.g., 001"
/>
</ View >
</ View >
</ View >
);
}
Required Bank Details
Account Number The recipient’s bank account number.
Account Name Full name as registered with the bank.
Bank Name Name of the financial institution.
Bank Code Optional routing or bank identification code.
Withdraw All Feature
Riders can quickly withdraw their entire balance with a single tap.
const handleWithdrawAll = () => {
const amountValue = balance ?. toString ();
setValue ( "amount" , amountValue );
};
< Button
size = "sm"
variant = "ghost"
onPress = { handleWithdrawAll }
className = "border border-accent"
>
< Text className = "font-medium text-accent" > All </ Text >
</ Button >
The payout form uses Zod schema validation.
src/modules/dashboard/parcels/validations/payout.validation.ts
import { PayoutMethod } from "@/types/enums/payout-enums" ;
import { z } from "zod" ;
export const payoutRequestSchema = z . object ({
amount: z . number (). min ( 1 , "Amount must be greater than 0" ),
payoutMethod: z . nativeEnum ( PayoutMethod ),
mobileMoneyProvider: z . string (). optional (),
mobileMoneyNumber: z . string (). optional (),
mobileMoneyAccountName: z . string (). optional (),
accountNumber: z . string (). optional (),
accountName: z . string (). optional (),
bankName: z . string (). optional (),
bankCode: z . string (). optional (),
notes: z . string (). optional (),
});
export type PayoutRequestFormData = z . infer < typeof payoutRequestSchema >;
Validation Rules
Must be greater than 0
Cannot exceed available balance
Validated against wallet balance
Required field using enum validation
Determines which additional fields are required
Provider and number required when method is mobile money
Account must be verified before submission
Account number, name, and bank name required
Bank code is optional
Submit Protection
The submit button is disabled when conditions aren’t met.
< Button
size = "lg"
onPress = { handleSubmit ( onSubmit ) }
isDisabled = {
isSubmitting ||
amount <= 0 ||
amount > balance ||
( payoutMethod === PayoutMethod . MOBILE_MONEY && ! verifiedAccountName )
}
>
{ isSubmitting ? (
< View className = "flex-row items-center gap-2" >
< ActivityIndicator size = "small" color = "white" />
< Text className = "text-white font-medium" > Processing... </ Text >
</ View >
) : (
< Text className = "text-white font-medium" > Submit Request </ Text >
) }
</ Button >
Success State
After successful submission, users see a success screen.
src/modules/dashboard/parcels/riders/request-payout/payout-success.tsx
export function PayoutSuccess () {
const router = useRouter ();
return (
< View className = "flex-1 bg-white items-center justify-center p-8" >
< View className = "bg-green-100 rounded-full p-8 mb-6" >
< Ionicons name = "checkmark-circle" size = { 80 } color = "#15803d" />
</ View >
< Text className = "text-2xl font-bold text-secondary text-center mb-3" >
Payout Request Submitted!
</ Text >
< Text className = "text-base text-gray-600 text-center mb-8" >
Your withdrawal request has been submitted successfully.
You'll receive your funds within 1-3 business days.
</ Text >
< Button size = "lg" onPress = { () => router . replace ( "/(parcel)/(tabs)/" ) } >
< Text className = "text-white font-medium" > Back to Dashboard </ Text >
</ Button >
</ View >
);
}
Payout requests are processed by administrators and typically complete within 1-3 business days depending on the selected method.
Transactions View payout status in transaction history.
Dashboard Check wallet balance before requesting payout.
Profile Manage account information and settings.