477 lines
16 KiB
TypeScript
477 lines
16 KiB
TypeScript
import React, { useState, useEffect } from "react";
|
|
import {
|
|
View,
|
|
ScrollView,
|
|
ActivityIndicator,
|
|
Alert,
|
|
Linking,
|
|
} from "react-native";
|
|
import { useSirouRouter } from "@sirou/react-native";
|
|
import { AppRoutes } from "@/lib/routes";
|
|
import { Stack, useLocalSearchParams } from "expo-router";
|
|
import { Text } from "@/components/ui/text";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card } from "@/components/ui/card";
|
|
import {
|
|
Wallet,
|
|
Link2,
|
|
Clock,
|
|
AlertTriangle,
|
|
User,
|
|
ShieldCheck,
|
|
Building2,
|
|
Hash,
|
|
CheckCircle2,
|
|
Eye,
|
|
Trash2,
|
|
Network,
|
|
AlertCircle,
|
|
Info,
|
|
} from "@/lib/icons";
|
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
|
import { StandardHeader } from "@/components/StandardHeader";
|
|
import { api, BASE_URL } from "@/lib/api";
|
|
import { useColorScheme } from "nativewind";
|
|
import { toast } from "@/lib/toast-store";
|
|
|
|
export default function PaymentDetailScreen() {
|
|
const nav = useSirouRouter<AppRoutes>();
|
|
const { id } = useLocalSearchParams();
|
|
const { colorScheme } = useColorScheme();
|
|
const isDark = colorScheme === "dark";
|
|
|
|
const [payment, setPayment] = useState<any>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [deleting, setDeleting] = useState(false);
|
|
const [matching, setMatching] = useState(false);
|
|
|
|
const paymentId = Array.isArray(id) ? id[0] : id;
|
|
|
|
useEffect(() => {
|
|
const fetchPayment = async () => {
|
|
try {
|
|
setLoading(true);
|
|
if (!paymentId) throw new Error("No ID provided");
|
|
|
|
console.log("[PaymentDetail] Fetching ID:", paymentId);
|
|
const response = await api.payments.getById({
|
|
params: { id: paymentId },
|
|
});
|
|
setPayment(response);
|
|
console.log("[PaymentDetail] Response:", response);
|
|
} catch (error) {
|
|
console.error("[PaymentDetail] Error fetching payment:", error);
|
|
toast.error("Error", "Failed to fetch payment details.");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
fetchPayment();
|
|
}, [paymentId]);
|
|
|
|
const handleDelete = async () => {
|
|
Alert.alert(
|
|
"Delete Payment",
|
|
"Are you sure you want to delete this payment record?",
|
|
[
|
|
{ text: "Cancel", style: "cancel" },
|
|
{
|
|
text: "Delete",
|
|
style: "destructive",
|
|
onPress: async () => {
|
|
setDeleting(true);
|
|
try {
|
|
if (!paymentId) return;
|
|
await api.payments.delete({ params: { id: paymentId } });
|
|
toast.success("Deleted", "Payment record has been removed.");
|
|
nav.back();
|
|
} catch (err: any) {
|
|
toast.error("Error", err.message || "Failed to delete payment.");
|
|
} finally {
|
|
setDeleting(false);
|
|
}
|
|
},
|
|
},
|
|
],
|
|
);
|
|
};
|
|
|
|
const handleMatch = async () => {
|
|
if (!payment || matching || !paymentId) return;
|
|
|
|
setMatching(true);
|
|
toast.info("Matching...", "Searching for a corresponding invoice.");
|
|
|
|
try {
|
|
// 1. Fetch all invoices
|
|
const invoices = await api.invoices.getAll();
|
|
const invoiceList = Array.isArray(invoices)
|
|
? invoices
|
|
: (invoices as any).data || [];
|
|
|
|
// 2. Algorithm: Match Amount AND (Sender OR Receiver Name)
|
|
const pAmount = Number(payment.amount);
|
|
const pSender = (payment.senderName || "").toLowerCase().trim();
|
|
const pReceiver = (payment.receiverName || "").toLowerCase().trim();
|
|
|
|
const match = invoiceList.find((inv: any) => {
|
|
const invAmount = Number(inv.amount);
|
|
const invCustomer = (inv.customerName || "").toLowerCase().trim();
|
|
|
|
// Exact amount match is primary
|
|
const amountMatches = Math.abs(invAmount - pAmount) < 0.01;
|
|
|
|
// Name proximity match (either sender or receiver)
|
|
const nameMatches =
|
|
(invCustomer && pSender && pSender.includes(invCustomer)) ||
|
|
(invCustomer && pSender && invCustomer.includes(pSender)) ||
|
|
(invCustomer && pReceiver && pReceiver.includes(invCustomer)) ||
|
|
(invCustomer && pReceiver && invCustomer.includes(pReceiver));
|
|
|
|
return amountMatches && nameMatches;
|
|
});
|
|
|
|
if (!match) {
|
|
toast.info(
|
|
"No Match Found",
|
|
"Could not find an invoice with the same amount and customer name.",
|
|
);
|
|
return;
|
|
}
|
|
|
|
// 3. Confirm match with user
|
|
Alert.alert(
|
|
"Match Found!",
|
|
`Associate this payment with Invoice #${match.invoiceNumber} for ${match.customerName}?`,
|
|
[
|
|
{ text: "Cancel", style: "cancel" },
|
|
{
|
|
text: "Associate",
|
|
style: "default",
|
|
onPress: async () => {
|
|
try {
|
|
await api.payments.associate({
|
|
params: { id: paymentId },
|
|
body: { invoiceId: match.id },
|
|
});
|
|
toast.success(
|
|
"Success",
|
|
"Payment successfully associated with invoice.",
|
|
);
|
|
// Refresh data
|
|
const updated = await api.payments.getById({
|
|
params: { id: paymentId },
|
|
});
|
|
setPayment(updated);
|
|
} catch (err: any) {
|
|
toast.error("Error", err.message || "Failed to associate.");
|
|
}
|
|
},
|
|
},
|
|
],
|
|
);
|
|
} catch (err: any) {
|
|
console.error("[Match] Error:", err);
|
|
toast.error("Error", "Failed to fetch invoices for matching.");
|
|
} finally {
|
|
setMatching(false);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<ScreenWrapper className="bg-background">
|
|
<Stack.Screen options={{ headerShown: false }} />
|
|
<StandardHeader title="Payment Details" showBack />
|
|
<View className="flex-1 justify-center items-center">
|
|
<ActivityIndicator color="#ea580c" size="large" />
|
|
<Text
|
|
variant="muted"
|
|
className="mt-4 font-bold uppercase tracking-widest text-[10px]"
|
|
>
|
|
Retrieving Transaction...
|
|
</Text>
|
|
</View>
|
|
</ScreenWrapper>
|
|
);
|
|
}
|
|
|
|
if (!payment) {
|
|
return (
|
|
<ScreenWrapper className="bg-background">
|
|
<Stack.Screen options={{ headerShown: false }} />
|
|
<StandardHeader title="Payment Details" showBack />
|
|
<View className="flex-1 justify-center items-center p-6">
|
|
<AlertTriangle color="#ef4444" size={48} strokeWidth={1.5} />
|
|
<Text variant="h4" className="mt-4 text-foreground font-black">
|
|
Transaction Not Found
|
|
</Text>
|
|
<Text variant="muted" className="mt-2 text-center">
|
|
The requested payment record could not be retrieved from the server.
|
|
</Text>
|
|
</View>
|
|
</ScreenWrapper>
|
|
);
|
|
}
|
|
|
|
const amountValue = Number(
|
|
typeof payment.amount === "object" ? payment.amount.value : payment.amount,
|
|
);
|
|
|
|
const paymentDate = payment.paymentDate
|
|
? new Date(payment.paymentDate)
|
|
: new Date(payment.createdAt);
|
|
|
|
const isFlagged = payment.isFlagged === true;
|
|
const isScanned = payment.isScanned === true;
|
|
const scanned = payment.scannedData || {};
|
|
const extracted = scanned.extractedFields || {};
|
|
const verification = payment.verification || scanned.verification || {};
|
|
const isFailed =
|
|
verification.verificationStatus === "failed" ||
|
|
verification.isVerified === false;
|
|
|
|
return (
|
|
<ScreenWrapper className="bg-background">
|
|
<Stack.Screen options={{ headerShown: false }} />
|
|
<StandardHeader title={"Payment Details"} showBack />
|
|
|
|
<ScrollView
|
|
className="flex-1"
|
|
contentContainerStyle={{ paddingBottom: 10 }}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
{/* Urgent Alerts */}
|
|
{isFlagged && (
|
|
<View className="mx-5 my-4 bg-red-500/10 border border-red-500/20 rounded-[24px] p-5 flex-row items-start">
|
|
<View className="bg-red-500/20 p-2 rounded-full mr-4">
|
|
<AlertTriangle color="#ef4444" size={20} />
|
|
</View>
|
|
<View className="flex-1">
|
|
<Text className="text-red-600 font-black text-[10px] uppercase tracking-[2px] mb-1">
|
|
Security Flag ({payment.flagReason || "Audit Needed"})
|
|
</Text>
|
|
<Text className="text-foreground/80 font-medium text-xs leading-5">
|
|
{payment.flagNotes || "System flagged this for manual review."}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
{/* Hero Section */}
|
|
<View className="px-5 pt-2">
|
|
{/* Status Badges */}
|
|
<View className="flex-row flex-wrap gap-2 mb-6">
|
|
<View
|
|
className={`px-3 py-1 rounded-full flex-row items-center gap-2 ${payment.invoiceId ? "bg-emerald-500/10" : "bg-amber-500/10"}`}
|
|
>
|
|
<View
|
|
className={`w-2 h-2 rounded-full ${payment.invoiceId ? "bg-emerald-500" : "bg-amber-500"}`}
|
|
/>
|
|
<Text
|
|
className={`text-[10px] font-black uppercase tracking-widest ${payment.invoiceId ? "text-emerald-600" : "text-amber-600"}`}
|
|
>
|
|
{payment.invoiceId ? "Matched" : "Pending Match"}
|
|
</Text>
|
|
</View>
|
|
|
|
{isFailed && (
|
|
<View className="bg-red-500/10 px-3 py-1 rounded-full flex-row items-center gap-2">
|
|
<AlertCircle size={12} color="#ef4444" />
|
|
<Text className="text-red-600 text-[10px] font-black uppercase tracking-widest">
|
|
Verify Failed
|
|
</Text>
|
|
</View>
|
|
)}
|
|
|
|
{isScanned && (
|
|
<View className="bg-primary/10 px-3 py-1 rounded-full flex-row items-center gap-2 border border-primary/20">
|
|
<CheckCircle2 size={12} color="#ea580c" />
|
|
<Text className="text-primary text-[10px] font-black uppercase tracking-widest">
|
|
Scanned
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
<Text
|
|
variant="muted"
|
|
className="text-[10px] font-black uppercase tracking-[3px] mb-2 opacity-60"
|
|
>
|
|
Total Transaction Amount
|
|
</Text>
|
|
<View className="flex-row items-end gap-3 mb-8">
|
|
<Text
|
|
variant="h1"
|
|
className="text-4xl font-black text-foreground tracking-tighter"
|
|
>
|
|
{amountValue.toLocaleString()}
|
|
</Text>
|
|
<Text className="text-2xl font-black text-primary mb-1">
|
|
{payment.currency || "USD"}
|
|
</Text>
|
|
</View>
|
|
|
|
{/* Core Info Grid */}
|
|
<View className="flex-row gap-3 mb-6">
|
|
<Card className="flex-1 rounded-[6px] p-5 border border-border/60 bg-card">
|
|
<Building2 size={18} color="#ea580c" className="mb-3" />
|
|
<Text
|
|
variant="muted"
|
|
className="text-[9px] uppercase font-black tracking-widest mb-1 opacity-50"
|
|
>
|
|
Merchant
|
|
</Text>
|
|
<Text
|
|
className="text-foreground font-black text-sm"
|
|
numberOfLines={1}
|
|
>
|
|
{extracted.merchantName ||
|
|
payment.merchantName ||
|
|
"Unknown Merchant"}
|
|
</Text>
|
|
</Card>
|
|
<Card className="flex-1 rounded-[6px] p-5 border border-border/60 bg-card">
|
|
<Network size={18} color="#ea580c" className="mb-3" />
|
|
<Text
|
|
variant="muted"
|
|
className="text-[9px] uppercase font-black tracking-widest mb-1 opacity-50"
|
|
>
|
|
Provider
|
|
</Text>
|
|
<Text
|
|
className="text-foreground font-black text-sm"
|
|
numberOfLines={1}
|
|
>
|
|
{extracted.provider ||
|
|
payment.paymentMethod ||
|
|
"Direct Payment"}
|
|
</Text>
|
|
</Card>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Sender / Payer Box */}
|
|
<View className="px-5 mb-8">
|
|
<View className="bg-card/50 rounded-[6px] p-6 border border-border/40 shadow-sm shadow-black/5">
|
|
<View className="flex-row items-center gap-4 mb-5">
|
|
<View className="h-12 w-12 rounded-full bg-secondary/10 items-center justify-center border border-secondary/20">
|
|
<User color={isDark ? "#f1f5f9" : "#0f172a"} size={22} />
|
|
</View>
|
|
<View>
|
|
<Text
|
|
variant="muted"
|
|
className="text-[9px] uppercase font-black tracking-[2px] mb-0.5"
|
|
>
|
|
Transaction Origin
|
|
</Text>
|
|
<Text className="text-foreground font-black text-lg">
|
|
{payment.senderName ||
|
|
(payment.user
|
|
? `${payment.user.firstName} ${payment.user.lastName}`
|
|
: "Business Account")}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
<View className="flex-row flex-wrap gap-5 pt-5 border-t border-border/40">
|
|
<View className="flex-row items-center gap-2">
|
|
<Hash size={14} color="#64748b" />
|
|
<Text className="text-muted-foreground font-bold text-xs tracking-tighter">
|
|
{payment.transactionId || "INTERNAL-TXN"}
|
|
</Text>
|
|
</View>
|
|
<View className="flex-row items-center gap-2">
|
|
<Clock size={14} color="#64748b" />
|
|
<Text className="text-muted-foreground font-bold text-xs tracking-tighter">
|
|
{paymentDate.toLocaleString()}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Notes */}
|
|
{payment.notes && (
|
|
<View className="px-5 mb-10">
|
|
<View className="flex-row items-center gap-2 mb-3">
|
|
<Info size={14} color="#64748b" />
|
|
<Text
|
|
variant="muted"
|
|
className="text-[10px] uppercase font-black tracking-widest"
|
|
>
|
|
Transaction Notes
|
|
</Text>
|
|
</View>
|
|
<View className="bg-muted/5 border border-border/40 rounded-[6px] p-5">
|
|
<Text className="text-foreground font-medium italic opacity-70 leading-6">
|
|
" {payment.notes} "
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
{/* Premium Actions */}
|
|
<View className="px-5 gap-3">
|
|
<View className="flex-row gap-3">
|
|
{scanned?.imageUrl && (
|
|
<Button
|
|
className="flex-1 h-14 rounded-[6px] bg-secondary shadow-lg shadow-black/10"
|
|
onPress={() =>
|
|
Linking.openURL(
|
|
`${BASE_URL}${scanned.imageUrl.startsWith("/") ? scanned.imageUrl.substring(1) : scanned.imageUrl}`,
|
|
)
|
|
}
|
|
>
|
|
<Eye
|
|
color={isDark ? "#f1f5f9" : "#0f172a"}
|
|
size={18}
|
|
strokeWidth={2.5}
|
|
/>
|
|
<Text className="ml-2 text-foreground font-black uppercase tracking-widest text-xs">
|
|
View Receipt
|
|
</Text>
|
|
</Button>
|
|
)}
|
|
{!payment.invoiceId && !isFailed && (
|
|
<Button
|
|
className="flex-1 h-14 rounded-[6px] bg-primary shadow-lg shadow-primary/20"
|
|
onPress={handleMatch}
|
|
disabled={matching}
|
|
>
|
|
{matching ? (
|
|
<ActivityIndicator color="white" />
|
|
) : (
|
|
<>
|
|
<Link2 size={18} color="white" strokeWidth={2.5} />
|
|
<Text className="ml-2 text-white font-black uppercase tracking-widest text-xs">
|
|
Match Invoice
|
|
</Text>
|
|
</>
|
|
)}
|
|
</Button>
|
|
)}
|
|
</View>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
className="h-14 rounded-[6px] border border-red-500/5 mb-10"
|
|
onPress={handleDelete}
|
|
disabled={deleting}
|
|
>
|
|
{deleting ? (
|
|
<ActivityIndicator color="#ef4444" />
|
|
) : (
|
|
<>
|
|
<Trash2 color="#ef4444" size={18} />
|
|
<Text className="ml-2 text-red-500 font-bold uppercase tracking-widest text-xs">
|
|
Terminate Record
|
|
</Text>
|
|
</>
|
|
)}
|
|
</Button>
|
|
</View>
|
|
</ScrollView>
|
|
</ScreenWrapper>
|
|
);
|
|
}
|