From 0f82642f7dfde59bda3498abee89ad889b5a6c30 Mon Sep 17 00:00:00 2001 From: Mufaddal5253110 Date: Sun, 24 May 2026 22:08:21 +0530 Subject: [PATCH] feat(import): add Splitwise API import for full expense history --- public/locales/en/common.json | 1 + src/lib/splitwise-api.ts | 304 +++++++++ src/lib/splitwise-converter.ts | 208 +++++++ src/pages/account.tsx | 5 + src/pages/import-splitwise-api.tsx | 356 +++++++++++ src/server/api/root.ts | 4 +- src/server/api/routers/splitwiseApiImport.ts | 254 ++++++++ src/tests/splitwise-converter.test.ts | 616 +++++++++++++++++++ 8 files changed, 1747 insertions(+), 1 deletion(-) create mode 100644 src/lib/splitwise-api.ts create mode 100644 src/lib/splitwise-converter.ts create mode 100644 src/pages/import-splitwise-api.tsx create mode 100644 src/server/api/routers/splitwiseApiImport.ts create mode 100644 src/tests/splitwise-converter.test.ts diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 590275bc..69a905fb 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -31,6 +31,7 @@ }, "follow_on_x": "Follow us on X", "import_from_splitwise": "Import from Splitwise", + "import_from_splitwise_api": "Import from Splitwise (API)", "import_from_splitwise_details": { "choose_file": "Choose file", "export_splitwise_data_button": "Export splitwise data", diff --git a/src/lib/splitwise-api.ts b/src/lib/splitwise-api.ts new file mode 100644 index 00000000..cdb280ac --- /dev/null +++ b/src/lib/splitwise-api.ts @@ -0,0 +1,304 @@ +/** + * Splitwise API Service + * + * Provides methods to fetch data from Splitwise API for importing + * expenses into SplitPro with complete accuracy. + */ + +const API_BASE = 'https://secure.splitwise.com/api/v3.0'; + +// ============= TYPES ============= + +export interface SplitwiseUserShare { + user_id: number; + user: { + id: number; + first_name: string; + last_name: string | null; + email: string; + picture?: { + medium?: string; + }; + }; + paid_share: string; + owed_share: string; + net_balance: string; +} + +export interface SplitwiseExpense { + id: number; + cost: string; + description: string; + details: string | null; + date: string; + currency_code: string; + category: { + id: number; + name: string; + }; + group_id: number | null; + friendship_id: number | null; + expense_bundle_id: number | null; + repeats: boolean; + repeat_interval: string | null; + email_reminder: boolean; + email_reminder_in_advance: number | null; + next_repeat: string | null; + comments_count: number; + payment: boolean; + transaction_confirmed: boolean; + created_at: string; + created_by: { + id: number; + first_name: string; + last_name: string | null; + }; + updated_at: string; + updated_by: { + id: number; + first_name: string; + last_name: string | null; + } | null; + deleted_at: string | null; + deleted_by: { + id: number; + first_name: string; + last_name: string | null; + } | null; + users: SplitwiseUserShare[]; + repayments: { + from: number; + to: number; + amount: string; + }[]; +} + +export interface SplitwiseGroupMember { + id: number; + first_name: string; + last_name: string | null; + email: string; + registration_status: string; + picture?: { + medium?: string; + }; + balance: { + currency_code: string; + amount: string; + }[]; +} + +export interface SplitwiseGroup { + id: number; + name: string; + group_type: string; + updated_at: string; + created_at: string; + simplify_by_default: boolean; + members: SplitwiseGroupMember[]; + original_debts: { + from: number; + to: number; + amount: string; + currency_code: string; + }[]; + simplified_debts: { + from: number; + to: number; + amount: string; + currency_code: string; + }[]; +} + +export interface SplitwiseFriend { + id: number; + first_name: string; + last_name: string | null; + email: string; + registration_status: string; + picture?: { + medium?: string; + }; + balance: { + currency_code: string; + amount: string; + }[]; + groups: { + group_id: number; + balance: Array<{ + currency_code: string; + amount: string; + }>; + }[]; +} + +export interface SplitwiseCurrentUser { + id: number; + first_name: string; + last_name: string | null; + email: string; + picture?: { + medium?: string; + }; + default_currency: string; +} + +// ============= API CLIENT ============= + +export class SplitwiseApiError extends Error { + constructor( + message: string, + public status: number, + public statusText: string, + ) { + super(message); + this.name = 'SplitwiseApiError'; + } +} + +async function fetchFromSplitwise( + endpoint: string, + apiKey: string, + params?: Record, +): Promise { + const url = new URL(`${API_BASE}${endpoint}`); + + if (params) { + Object.entries(params).forEach(([key, value]) => { + url.searchParams.append(key, String(value)); + }); + } + + const response = await fetch(url.toString(), { + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new SplitwiseApiError( + `Splitwise API request failed: ${response.status} ${response.statusText}`, + response.status, + response.statusText, + ); + } + + return response.json() as Promise; +} + +// ============= API METHODS ============= + +/** + * Get current user info to validate API key + */ +export async function getCurrentUser(apiKey: string): Promise { + const response = await fetchFromSplitwise<{ + user: SplitwiseCurrentUser; + }>('/get_current_user', apiKey); + return response.user; +} + +/** + * Get all groups for the current user + */ +export async function getGroups(apiKey: string): Promise { + const response = await fetchFromSplitwise<{ groups: SplitwiseGroup[] }>('/get_groups', apiKey); + // Filter out the "Non-group expenses" (id: 0) + return response.groups.filter((g) => g.id !== 0); +} + +/** + * Get all friends for the current user + */ +export async function getFriends(apiKey: string): Promise { + const response = await fetchFromSplitwise<{ friends: SplitwiseFriend[] }>('/get_friends', apiKey); + return response.friends; +} + +/** + * Get expenses for a specific group with pagination + */ +export async function getGroupExpenses( + apiKey: string, + groupId: number, + options: { + limit?: number; + offset?: number; + datedAfter?: string; + datedBefore?: string; + } = {}, +): Promise { + const params: Record = { + group_id: groupId, + limit: options.limit ?? 100, + offset: options.offset ?? 0, + }; + + if (options.datedAfter) { + params.dated_after = options.datedAfter; + } + if (options.datedBefore) { + params.dated_before = options.datedBefore; + } + + const response = await fetchFromSplitwise<{ expenses: SplitwiseExpense[] }>( + '/get_expenses', + apiKey, + params, + ); + + return response.expenses; +} + +/** + * Get ALL expenses for a group (handles pagination automatically) + */ +export async function getAllGroupExpenses( + apiKey: string, + groupId: number, + onProgress?: (fetched: number, total: number | null) => void, +): Promise { + const allExpenses: SplitwiseExpense[] = []; + const limit = 100; + let offset = 0; + let hasMore = true; + + while (hasMore) { + const expenses = await getGroupExpenses(apiKey, groupId, { limit, offset }); + + // Filter out deleted expenses + const activeExpenses = expenses.filter((e) => !e.deleted_at); + allExpenses.push(...activeExpenses); + + onProgress?.(allExpenses.length, null); + + if (expenses.length < limit) { + hasMore = false; + } else { + offset += limit; + } + } + + return allExpenses; +} + +/** + * Validate API key by trying to fetch current user + */ +export async function validateApiKey( + apiKey: string, +): Promise<{ valid: boolean; user?: SplitwiseCurrentUser; error?: string }> { + try { + const user = await getCurrentUser(apiKey); + return { valid: true, user }; + } catch (error) { + if (error instanceof SplitwiseApiError) { + if (error.status === 401) { + return { valid: false, error: 'Invalid API key' }; + } + return { valid: false, error: error.message }; + } + return { valid: false, error: 'Failed to validate API key' }; + } +} diff --git a/src/lib/splitwise-converter.ts b/src/lib/splitwise-converter.ts new file mode 100644 index 00000000..7d964bad --- /dev/null +++ b/src/lib/splitwise-converter.ts @@ -0,0 +1,208 @@ +/** + * Splitwise to SplitPro Conversion Utilities + * + * Converts Splitwise expense data to SplitPro format. + * Extracted for testability. + */ + +import { SplitType } from '@prisma/client'; +import { type SplitwiseExpense } from './splitwise-api'; + +export interface ConvertedExpense { + name: string; + category: string; + amount: bigint; + currency: string; + splitType: SplitType; + expenseDate: Date; + createdAt: Date; + paidBy: number; + groupId: number; + participants: { userId: number; amount: bigint }[]; + transactionId: string; +} + +/** + * Convert Splitwise expense to SplitPro expense data + * Uses Splitwise user ID to map to our DB user ID + * + * @param expense - The Splitwise expense to convert + * @param splitwiseIdToDbId - Map from Splitwise user ID to SplitPro DB user ID + * @param groupId - The SplitPro group ID + * @returns Converted expense data or null if conversion fails + */ +export function convertExpenseToSplitPro( + expense: SplitwiseExpense, + splitwiseIdToDbId: Map, + groupId: number, +): ConvertedExpense | null { + // Find the payer (user with paid_share > 0) + const payer = expense.users.find((u) => parseFloat(u.paid_share) > 0); + + if (!payer) { + console.warn(`No payer found for expense: ${expense.description}`); + return null; + } + + // Use user_id from the expense data to look up DB user + const payerDbId = splitwiseIdToDbId.get(payer.user_id); + if (!payerDbId) { + console.warn(`Payer not found in DB: Splitwise ID ${payer.user_id}`); + return null; + } + + // Convert cost to BigInt (paisa/cents) + const totalAmount = BigInt(Math.round(parseFloat(expense.cost) * 100)); + + // Build participants + const participants: { userId: number; amount: bigint }[] = []; + + for (const userShare of expense.users) { + const userId = splitwiseIdToDbId.get(userShare.user_id); + if (!userId) { + console.warn(`User not found in DB: Splitwise ID ${userShare.user_id}`); + continue; + } + + const paidShare = parseFloat(userShare.paid_share); + const owedShare = parseFloat(userShare.owed_share); + + // Calculate participant amount using SplitPro's model: + // - If they paid: amount = -owedShare + paidShare + // - If they didn't pay: amount = -owedShare + let participantAmount: bigint; + if (paidShare > 0) { + // This is a payer + participantAmount = BigInt(Math.round((-owedShare + paidShare) * 100)); + } else { + // Non-payer + participantAmount = BigInt(Math.round(-owedShare * 100)); + } + + // Skip zero amounts + if (0n === participantAmount) { + continue; + } + + participants.push({ + userId, + amount: participantAmount, + }); + } + + if (participants.length === 0) { + console.warn(`No participants for expense: ${expense.description}`); + return null; + } + + // Determine split type + const splitType = determineSplitType(expense); + + return { + name: expense.description || 'Untitled expense', + category: expense.category?.name?.toLowerCase() || 'general', + amount: totalAmount, + currency: expense.currency_code, + splitType, + expenseDate: new Date(expense.date), + createdAt: new Date(expense.created_at), + paidBy: payerDbId, + groupId, + participants, + transactionId: `splitwise-api-${expense.id}`, + }; +} + +/** + * Determine the split type based on expense data + */ +export function determineSplitType(expense: SplitwiseExpense): SplitType { + if (expense.payment) { + return SplitType.SETTLEMENT; + } + + // Check if it's an equal split + const shares = expense.users.map((u) => parseFloat(u.owed_share)).filter((s) => s > 0); + const uniqueShares = new Set(shares.map((s) => s.toFixed(2))); + + if (uniqueShares.size === 1 && shares.length > 1) { + return SplitType.EQUAL; + } + + return SplitType.EXACT; +} + +/** + * Map Splitwise category to SplitPro category + */ +export function mapCategory(splitwiseCategory: string): string { + const categoryMap: Record = { + groceries: 'food', + 'dining out': 'food', + food: 'food', + drinks: 'food', + liquor: 'food', + rent: 'home', + mortgage: 'home', + household: 'home', + furniture: 'home', + maintenance: 'home', + pets: 'home', + services: 'home', + electronics: 'home', + utilities: 'utilities', + electricity: 'utilities', + heat: 'utilities', + water: 'utilities', + tv: 'utilities', + internet: 'utilities', + trash: 'utilities', + cleaning: 'utilities', + transportation: 'transportation', + parking: 'transportation', + car: 'transportation', + bus: 'transportation', + train: 'transportation', + plane: 'transportation', + taxi: 'transportation', + bicycle: 'transportation', + hotel: 'transportation', + gas: 'transportation', + entertainment: 'entertainment', + games: 'entertainment', + movies: 'entertainment', + music: 'entertainment', + sports: 'entertainment', + clothing: 'personal', + gifts: 'personal', + medical: 'personal', + insurance: 'personal', + taxes: 'personal', + education: 'personal', + childcare: 'personal', + general: 'general', + payment: 'general', + other: 'general', + }; + + return categoryMap[splitwiseCategory.toLowerCase()] || 'general'; +} + +/** + * Calculate the participant amount for SplitPro's model + * + * In SplitPro: + * - Payer gets: total - their_share (positive, they are owed money) + * - Non-payer gets: -their_share (negative, they owe money) + * + * From Splitwise: + * - paid_share: how much they paid + * - owed_share: how much they owe (their share of the expense) + * + * Formula: amount = paid_share - owed_share + * - If paid_share > owed_share: positive (they are owed) + * - If paid_share < owed_share: negative (they owe) + */ +export function calculateParticipantAmount(paidShare: number, owedShare: number): bigint { + return BigInt(Math.round((paidShare - owedShare) * 100)); +} diff --git a/src/pages/account.tsx b/src/pages/account.tsx index 3f37ad26..301a1ae6 100644 --- a/src/pages/account.tsx +++ b/src/pages/account.tsx @@ -172,6 +172,11 @@ const AccountPage: NextPageWithUser<{ {t('account.import_from_splitwise')} + + + {t('account.import_from_splitwise_api')} + + diff --git a/src/pages/import-splitwise-api.tsx b/src/pages/import-splitwise-api.tsx new file mode 100644 index 00000000..9388568c --- /dev/null +++ b/src/pages/import-splitwise-api.tsx @@ -0,0 +1,356 @@ +/** + * Splitwise API Import Page + * + * Imports complete expense history from Splitwise using their API. + * Provides 100% accurate import with full expense details. + */ + +import { CheckCircle, Key, Loader2, XCircle } from 'lucide-react'; +import Head from 'next/head'; +import Link from 'next/link'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import MainLayout from '~/components/Layout/MainLayout'; +import { Button } from '~/components/ui/button'; +import { Checkbox } from '~/components/ui/checkbox'; +import { Input } from '~/components/ui/input'; +import { Separator } from '~/components/ui/separator'; +import { type NextPageWithUser } from '~/types'; +import { api } from '~/utils/api'; +import { withI18nStaticProps } from '~/utils/i18n/server'; + +type ImportStatus = 'idle' | 'importing' | 'success' | 'error'; + +interface GroupImportState { + status: ImportStatus; + imported?: number; + skipped?: number; + total?: number; + error?: string; +} + +const ImportSplitwiseApiPage: NextPageWithUser = () => { + const [apiKey, setApiKey] = useState(''); + const [isValidating, setIsValidating] = useState(false); + const [isValidated, setIsValidated] = useState(false); + const [userName, setUserName] = useState(''); + const [selectedGroups, setSelectedGroups] = useState>({}); + const [importStatus, setImportStatus] = useState>({}); + + // Validate API key mutation + const validateMutation = api.splitwiseApiImport.validateKey.useMutation(); + + // Get groups query (enabled only after validation) + const groupsQuery = api.splitwiseApiImport.getGroups.useQuery( + { apiKey }, + { enabled: isValidated && apiKey.length > 0 }, + ); + + // Import group mutation + const importMutation = api.splitwiseApiImport.importGroup.useMutation(); + + const handleValidateKey = async () => { + if (!apiKey.trim()) { + toast.error('Please enter your Splitwise API key'); + return; + } + + setIsValidating(true); + try { + const result = await validateMutation.mutateAsync({ apiKey }); + if (result.valid && result.user) { + setIsValidated(true); + setUserName( + `${result.user.first_name}${result.user.last_name ? ` ${result.user.last_name}` : ''}`, + ); + toast.success(`Connected as ${result.user.first_name}`); + } else { + toast.error(result.error || 'Invalid API key'); + } + } catch (error) { + toast.error('Failed to validate API key'); + } finally { + setIsValidating(false); + } + }; + + const handleImportSelected = async () => { + const selectedGroupIds = Object.entries(selectedGroups) + .filter(([, selected]) => selected) + .map(([id]) => Number(id)); + + if (selectedGroupIds.length === 0) { + toast.error('Please select at least one group to import'); + return; + } + + const groups = groupsQuery.data || []; + + // Import groups sequentially + for (const groupId of selectedGroupIds) { + const group = groups.find((g) => g.id === groupId); + if (!group) { + continue; + } + + setImportStatus((prev) => ({ + ...prev, + [groupId]: { status: 'importing' }, + })); + + try { + const result = await importMutation.mutateAsync({ + apiKey, + groupId, + groupName: group.name, + }); + + setImportStatus((prev) => ({ + ...prev, + [groupId]: { + status: 'success', + imported: result.imported, + skipped: result.skipped, + total: result.total, + }, + })); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + setImportStatus((prev) => ({ + ...prev, + [groupId]: { status: 'error', error: message }, + })); + } + } + + toast.success('Import completed!'); + }; + + const handleSelectAll = () => { + const groups = groupsQuery.data || []; + const allSelected = groups.every((g) => selectedGroups[g.id]); + + if (allSelected) { + setSelectedGroups({}); + } else { + const newSelected: Record = {}; + groups.forEach((g) => { + newSelected[g.id] = true; + }); + setSelectedGroups(newSelected); + } + }; + + const isImporting = Object.values(importStatus).some((s) => s.status === 'importing'); + const hasSelection = Object.values(selectedGroups).some(Boolean); + + return ( + <> + + Import from Splitwise (API) + + + +
+
+ + + +
+
Import from Splitwise
+
+
+ + {/* API Key Section */} +
+

Step 1: Connect to Splitwise

+

+ Enter your Splitwise API key to fetch your groups and expenses. +

+ +
+
+ + setApiKey(e.target.value)} + disabled={isValidated} + className="pl-10" + /> +
+ {!isValidated ? ( + + ) : ( + + )} +
+ + {isValidated &&

Connected as {userName}

} + +

+ Get your API key from{' '} + + secure.splitwise.com/apps + +

+
+ + {/* Groups Section */} + {isValidated && ( +
+
+

Step 2: Select Groups to Import

+ {groupsQuery.data && groupsQuery.data.length > 0 && ( + + )} +
+

+ Choose which groups to import. All expenses will be imported with complete accuracy. +

+ + {groupsQuery.isLoading && ( +
+ +
+ )} + + {groupsQuery.error && ( +
+ Failed to load groups: {groupsQuery.error.message} +
+ )} + + {groupsQuery.data && ( +
+ {groupsQuery.data.map((group, index) => { + const status = importStatus[group.id]; + return ( +
+
+
+ { + setSelectedGroups({ + ...selectedGroups, + [group.id]: Boolean(checked), + }); + }} + disabled={ + status?.status === 'importing' || status?.status === 'success' + } + /> +
+

{group.name}

+

{group.memberCount} members

+
+
+ +
+ {status?.status === 'importing' && ( +
+ + Importing... +
+ )} + {status?.status === 'success' && ( +
+ + + {status.imported} imported, {status.skipped} skipped + +
+ )} + {status?.status === 'error' && ( +
+ + {status.error} +
+ )} +
+
+ {index !== groupsQuery.data.length - 1 && } +
+ ); + })} +
+ )} + + {/* Import Button */} + {groupsQuery.data && groupsQuery.data.length > 0 && ( +
+ +
+ )} +
+ )} + + {/* Info Section */} +
+

How this works

+
    +
  • - Fetches complete expense data directly from Splitwise API
  • +
  • - Imports exact payment splits (who paid, who owes what)
  • +
  • - Creates accounts for group members using their Splitwise email
  • +
  • - Skips duplicate expenses (safe to re-run)
  • +
  • - Preserves original expense dates and categories
  • +
+
+ + {/* Note about friends */} +
+

About group members

+

+ When you import a group, accounts are created for all members using their Splitwise + email. When your friends sign up to SplitPro using the same email they + use in Splitwise, they'll automatically see the imported expenses and groups. +

+
+ + + ); +}; + +ImportSplitwiseApiPage.auth = true; + +export const getStaticProps = withI18nStaticProps(['common']); + +export default ImportSplitwiseApiPage; diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 951725e6..8fb7ca68 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -4,6 +4,7 @@ import { createTRPCRouter } from '~/server/api/trpc'; import { userRouter } from './routers/user'; import { bankTransactionsRouter } from './routers/bankTransactions'; import { expenseRouter } from './routers/expense'; +import { splitwiseApiImportRouter } from './routers/splitwiseApiImport'; /** * This is the primary router for your server. @@ -15,7 +16,8 @@ export const appRouter = createTRPCRouter({ user: userRouter, bankTransactions: bankTransactionsRouter, expense: expenseRouter, + splitwiseApiImport: splitwiseApiImportRouter, }); -// export type definition of API +// Export type definition of API export type AppRouter = typeof appRouter; diff --git a/src/server/api/routers/splitwiseApiImport.ts b/src/server/api/routers/splitwiseApiImport.ts new file mode 100644 index 00000000..571c71e4 --- /dev/null +++ b/src/server/api/routers/splitwiseApiImport.ts @@ -0,0 +1,254 @@ +/** + * Splitwise API Import Router + * + * Handles importing expenses from Splitwise using their API. + * This provides 100% accurate import with complete expense data. + */ + +import { z } from 'zod'; +import { createTRPCRouter, protectedProcedure } from '~/server/api/trpc'; +import { getAllGroupExpenses, getGroups, validateApiKey } from '~/lib/splitwise-api'; +import { nanoid } from 'nanoid'; +import { convertExpenseToSplitPro, mapCategory } from '~/lib/splitwise-converter'; + +// ============= ROUTER ============= + +export const splitwiseApiImportRouter = createTRPCRouter({ + /** + * Validate Splitwise API key + */ + validateKey: protectedProcedure + .input(z.object({ apiKey: z.string().min(1) })) + .mutation(async ({ input }) => { + const result = await validateApiKey(input.apiKey); + return result; + }), + + /** + * Get groups from Splitwise + */ + getGroups: protectedProcedure + .input(z.object({ apiKey: z.string().min(1) })) + .query(async ({ input }) => { + const groups = await getGroups(input.apiKey); + + // Return groups with summary info + return groups.map((group) => ({ + id: group.id, + name: group.name, + memberCount: group.members.length, + members: group.members.map((m) => ({ + id: m.id, + name: `${m.first_name}${m.last_name ? ` ${m.last_name}` : ''}`, + email: m.email, + balance: m.balance, + })), + simplifyByDefault: group.simplify_by_default, + createdAt: group.created_at, + })); + }), + + /** + * Import a single group from Splitwise + */ + importGroup: protectedProcedure + .input( + z.object({ + apiKey: z.string().min(1), + groupId: z.number(), + groupName: z.string(), + }), + ) + .mutation(async ({ ctx, input }) => { + const currentUserId = ctx.session.user.id; + + console.info(`Starting import for group: ${input.groupName} (${input.groupId})`); + + // Step 1: Fetch all groups to get member details + const groups = await getGroups(input.apiKey); + const splitwiseGroup = groups.find((g) => g.id === input.groupId); + + if (!splitwiseGroup) { + throw new Error(`Group not found: ${input.groupId}`); + } + + // Step 2: Create or find users for all group members + // Map Splitwise user ID to our DB user ID + const splitwiseIdToDbId = new Map(); + + for (const member of splitwiseGroup.members) { + let user = await ctx.db.user.findUnique({ + where: { email: member.email }, + }); + + if (!user) { + user = await ctx.db.user.create({ + data: { + email: member.email, + name: `${member.first_name}${member.last_name ? ` ${member.last_name}` : ''}`, + image: member.picture?.medium, + currency: 'INR', // Default, can be updated + }, + }); + console.info(`Created user: ${user.email}`); + } + + // Map Splitwise member ID to our DB user ID + splitwiseIdToDbId.set(member.id, user.id); + console.info(`Mapped Splitwise ID ${member.id} -> DB ID ${user.id} (${member.email})`); + } + + // Step 3: Create group in SplitPro (or find existing) + let group = await ctx.db.group.findUnique({ + where: { splitwiseGroupId: input.groupId.toString() }, + }); + + if (group) { + console.info(`Group already exists: ${group.name}`); + + // Update group name if it changed in Splitwise + if (group.name !== splitwiseGroup.name) { + await ctx.db.group.update({ + where: { id: group.id }, + data: { name: splitwiseGroup.name }, + }); + console.info(`Updated group name: ${group.name} -> ${splitwiseGroup.name}`); + } + + // Add any new members that might not be in the group yet + for (const member of splitwiseGroup.members) { + const userId = splitwiseIdToDbId.get(member.id); + if (userId) { + const existingMember = await ctx.db.groupUser.findUnique({ + where: { + groupId_userId: { + groupId: group.id, + userId: userId, + }, + }, + }); + if (!existingMember) { + await ctx.db.groupUser.create({ + data: { + groupId: group.id, + userId, + }, + }); + console.info(`Added new member to existing group: ${member.email}`); + } + } + } + } else { + group = await ctx.db.group.create({ + data: { + name: splitwiseGroup.name, + publicId: nanoid(10), + userId: currentUserId, + defaultCurrency: 'INR', + splitwiseGroupId: input.groupId.toString(), + simplifyDebts: splitwiseGroup.simplify_by_default, + createdAt: new Date(splitwiseGroup.created_at), + }, + }); + console.info(`Created group: ${group.name}`); + + // Add all members to the group + for (const member of splitwiseGroup.members) { + const userId = splitwiseIdToDbId.get(member.id); + if (userId) { + await ctx.db.groupUser.create({ + data: { + groupId: group.id, + userId, + }, + }); + } + } + console.info(`Added ${splitwiseGroup.members.length} members to group`); + } + + // Step 4: Fetch ALL expenses for this group + console.info(`Fetching expenses for group: ${input.groupName}...`); + const expenses = await getAllGroupExpenses(input.apiKey, input.groupId); + console.info(`Fetched ${expenses.length} expenses`); + + // Step 5: Import expenses + let imported = 0; + let skipped = 0; + const errors: string[] = []; + + for (const expense of expenses) { + // Skip deleted expenses + if (expense.deleted_at) { + skipped++; + continue; + } + + // Check for duplicates + const existing = await ctx.db.expense.findFirst({ + where: { + transactionId: `splitwise-api-${expense.id}`, + }, + }); + + if (existing) { + skipped++; + continue; + } + + // Convert expense + const converted = convertExpenseToSplitPro(expense, splitwiseIdToDbId, group.id); + + if (!converted) { + errors.push(`Failed to convert: ${expense.description}`); + continue; + } + + // Create expense and participants in transaction + try { + await ctx.db.$transaction(async (tx) => { + const createdExpense = await tx.expense.create({ + data: { + name: converted.name, + category: mapCategory(converted.category), + amount: converted.amount, + currency: converted.currency, + splitType: converted.splitType, + expenseDate: converted.expenseDate, + createdAt: converted.createdAt, + paidBy: converted.paidBy, + addedBy: currentUserId, + groupId: converted.groupId, + transactionId: converted.transactionId, + }, + }); + + await tx.expenseParticipant.createMany({ + data: converted.participants.map((p) => ({ + expenseId: createdExpense.id, + userId: p.userId, + amount: p.amount, + })), + }); + }); + + imported++; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + errors.push(`Error importing "${expense.description}": ${msg}`); + } + } + + console.info(`Import complete: ${imported} imported, ${skipped} skipped`); + + return { + success: true, + groupId: group.id, + groupName: group.name, + imported, + skipped, + total: expenses.length, + errors: errors.slice(0, 10), // Return first 10 errors + }; + }), +}); diff --git a/src/tests/splitwise-converter.test.ts b/src/tests/splitwise-converter.test.ts new file mode 100644 index 00000000..ba903dd3 --- /dev/null +++ b/src/tests/splitwise-converter.test.ts @@ -0,0 +1,616 @@ +import { SplitType } from '@prisma/client'; +import { + calculateParticipantAmount, + convertExpenseToSplitPro, + determineSplitType, + mapCategory, +} from '~/lib/splitwise-converter'; +import { type SplitwiseExpense } from '~/lib/splitwise-api'; + +// Suppress console.warn during tests (we're testing error cases intentionally) +beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +// ============= TEST HELPERS ============= + +/** + * Create a mock Splitwise expense for testing + */ +function createMockExpense(overrides: Partial = {}): SplitwiseExpense { + return { + id: 1, + cost: '100.00', + description: 'Test Expense', + details: null, + date: '2024-01-15T12:00:00Z', + currency_code: 'INR', + category: { id: 1, name: 'General' }, + group_id: 1, + friendship_id: null, + expense_bundle_id: null, + repeats: false, + repeat_interval: null, + email_reminder: false, + email_reminder_in_advance: null, + next_repeat: null, + comments_count: 0, + payment: false, + transaction_confirmed: true, + created_at: '2024-01-15T12:00:00Z', + created_by: { id: 1, first_name: 'Test', last_name: 'User' }, + updated_at: '2024-01-15T12:00:00Z', + updated_by: null, + deleted_at: null, + deleted_by: null, + users: [ + { + user_id: 101, + user: { + id: 101, + first_name: 'Alice', + last_name: 'Smith', + email: 'alice@example.com', + }, + paid_share: '100.00', + owed_share: '50.00', + net_balance: '50.00', + }, + { + user_id: 102, + user: { + id: 102, + first_name: 'Bob', + last_name: 'Jones', + email: 'bob@example.com', + }, + paid_share: '0.00', + owed_share: '50.00', + net_balance: '-50.00', + }, + ], + repayments: [], + ...overrides, + }; +} + +/** + * Create a standard user mapping for tests + */ +function createUserMapping(): Map { + const map = new Map(); + map.set(101, 1); // Splitwise ID 101 -> DB ID 1 + map.set(102, 2); // Splitwise ID 102 -> DB ID 2 + map.set(103, 3); // Splitwise ID 103 -> DB ID 3 + return map; +} + +// ============= TESTS ============= + +describe('convertExpenseToSplitPro', () => { + describe('basic conversion', () => { + it('converts a simple two-person equal split expense', () => { + const expense = createMockExpense(); + const userMap = createUserMapping(); + + const result = convertExpenseToSplitPro(expense, userMap, 10); + + expect(result).not.toBeNull(); + expect(result!.name).toBe('Test Expense'); + expect(result!.amount).toBe(10000n); // 100.00 * 100 = 10000 paisa + expect(result!.currency).toBe('INR'); + expect(result!.groupId).toBe(10); + expect(result!.paidBy).toBe(1); // Alice (DB ID 1) + expect(result!.transactionId).toBe('splitwise-api-1'); + }); + + it('calculates participant amounts correctly for equal split', () => { + // Alice paid 100, split equally (50 each) + // Alice: paid 100, owes 50 -> amount = 100 - 50 = +50 (is owed) + // Bob: paid 0, owes 50 -> amount = 0 - 50 = -50 (owes) + const expense = createMockExpense(); + const userMap = createUserMapping(); + + const result = convertExpenseToSplitPro(expense, userMap, 10); + + expect(result!.participants).toHaveLength(2); + + const aliceParticipant = result!.participants.find((p) => p.userId === 1); + const bobParticipant = result!.participants.find((p) => p.userId === 2); + + expect(aliceParticipant!.amount).toBe(5000n); // +50.00 (is owed) + expect(bobParticipant!.amount).toBe(-5000n); // -50.00 (owes) + }); + + it('handles three-way equal split', () => { + const expense = createMockExpense({ + cost: '300.00', + users: [ + { + user_id: 101, + user: { id: 101, first_name: 'Alice', last_name: null, email: 'alice@example.com' }, + paid_share: '300.00', + owed_share: '100.00', + net_balance: '200.00', + }, + { + user_id: 102, + user: { id: 102, first_name: 'Bob', last_name: null, email: 'bob@example.com' }, + paid_share: '0.00', + owed_share: '100.00', + net_balance: '-100.00', + }, + { + user_id: 103, + user: { id: 103, first_name: 'Charlie', last_name: null, email: 'charlie@example.com' }, + paid_share: '0.00', + owed_share: '100.00', + net_balance: '-100.00', + }, + ], + }); + const userMap = createUserMapping(); + + const result = convertExpenseToSplitPro(expense, userMap, 10); + + expect(result!.participants).toHaveLength(3); + expect(result!.amount).toBe(30000n); + + const alice = result!.participants.find((p) => p.userId === 1); + const bob = result!.participants.find((p) => p.userId === 2); + const charlie = result!.participants.find((p) => p.userId === 3); + + expect(alice!.amount).toBe(20000n); // +200 (paid 300, owes 100) + expect(bob!.amount).toBe(-10000n); // -100 + expect(charlie!.amount).toBe(-10000n); // -100 + }); + }); + + describe('unequal splits', () => { + it('handles unequal split correctly', () => { + // Alice paid 100, Alice owes 30, Bob owes 70 + const expense = createMockExpense({ + cost: '100.00', + users: [ + { + user_id: 101, + user: { id: 101, first_name: 'Alice', last_name: null, email: 'alice@example.com' }, + paid_share: '100.00', + owed_share: '30.00', + net_balance: '70.00', + }, + { + user_id: 102, + user: { id: 102, first_name: 'Bob', last_name: null, email: 'bob@example.com' }, + paid_share: '0.00', + owed_share: '70.00', + net_balance: '-70.00', + }, + ], + }); + const userMap = createUserMapping(); + + const result = convertExpenseToSplitPro(expense, userMap, 10); + + const alice = result!.participants.find((p) => p.userId === 1); + const bob = result!.participants.find((p) => p.userId === 2); + + expect(alice!.amount).toBe(7000n); // +70 (paid 100, owes 30) + expect(bob!.amount).toBe(-7000n); // -70 + }); + }); + + describe('settlements/payments', () => { + it('converts settlement expense correctly', () => { + // Bob pays Alice 50 (settling a debt) + const expense = createMockExpense({ + description: 'Payment', + cost: '50.00', + payment: true, + users: [ + { + user_id: 101, + user: { id: 101, first_name: 'Alice', last_name: null, email: 'alice@example.com' }, + paid_share: '0.00', + owed_share: '50.00', + net_balance: '-50.00', + }, + { + user_id: 102, + user: { id: 102, first_name: 'Bob', last_name: null, email: 'bob@example.com' }, + paid_share: '50.00', + owed_share: '0.00', + net_balance: '50.00', + }, + ], + }); + const userMap = createUserMapping(); + + const result = convertExpenseToSplitPro(expense, userMap, 10); + + expect(result!.splitType).toBe(SplitType.SETTLEMENT); + expect(result!.paidBy).toBe(2); // Bob paid + + const alice = result!.participants.find((p) => p.userId === 1); + const bob = result!.participants.find((p) => p.userId === 2); + + expect(alice!.amount).toBe(-5000n); // Alice received payment (owes less now) + expect(bob!.amount).toBe(5000n); // Bob made payment (is owed now) + }); + }); + + describe('edge cases', () => { + it('returns null when no payer is found', () => { + const expense = createMockExpense({ + users: [ + { + user_id: 101, + user: { id: 101, first_name: 'Alice', last_name: null, email: 'alice@example.com' }, + paid_share: '0.00', + owed_share: '50.00', + net_balance: '-50.00', + }, + { + user_id: 102, + user: { id: 102, first_name: 'Bob', last_name: null, email: 'bob@example.com' }, + paid_share: '0.00', + owed_share: '50.00', + net_balance: '-50.00', + }, + ], + }); + const userMap = createUserMapping(); + + const result = convertExpenseToSplitPro(expense, userMap, 10); + + expect(result).toBeNull(); + }); + + it('returns null when payer not in user map', () => { + const expense = createMockExpense({ + users: [ + { + user_id: 999, // Not in user map + user: { id: 999, first_name: 'Unknown', last_name: null, email: 'unknown@example.com' }, + paid_share: '100.00', + owed_share: '50.00', + net_balance: '50.00', + }, + { + user_id: 102, + user: { id: 102, first_name: 'Bob', last_name: null, email: 'bob@example.com' }, + paid_share: '0.00', + owed_share: '50.00', + net_balance: '-50.00', + }, + ], + }); + const userMap = createUserMapping(); + + const result = convertExpenseToSplitPro(expense, userMap, 10); + + expect(result).toBeNull(); + }); + + it('skips participants with zero amounts', () => { + // User 103 has zero owed_share and zero paid_share + const expense = createMockExpense({ + users: [ + { + user_id: 101, + user: { id: 101, first_name: 'Alice', last_name: null, email: 'alice@example.com' }, + paid_share: '100.00', + owed_share: '50.00', + net_balance: '50.00', + }, + { + user_id: 102, + user: { id: 102, first_name: 'Bob', last_name: null, email: 'bob@example.com' }, + paid_share: '0.00', + owed_share: '50.00', + net_balance: '-50.00', + }, + { + user_id: 103, + user: { id: 103, first_name: 'Charlie', last_name: null, email: 'charlie@example.com' }, + paid_share: '0.00', + owed_share: '0.00', + net_balance: '0.00', + }, + ], + }); + const userMap = createUserMapping(); + + const result = convertExpenseToSplitPro(expense, userMap, 10); + + expect(result!.participants).toHaveLength(2); + expect(result!.participants.find((p) => p.userId === 3)).toBeUndefined(); + }); + + it('handles missing description', () => { + const expense = createMockExpense({ description: '' }); + const userMap = createUserMapping(); + + const result = convertExpenseToSplitPro(expense, userMap, 10); + + expect(result!.name).toBe('Untitled expense'); + }); + + it('handles decimal amounts correctly (avoids floating point issues)', () => { + const expense = createMockExpense({ + cost: '33.33', + users: [ + { + user_id: 101, + user: { id: 101, first_name: 'Alice', last_name: null, email: 'alice@example.com' }, + paid_share: '33.33', + owed_share: '11.11', + net_balance: '22.22', + }, + { + user_id: 102, + user: { id: 102, first_name: 'Bob', last_name: null, email: 'bob@example.com' }, + paid_share: '0.00', + owed_share: '11.11', + net_balance: '-11.11', + }, + { + user_id: 103, + user: { id: 103, first_name: 'Charlie', last_name: null, email: 'charlie@example.com' }, + paid_share: '0.00', + owed_share: '11.11', + net_balance: '-11.11', + }, + ], + }); + const userMap = createUserMapping(); + + const result = convertExpenseToSplitPro(expense, userMap, 10); + + expect(result!.amount).toBe(3333n); + + const alice = result!.participants.find((p) => p.userId === 1); + const bob = result!.participants.find((p) => p.userId === 2); + const charlie = result!.participants.find((p) => p.userId === 3); + + expect(alice!.amount).toBe(2222n); + expect(bob!.amount).toBe(-1111n); + expect(charlie!.amount).toBe(-1111n); + }); + }); + + describe('balance verification', () => { + it('participant amounts sum to zero', () => { + const expense = createMockExpense(); + const userMap = createUserMapping(); + + const result = convertExpenseToSplitPro(expense, userMap, 10); + + const sum = result!.participants.reduce((acc, p) => acc + p.amount, 0n); + expect(sum).toBe(0n); + }); + + it('participant amounts sum to zero for three-way split', () => { + const expense = createMockExpense({ + cost: '300.00', + users: [ + { + user_id: 101, + user: { id: 101, first_name: 'Alice', last_name: null, email: 'alice@example.com' }, + paid_share: '300.00', + owed_share: '100.00', + net_balance: '200.00', + }, + { + user_id: 102, + user: { id: 102, first_name: 'Bob', last_name: null, email: 'bob@example.com' }, + paid_share: '0.00', + owed_share: '100.00', + net_balance: '-100.00', + }, + { + user_id: 103, + user: { id: 103, first_name: 'Charlie', last_name: null, email: 'charlie@example.com' }, + paid_share: '0.00', + owed_share: '100.00', + net_balance: '-100.00', + }, + ], + }); + const userMap = createUserMapping(); + + const result = convertExpenseToSplitPro(expense, userMap, 10); + + const sum = result!.participants.reduce((acc, p) => acc + p.amount, 0n); + expect(sum).toBe(0n); + }); + + it('participant amounts sum to zero for settlement', () => { + const expense = createMockExpense({ + payment: true, + cost: '50.00', + users: [ + { + user_id: 101, + user: { id: 101, first_name: 'Alice', last_name: null, email: 'alice@example.com' }, + paid_share: '0.00', + owed_share: '50.00', + net_balance: '-50.00', + }, + { + user_id: 102, + user: { id: 102, first_name: 'Bob', last_name: null, email: 'bob@example.com' }, + paid_share: '50.00', + owed_share: '0.00', + net_balance: '50.00', + }, + ], + }); + const userMap = createUserMapping(); + + const result = convertExpenseToSplitPro(expense, userMap, 10); + + const sum = result!.participants.reduce((acc, p) => acc + p.amount, 0n); + expect(sum).toBe(0n); + }); + }); +}); + +describe('determineSplitType', () => { + it('returns SETTLEMENT for payment expenses', () => { + const expense = createMockExpense({ payment: true }); + expect(determineSplitType(expense)).toBe(SplitType.SETTLEMENT); + }); + + it('returns EQUAL for equal splits', () => { + const expense = createMockExpense({ + users: [ + { + user_id: 101, + user: { id: 101, first_name: 'Alice', last_name: null, email: 'alice@example.com' }, + paid_share: '100.00', + owed_share: '50.00', + net_balance: '50.00', + }, + { + user_id: 102, + user: { id: 102, first_name: 'Bob', last_name: null, email: 'bob@example.com' }, + paid_share: '0.00', + owed_share: '50.00', + net_balance: '-50.00', + }, + ], + }); + expect(determineSplitType(expense)).toBe(SplitType.EQUAL); + }); + + it('returns EXACT for unequal splits', () => { + const expense = createMockExpense({ + users: [ + { + user_id: 101, + user: { id: 101, first_name: 'Alice', last_name: null, email: 'alice@example.com' }, + paid_share: '100.00', + owed_share: '30.00', + net_balance: '70.00', + }, + { + user_id: 102, + user: { id: 102, first_name: 'Bob', last_name: null, email: 'bob@example.com' }, + paid_share: '0.00', + owed_share: '70.00', + net_balance: '-70.00', + }, + ], + }); + expect(determineSplitType(expense)).toBe(SplitType.EXACT); + }); + + it('returns EXACT when only one person owes', () => { + const expense = createMockExpense({ + users: [ + { + user_id: 101, + user: { id: 101, first_name: 'Alice', last_name: null, email: 'alice@example.com' }, + paid_share: '100.00', + owed_share: '0.00', + net_balance: '100.00', + }, + { + user_id: 102, + user: { id: 102, first_name: 'Bob', last_name: null, email: 'bob@example.com' }, + paid_share: '0.00', + owed_share: '100.00', + net_balance: '-100.00', + }, + ], + }); + expect(determineSplitType(expense)).toBe(SplitType.EXACT); + }); +}); + +describe('mapCategory', () => { + it('maps food-related categories to food', () => { + expect(mapCategory('groceries')).toBe('food'); + expect(mapCategory('dining out')).toBe('food'); + expect(mapCategory('food')).toBe('food'); + expect(mapCategory('drinks')).toBe('food'); + expect(mapCategory('liquor')).toBe('food'); + }); + + it('maps home-related categories to home', () => { + expect(mapCategory('rent')).toBe('home'); + expect(mapCategory('mortgage')).toBe('home'); + expect(mapCategory('household')).toBe('home'); + expect(mapCategory('furniture')).toBe('home'); + expect(mapCategory('electronics')).toBe('home'); + }); + + it('maps utility categories to utilities', () => { + expect(mapCategory('utilities')).toBe('utilities'); + expect(mapCategory('electricity')).toBe('utilities'); + expect(mapCategory('water')).toBe('utilities'); + expect(mapCategory('internet')).toBe('utilities'); + }); + + it('maps transportation categories to transportation', () => { + expect(mapCategory('transportation')).toBe('transportation'); + expect(mapCategory('parking')).toBe('transportation'); + expect(mapCategory('taxi')).toBe('transportation'); + expect(mapCategory('gas')).toBe('transportation'); + }); + + it('maps entertainment categories to entertainment', () => { + expect(mapCategory('entertainment')).toBe('entertainment'); + expect(mapCategory('games')).toBe('entertainment'); + expect(mapCategory('movies')).toBe('entertainment'); + expect(mapCategory('music')).toBe('entertainment'); + }); + + it('maps personal categories to personal', () => { + expect(mapCategory('clothing')).toBe('personal'); + expect(mapCategory('gifts')).toBe('personal'); + expect(mapCategory('medical')).toBe('personal'); + expect(mapCategory('insurance')).toBe('personal'); + }); + + it('returns general for unknown categories', () => { + expect(mapCategory('unknown')).toBe('general'); + expect(mapCategory('random')).toBe('general'); + expect(mapCategory('xyz')).toBe('general'); + }); + + it('handles case-insensitive input', () => { + expect(mapCategory('GROCERIES')).toBe('food'); + expect(mapCategory('Rent')).toBe('home'); + expect(mapCategory('ENTERTAINMENT')).toBe('entertainment'); + }); +}); + +describe('calculateParticipantAmount', () => { + it('returns positive amount when paid more than owed', () => { + // Paid 100, owes 30 -> +70 + const amount = calculateParticipantAmount(100, 30); + expect(amount).toBe(7000n); + }); + + it('returns negative amount when owed more than paid', () => { + // Paid 0, owes 50 -> -50 + const amount = calculateParticipantAmount(0, 50); + expect(amount).toBe(-5000n); + }); + + it('returns zero when paid equals owed', () => { + const amount = calculateParticipantAmount(50, 50); + expect(amount).toBe(0n); + }); + + it('handles decimal values correctly', () => { + const amount = calculateParticipantAmount(33.33, 11.11); + expect(amount).toBe(2222n); + }); +});