Skip to main content

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.
Withdraw funds to mobile money accounts (MTN, Vodafone, AirtelTigo) with real-time verification.

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>

Form Validation

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.

Build docs developers (and LLMs) love