Skip to main content

Overview

The transactions feature provides riders with a comprehensive view of all wallet activities, including payments received, withdrawals, and payout statuses. The interface supports filtering by transaction type and implements infinite scroll pagination for smooth browsing.

Transaction History Screen

Main Component

The transactions screen displays a filterable list of all wallet transactions.
src/app/(parcel)/(tabs)/transactions.tsx
import { getUserWalletQueryOptions } from "@/lib/tanstack-query/query-options/users";
import { api } from "@/services/api";
import { WalletTransactionTypes } from "@/types/enums/index.enum";
import { formatCurrency } from "@/utils/currency";
import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Skeleton } from "heroui-native";
import { Pressable, RefreshControl, Text, View } from "react-native";

const FILTER_OPTIONS = [
  { value: "all", label: "All", icon: "apps" },
  { value: WalletTransactionTypes.PAYMENT_RECEIVED, label: "Received", icon: "arrow-down" },
  { value: WalletTransactionTypes.WITHDRAWAL, label: "Withdrawn", icon: "arrow-up" },
  { value: WalletTransactionTypes.DEBIT, label: "Debit", icon: "remove" },
  { value: WalletTransactionTypes.ADJUSTMENT, label: "Adjustment", icon: "swap-horizontal" },
  { value: WalletTransactionTypes.PAYOUT_PENDING, label: "Pending", icon: "time" },
  { value: WalletTransactionTypes.PAYOUT_APPROVED, label: "Approved", icon: "checkmark" },
];

export default function Transactions() {
  const [selectedFilter, setSelectedFilter] = React.useState<string>("all");
  const [refreshing, setRefreshing] = React.useState(false);

  const { data: walletResponse, isLoading: isWalletLoading } = useQuery(
    getUserWalletQueryOptions(),
  );

  const { data, isLoading, hasNextPage, fetchNextPage, refetch } = useInfiniteQuery({
    queryKey: ["transactions", "infinite", selectedFilter],
    queryFn: ({ pageParam = 1 }) => {
      const params: { limit: number; page: number; type?: string } = {
        limit: 10,
        page: pageParam,
      };
      if (selectedFilter !== "all") {
        params.type = selectedFilter;
      }
      return api.users.transactions(params);
    },
    getNextPageParam: (lastPage) => {
      const currentPage = lastPage.data.meta.currentPage;
      const totalPages = lastPage.data.meta.totalPages;
      return currentPage < totalPages ? currentPage + 1 : undefined;
    },
    initialPageParam: 1,
  });

  const transactions = data?.pages.flatMap((page) => page.data.items) || [];
  const wallet = walletResponse?.data;
  const totalEarned = wallet?.totalEarned ? parseFloat(wallet.totalEarned) : 0;
  const balance = wallet?.balance ? parseFloat(wallet.balance) : 0;
  const totalWithdrawn = totalEarned - balance;

  return (
    <View className="flex-1 bg-gray-50">
      {/* Header */}
      <View className="bg-white px-5 pt-safe pb-4">
        <Text className="text-xl font-bold text-secondary">Transactions</Text>
      </View>

      {/* Summary Stats */}
      <View className="bg-white px-5 pb-4">
        <View className="flex-row gap-3">
          <View className="flex-1 bg-green-50 border border-green-200 rounded-xl p-3">
            <Text className="text-xs text-green-700 font-medium mb-1">
              Total Transactions
            </Text>
            <Text className="text-2xl font-bold text-green-900">
              {totalTransactions}
            </Text>
          </View>
          <View className="flex-1 bg-blue-50 border border-blue-200 rounded-xl p-3">
            <Text className="text-xs text-blue-700 font-medium mb-1">
              Total Received
            </Text>
            <Text className="text-xl font-bold text-blue-900">
              {AppConfig.currency.symbol}{formatCurrency(totalReceived)}
            </Text>
          </View>
        </View>
        <View className="mt-3">
          <View className="bg-red-50 border border-red-200 rounded-xl p-3">
            <Text className="text-xs text-red-700 font-medium mb-1">
              Total Withdrawn
            </Text>
            <Text className="text-xl font-bold text-red-900">
              {AppConfig.currency.symbol}{formatCurrency(totalWithdrawn)}
            </Text>
          </View>
        </View>
      </View>

      {/* Filters */}
      <View className="bg-white px-5 py-3 mb-2">
        <View className="flex-row flex-wrap gap-2">
          {FILTER_OPTIONS.map((filter) => (
            <Pressable key={filter.value}>
              <View
                className={`flex-row items-center gap-1.5 px-3 py-2 rounded-full ${
                  selectedFilter === filter.value ? "bg-accent" : "bg-gray-100"
                }`}
                onTouchEnd={() => setSelectedFilter(filter.value)}
              >
                <Ionicons name={filter.icon} size={14} />
                <Text className="text-xs font-medium">{filter.label}</Text>
              </View>
            </Pressable>
          ))}
        </View>
      </View>

      {/* Transaction List */}
      <View className="flex-1">
        <FlashList
          data={transactions}
          renderItem={({ item }) => <TransactionCard transaction={item} />}
          keyExtractor={(item) => item.id}
          onEndReached={() => hasNextPage && fetchNextPage()}
          refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
        />
      </View>
    </View>
  );
}

Summary Statistics

The top section displays key wallet metrics with color-coded cards.

Total Transactions

Shows the total number of transactions in the filtered view.

Total Received

Displays cumulative earnings from payment received transactions.

Total Withdrawn

Shows the total amount withdrawn from the wallet.

Calculation

const totalEarned = wallet?.totalEarned ? parseFloat(wallet.totalEarned) : 0;
const balance = wallet?.balance ? parseFloat(wallet.balance) : 0;
const totalWithdrawn = totalEarned - balance;

const totalReceived = allTransactions
  .filter((t) => t.type === WalletTransactionTypes.PAYMENT_RECEIVED)
  .reduce((sum, t) => sum + parseFloat(t.amount), 0);

Transaction Types

The app supports multiple transaction types for comprehensive wallet tracking.
Earnings from completed deliveries. Shows as positive amount in green.

Transaction Type Enum

src/types/enums/index.enum.ts
export enum WalletTransactionTypes {
  PAYMENT_RECEIVED = "payment_received",
  WITHDRAWAL = "withdrawal",
  DEBIT = "debit",
  ADJUSTMENT = "adjustment",
  PAYOUT_PENDING = "payout_pending",
  PAYOUT_APPROVED = "payout_approved",
  PAYOUT_REJECTED = "payout_rejected",
  PAYOUT_FAILED = "payout_failed",
}

Transaction Card Component

Each transaction is rendered with appropriate styling based on its type.
function TransactionCard({ transaction }: { transaction: Transaction }) {
  const getTransactionInfo = (type: WalletTransactionTypes) => {
    switch (type) {
      case WalletTransactionTypes.PAYMENT_RECEIVED:
        return {
          label: "Payment Received",
          icon: "arrow-down" as const,
          color: "#10b981",
        };
      case WalletTransactionTypes.WITHDRAWAL:
        return {
          label: "Withdrawal",
          icon: "arrow-up" as const,
          color: "#ef4444",
        };
      case WalletTransactionTypes.PAYOUT_PENDING:
        return {
          label: "Payout Pending",
          icon: "time" as const,
          color: "#f59e0b",
        };
      default:
        return {
          label: type,
          icon: "ellipse" as const,
          color: "#6b7280",
        };
    }
  };

  const isPositive =
    transaction.type === WalletTransactionTypes.PAYMENT_RECEIVED ||
    transaction.type === WalletTransactionTypes.ADJUSTMENT ||
    transaction.type === WalletTransactionTypes.PAYOUT_APPROVED;

  const amount = parseFloat(transaction.amount);
  const info = getTransactionInfo(transaction.type);

  return (
    <View className="bg-white rounded-xl p-4">
      <View className="flex-row items-center justify-between mb-2">
        <View className="flex-row items-center gap-3 flex-1">
          <View className="bg-gray-100 rounded-full p-2">
            <Ionicons name={info.icon} size={20} color={info.color} />
          </View>
          <View className="flex-1">
            <Text className="text-base font-semibold text-secondary">
              {info.label}
            </Text>
            <Text className="text-xs text-gray-400 mt-0.5">
              {formatDate(transaction.createdAt)}{formatTime(transaction.createdAt)}
            </Text>
          </View>
        </View>
        <Text
          className={`text-lg font-bold ${
            isPositive ? "text-green-600" : "text-gray-900"
          }`}
        >
          {isPositive ? "+" : ""}
          {AppConfig.currency.symbol}{formatCurrency(amount)}
        </Text>
      </View>
    </View>
  );
}

Date Formatting

const formatDate = (dateString: string) => {
  const date = new Date(dateString);
  const now = new Date();
  const diffDays = Math.floor(
    (now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24)
  );

  if (diffDays === 0) return "Today";
  if (diffDays === 1) return "Yesterday";
  if (diffDays < 7) return `${diffDays} days ago`;

  return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
};

const formatTime = (dateString: string) => {
  const date = new Date(dateString);
  return date.toLocaleTimeString("en-US", {
    hour: "numeric",
    minute: "2-digit",
  });
};

Filter Implementation

Users can filter transactions by type using chip-style filter buttons.
const FILTER_OPTIONS = [
  { value: "all", label: "All", icon: "apps" as const },
  { value: WalletTransactionTypes.PAYMENT_RECEIVED, label: "Received", icon: "arrow-down" as const },
  { value: WalletTransactionTypes.WITHDRAWAL, label: "Withdrawn", icon: "arrow-up" as const },
  { value: WalletTransactionTypes.DEBIT, label: "Debit", icon: "remove" as const },
  { value: WalletTransactionTypes.ADJUSTMENT, label: "Adjustment", icon: "swap-horizontal" as const },
  { value: WalletTransactionTypes.PAYOUT_PENDING, label: "Pending", icon: "time" as const },
  { value: WalletTransactionTypes.PAYOUT_APPROVED, label: "Approved", icon: "checkmark" as const },
];

<View className="flex-row flex-wrap gap-2">
  {FILTER_OPTIONS.map((filter) => {
    const isSelected = selectedFilter === filter.value;
    return (
      <Pressable key={filter.value}>
        <View
          className={`flex-row items-center gap-1.5 px-3 py-2 rounded-full ${
            isSelected ? "bg-accent" : "bg-gray-100 border border-gray-200"
          }`}
          onTouchEnd={() => setSelectedFilter(filter.value)}
        >
          <Ionicons
            name={filter.icon}
            size={14}
            color={isSelected ? "#fff" : "#6b7280"}
          />
          <Text
            className={`text-xs font-medium ${
              isSelected ? "text-white" : "text-gray-600"
            }`}
          >
            {filter.label}
          </Text>
        </View>
      </Pressable>
    );
  })}
</View>

Infinite Scroll Pagination

Transactions are loaded in pages with automatic pagination.
const { data, hasNextPage, fetchNextPage, isFetchingNextPage } = useInfiniteQuery({
  queryKey: ["transactions", "infinite", selectedFilter],
  queryFn: ({ pageParam = 1 }) => {
    const params: { limit: number; page: number; type?: string } = {
      limit: 10,
      page: pageParam,
    };
    if (selectedFilter !== "all") {
      params.type = selectedFilter;
    }
    return api.users.transactions(params);
  },
  getNextPageParam: (lastPage) => {
    const currentPage = lastPage.data.meta.currentPage;
    const totalPages = lastPage.data.meta.totalPages;
    return currentPage < totalPages ? currentPage + 1 : undefined;
  },
  initialPageParam: 1,
});

const transactions = data?.pages.flatMap((page) => page.data.items) || [];

Pagination Features

Loads next page when user scrolls to the bottom with onEndReached threshold.
Shows a skeleton loader at the bottom while fetching more transactions.
Supports pull-to-refresh gesture to reload transactions.
TanStack Query caches all pages for instant navigation.

Pull to Refresh

Users can manually refresh the transaction list.
const [refreshing, setRefreshing] = React.useState(false);

const onRefresh = React.useCallback(async () => {
  setRefreshing(true);
  await refetch();
  setRefreshing(false);
}, [refetch]);

<FlashList
  refreshControl={
    <RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
  }
/>

Empty State

When no transactions match the filter, a helpful empty state is shown.
function EmptyState({ selectedFilter }: { selectedFilter: string }) {
  const filterLabel =
    FILTER_OPTIONS.find((f) => f.value === selectedFilter)?.label || "All";

  return (
    <View className="flex-1 items-center justify-center px-8 bg-white mx-5 rounded-2xl my-2">
      <View className="bg-gray-100 rounded-full p-8 mb-4">
        <Ionicons name="receipt-outline" size={56} color="#d1d5db" />
      </View>
      <Text className="text-lg font-bold text-secondary text-center mb-2">
        No Transactions
      </Text>
      <Text className="text-sm text-gray-500 text-center">
        {selectedFilter === "all"
          ? "Your transactions will appear here once you start receiving payments."
          : `No ${filterLabel.toLowerCase()} transactions found.`}
      </Text>
    </View>
  );
}
The transaction list automatically refetches when the screen comes into focus using the useFocusEffect hook.

Dashboard

View wallet balance summary.

Payouts

Request withdrawal from wallet.

Shipments

Track earnings from deliveries.

Build docs developers (and LLMs) love