Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions src/components/SettleUpModal.tsx
Original file line number Diff line number Diff line change
@@ -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<Props> = ({
isVisible,
onClose,
groupId,
userId,
otherUserId,
defaultAmount,
expenseList,
}) => {
const [amount, setAmount] = useState(String(defaultAmount));
const [selected, setSelected] = useState<string[]>([]);
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 (
<Modal visible={isVisible} transparent animationType="slide">
<View style={styles.overlay}>
<View style={styles.container}>
<Text style={styles.title}>Settle Up</Text>
<TextInput
style={styles.input}
value={amount}
onChangeText={setAmount}
keyboardType="decimal-pad"
/>
<ScrollView style={styles.list}>
{expenseList.map((exp) => (
<TouchableOpacity
key={exp.id}
style={styles.item}
onPress={() => toggle(exp.id)}
>
<Text style={styles.checkbox}>
{selected.includes(exp.id) ? '☑' : '☐'}
</Text>
<Text style={styles.itemText}>
{exp.description} - ${exp.amountOwed.toFixed(2)}
</Text>
</TouchableOpacity>
))}
</ScrollView>
{error && <Text style={styles.error}>{error}</Text>}
<View style={styles.actions}>
<TouchableOpacity style={styles.button} onPress={onClose}>
<Text>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.primary]}
onPress={handleConfirm}
disabled={loading}
>
<Text>{loading ? 'Saving...' : 'Confirm'}</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
};

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 },
});
107 changes: 107 additions & 0 deletions src/hooks/__tests__/useAddSettlement.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
48 changes: 48 additions & 0 deletions src/hooks/useAddSettlement.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);

const addSettlement = async (
data: AddSettlementData
): Promise<Settlement | null> => {
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 };
};
42 changes: 42 additions & 0 deletions src/hooks/useSettlements.ts
Original file line number Diff line number Diff line change
@@ -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<Settlement[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 };
};
23 changes: 23 additions & 0 deletions src/lib/supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ export type Database = {
amount: number;
settled_at: string;
note: string | null;
expense_id: string | null;
};
Insert: {
id?: string;
Expand All @@ -139,6 +140,7 @@ export type Database = {
amount: number;
settled_at?: string;
note?: string | null;
expense_id?: string | null;
};
Update: {
id?: string;
Expand All @@ -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;
};
};
};
Expand Down
Loading