From 081184682eb194f835b21d3cf2b1c4bb601c1a5b Mon Sep 17 00:00:00 2001 From: Rikhil Fellner Date: Thu, 24 Jul 2025 23:09:03 -0400 Subject: [PATCH] feat: support settlements per expense --- src/components/SettleUpModal.tsx | 151 +++++++++++++++++++ src/hooks/__tests__/useAddSettlement.test.ts | 107 +++++++++++++ src/hooks/useAddSettlement.ts | 48 ++++++ src/hooks/useSettlements.ts | 42 ++++++ src/lib/supabase.ts | 23 +++ src/screens/ExpenseDetailScreen.tsx | 82 ++++++++-- src/screens/GroupDetailScreen.tsx | 30 +++- src/types/db.ts | 7 + 8 files changed, 477 insertions(+), 13 deletions(-) create mode 100644 src/components/SettleUpModal.tsx create mode 100644 src/hooks/__tests__/useAddSettlement.test.ts create mode 100644 src/hooks/useAddSettlement.ts create mode 100644 src/hooks/useSettlements.ts diff --git a/src/components/SettleUpModal.tsx b/src/components/SettleUpModal.tsx new file mode 100644 index 0000000..9784ce9 --- /dev/null +++ b/src/components/SettleUpModal.tsx @@ -0,0 +1,151 @@ +import React, { useState } from 'react'; +import { + Modal, + View, + Text, + TextInput, + TouchableOpacity, + ScrollView, + StyleSheet, +} from 'react-native'; +import { useAddSettlement } from '../hooks/useAddSettlement'; + +export interface ExpenseSelection { + id: string; + description: string; + amountOwed: number; +} + +interface Props { + isVisible: boolean; + onClose: () => void; + groupId: string; + userId: string; + otherUserId: string; + defaultAmount: number; + expenseList: ExpenseSelection[]; +} + +export const SettleUpModal: React.FC = ({ + isVisible, + onClose, + groupId, + userId, + otherUserId, + defaultAmount, + expenseList, +}) => { + const [amount, setAmount] = useState(String(defaultAmount)); + const [selected, setSelected] = useState([]); + const { addSettlement, loading, error } = useAddSettlement(); + + const toggle = (id: string) => { + setSelected((prev) => + prev.includes(id) ? prev.filter((e) => e !== id) : [...prev, id] + ); + }; + + const handleConfirm = async () => { + const amt = parseFloat(amount) || 0; + if (selected.length > 0) { + for (const id of selected) { + await addSettlement({ + group_id: groupId, + paid_by: userId, + paid_to: otherUserId, + amount: amt, + expense_id: id, + }); + } + } else { + await addSettlement({ + group_id: groupId, + paid_by: userId, + paid_to: otherUserId, + amount: amt, + expense_id: null, + }); + } + onClose(); + }; + + return ( + + + + Settle Up + + + {expenseList.map((exp) => ( + toggle(exp.id)} + > + + {selected.includes(exp.id) ? '☑' : '☐'} + + + {exp.description} - ${exp.amountOwed.toFixed(2)} + + + ))} + + {error && {error}} + + + Cancel + + + {loading ? 'Saving...' : 'Confirm'} + + + + + + ); +}; + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.3)', + justifyContent: 'center', + alignItems: 'center', + }, + container: { + width: '90%', + backgroundColor: '#fff', + borderRadius: 8, + padding: 16, + }, + title: { + fontSize: 18, + fontWeight: '600', + marginBottom: 12, + }, + input: { + borderWidth: 1, + borderColor: '#ccc', + borderRadius: 6, + padding: 8, + marginBottom: 12, + }, + list: { maxHeight: 200 }, + item: { flexDirection: 'row', alignItems: 'center', paddingVertical: 4 }, + checkbox: { marginRight: 8 }, + itemText: { flex: 1 }, + error: { color: 'red', marginTop: 8 }, + actions: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 16 }, + button: { marginLeft: 8, padding: 8 }, + primary: { backgroundColor: '#007AFF', borderRadius: 4 }, +}); diff --git a/src/hooks/__tests__/useAddSettlement.test.ts b/src/hooks/__tests__/useAddSettlement.test.ts new file mode 100644 index 0000000..1f040ed --- /dev/null +++ b/src/hooks/__tests__/useAddSettlement.test.ts @@ -0,0 +1,107 @@ +import { renderHook, act } from '@testing-library/react'; +import { useAddSettlement } from '../useAddSettlement'; +import { useSettlements } from '../useSettlements'; + +jest.mock('../../lib/supabase', () => ({ + supabase: { + from: jest.fn(), + auth: { getUser: jest.fn() }, + }, +})); + +import { supabase } from '../../lib/supabase'; + +describe('useAddSettlement', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('adds settlement with expense id and fetches it', async () => { + const insert = jest.fn(() => ({ + select: () => ({ + single: jest.fn().mockResolvedValue({ + data: { id: 's1', group_id: 'g1', expense_id: 'e1' }, + error: null, + }), + }), + })); + const order = jest.fn().mockResolvedValue({ + data: [{ id: 's1', group_id: 'g1', expense_id: 'e1' }], + error: null, + }); + const eqExpense = jest.fn(() => ({ order })); + const eqGroup = jest.fn(() => ({ eq: eqExpense })); + const select = jest.fn(() => ({ eq: eqGroup })); + + (supabase.from as jest.Mock).mockImplementation((table: string) => { + if (table === 'settlements') { + return { insert, select } as any; + } + return {} as any; + }); + + const { result: addHook } = renderHook(() => useAddSettlement()); + + await act(async () => { + await addHook.current.addSettlement({ + group_id: 'g1', + paid_by: 'u1', + paid_to: 'u2', + amount: 5, + expense_id: 'e1', + }); + }); + + expect(insert).toHaveBeenCalledWith({ + group_id: 'g1', + paid_by: 'u1', + paid_to: 'u2', + amount: 5, + expense_id: 'e1', + }); + + const { result } = renderHook(() => useSettlements('g1', 'e1')); + + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + expect(result.current.settlements).toEqual([ + { id: 's1', group_id: 'g1', expense_id: 'e1' }, + ]); + expect(eqGroup).toHaveBeenCalledWith('group_id', 'g1'); + expect(eqExpense).toHaveBeenCalledWith('expense_id', 'e1'); + }); + + it('adds settlement without expense id', async () => { + const insert = jest.fn(() => ({ + select: () => ({ + single: jest.fn().mockResolvedValue({ + data: { id: 's2', group_id: 'g1', expense_id: null }, + error: null, + }), + }), + })); + + (supabase.from as jest.Mock).mockReturnValue({ insert } as any); + + const { result: addHook } = renderHook(() => useAddSettlement()); + + await act(async () => { + await addHook.current.addSettlement({ + group_id: 'g1', + paid_by: 'u1', + paid_to: 'u2', + amount: 10, + }); + }); + + expect(insert).toHaveBeenCalledWith({ + group_id: 'g1', + paid_by: 'u1', + paid_to: 'u2', + amount: 10, + expense_id: null, + }); + }); +}); diff --git a/src/hooks/useAddSettlement.ts b/src/hooks/useAddSettlement.ts new file mode 100644 index 0000000..f0962e0 --- /dev/null +++ b/src/hooks/useAddSettlement.ts @@ -0,0 +1,48 @@ +import { useState } from 'react'; +import { supabase } from '../lib/supabase'; +import { Settlement } from '../types/db'; + +export interface AddSettlementData { + group_id: string; + paid_by: string | null; + paid_to: string | null; + amount: number; + expense_id?: string | null; +} + +export const useAddSettlement = () => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const addSettlement = async ( + data: AddSettlementData + ): Promise => { + try { + setLoading(true); + setError(null); + const { data: settlement, error: insertError } = await supabase + .from('settlements') + .insert({ + group_id: data.group_id, + paid_by: data.paid_by, + paid_to: data.paid_to, + amount: data.amount, + expense_id: data.expense_id ?? null, + }) + .select() + .single(); + if (insertError) { + setError(insertError.message); + return null; + } + return settlement; + } catch (err) { + setError(err instanceof Error ? err.message : 'An error occurred'); + return null; + } finally { + setLoading(false); + } + }; + + return { addSettlement, loading, error }; +}; diff --git a/src/hooks/useSettlements.ts b/src/hooks/useSettlements.ts new file mode 100644 index 0000000..e196d2b --- /dev/null +++ b/src/hooks/useSettlements.ts @@ -0,0 +1,42 @@ +import { useState, useEffect } from 'react'; +import { supabase } from '../lib/supabase'; +import { Settlement } from '../types/db'; + +export const useSettlements = (groupId: string, expenseId?: string) => { + const [settlements, setSettlements] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchSettlements = async () => { + if (!groupId) return; + try { + setLoading(true); + setError(null); + let query = supabase + .from('settlements') + .select('*') + .eq('group_id', groupId); + if (expenseId) { + query = query.eq('expense_id', expenseId); + } + const { data, error: fetchError } = await query.order('settled_at', { + ascending: false, + }); + if (fetchError) { + setError(fetchError.message); + return; + } + setSettlements(data || []); + } catch (err) { + setError(err instanceof Error ? err.message : 'An error occurred'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchSettlements(); + }, [groupId, expenseId]); + + return { settlements, loading, error, refetch: fetchSettlements }; +}; diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index 055a63b..0957614 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -130,6 +130,7 @@ export type Database = { amount: number; settled_at: string; note: string | null; + expense_id: string | null; }; Insert: { id?: string; @@ -139,6 +140,7 @@ export type Database = { amount: number; settled_at?: string; note?: string | null; + expense_id?: string | null; }; Update: { id?: string; @@ -148,6 +150,27 @@ export type Database = { amount?: number; settled_at?: string; note?: string | null; + expense_id?: string | null; + }; + }; + settlement_items: { + Row: { + id: string; + settlement_id: string; + expense_id: string; + amount: number; + }; + Insert: { + id?: string; + settlement_id: string; + expense_id: string; + amount: number; + }; + Update: { + id?: string; + settlement_id?: string; + expense_id?: string; + amount?: number; }; }; }; diff --git a/src/screens/ExpenseDetailScreen.tsx b/src/screens/ExpenseDetailScreen.tsx index 0091e98..f6d43b3 100644 --- a/src/screens/ExpenseDetailScreen.tsx +++ b/src/screens/ExpenseDetailScreen.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { View, Text, @@ -8,8 +8,10 @@ import { Alert, ActivityIndicator, } from 'react-native'; -import { Expense, Group } from '../types/db'; +import { Expense, Group, ExpenseSplit } from '../types/db'; import { useDeleteExpense } from '../hooks/useDeleteExpense'; +import { useSettlements } from '../hooks/useSettlements'; +import { supabase } from '../lib/supabase'; interface Props { navigation: any; @@ -25,6 +27,38 @@ export const ExpenseDetailScreen: React.FC = ({ navigation, route }) => { const { expense, group } = route.params; const { deleteExpense, loading: deleting, error: deleteError } = useDeleteExpense(); + const [splits, setSplits] = useState<(ExpenseSplit & { user: { id: string; email: string } })[]>([]); + const [splitsLoading, setSplitsLoading] = useState(true); + const [splitsError, setSplitsError] = useState(null); + + const { settlements, loading: settlementsLoading } = useSettlements( + group.id, + expense.id + ); + + useEffect(() => { + const fetchSplits = async () => { + try { + setSplitsLoading(true); + setSplitsError(null); + const { data, error } = await supabase + .from('expense_splits') + .select('*, user: user_id (id, email)') + .eq('expense_id', expense.id); + if (error) { + setSplitsError(error.message); + return; + } + setSplits((data as any) || []); + } catch (err) { + setSplitsError(err instanceof Error ? err.message : 'An error occurred'); + } finally { + setSplitsLoading(false); + } + }; + fetchSplits(); + }, [expense.id]); + const handleEditExpense = () => { // TODO: Implement edit expense functionality Alert.alert('Coming Soon', 'Edit expense functionality will be available soon!'); @@ -82,11 +116,26 @@ export const ExpenseDetailScreen: React.FC = ({ navigation, route }) => { Split Breakdown - - - Split details will be available when expense splits are implemented. - - + {splitsLoading ? ( + + ) : splitsError ? ( + {splitsError} + ) : ( + + {splits.map((s) => { + const paid = settlements.some( + (set) => set.paid_by === s.user_id && set.expense_id === expense.id + ); + return ( + + {s.user.email} + ${s.amount.toFixed(2)} + {paid && Paid} + + ); + })} + + )} @@ -110,9 +159,17 @@ export const ExpenseDetailScreen: React.FC = ({ navigation, route }) => { Settlements - - Settlement tracking will be available when settlements are implemented. - + {settlementsLoading ? ( + + ) : settlements.length === 0 ? ( + No repayments yet. + ) : ( + settlements.map((s) => ( + + {s.paid_by} paid {s.paid_to} ${s.amount.toFixed(2)} + + )) + )} @@ -222,6 +279,11 @@ const styles = StyleSheet.create({ fontSize: 16, fontWeight: '600', }, + paidLabel: { + fontSize: 12, + color: '#28a745', + marginLeft: 8, + }, settlementsContainer: { padding: 16, backgroundColor: '#f8f9fa', diff --git a/src/screens/GroupDetailScreen.tsx b/src/screens/GroupDetailScreen.tsx index e83d1c1..f13531a 100644 --- a/src/screens/GroupDetailScreen.tsx +++ b/src/screens/GroupDetailScreen.tsx @@ -12,6 +12,8 @@ import { import { useFocusEffect } from '@react-navigation/native'; import { useExpenses } from '../hooks/useExpenses'; import { Group, Expense } from '../types/db'; +import { supabase } from '../lib/supabase'; +import { SettleUpModal } from '../components/SettleUpModal'; interface Props { navigation: any; @@ -28,6 +30,12 @@ export const GroupDetailScreen: React.FC = ({ navigation, route }) => { const [activeTab, setActiveTab] = useState<'expenses' | 'members'>('expenses'); const [refreshing, setRefreshing] = useState(false); const hasRefetchedRef = useRef(false); + const [showSettle, setShowSettle] = useState(false); + const [userId, setUserId] = useState(null); + + useEffect(() => { + supabase.auth.getUser().then(({ data }) => setUserId(data.user?.id ?? null)); + }, []); // Auto-refresh when screen comes into focus (e.g., returning from AddExpense) useFocusEffect( @@ -218,9 +226,14 @@ export const GroupDetailScreen: React.FC = ({ navigation, route }) => { <> Expenses - - + Add Expense - + + + + Add Expense + + setShowSettle(true)}> + Settle Up + + {renderExpensesContent()} @@ -229,6 +242,17 @@ export const GroupDetailScreen: React.FC = ({ navigation, route }) => { )} + {userId && ( + setShowSettle(false)} + groupId={group.id} + userId={userId} + otherUserId={''} + defaultAmount={0} + expenseList={[]} + /> + )} ); }; diff --git a/src/types/db.ts b/src/types/db.ts index e8b7e5e..04502f7 100644 --- a/src/types/db.ts +++ b/src/types/db.ts @@ -20,6 +20,13 @@ export type Settlement = Database['public']['Tables']['settlements']['Row']; export type SettlementInsert = Database['public']['Tables']['settlements']['Insert']; export type SettlementUpdate = Database['public']['Tables']['settlements']['Update']; +export interface SettlementItem { + id: string; + settlement_id: string; + expense_id: string; + amount: number; +} + export type User = { id: string; email: string;