diff --git a/public/locales/de/common.json b/public/locales/de/common.json index da20c85e..949d891a 100644 --- a/public/locales/de/common.json +++ b/public/locales/de/common.json @@ -30,8 +30,7 @@ "messages": { "import_success": "Import erfolgreich" }, - "no_file_chosen": "Keine Datei ausgewählt", - "note": "Hinweis: Es werden derzeit nur Freunde und Gruppen importiert. Transaktionen werden nicht importiert. Wir arbeiten daran." + "no_file_chosen": "Keine Datei ausgewählt" }, "logout": "Abmelden", "messages": { diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 12df457e..41f4fcad 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -30,8 +30,8 @@ "messages": { "import_success": "Import successful" }, - "no_file_chosen": "No file chosen", - "note": "Note: It currently only supports importing friends and groups. It will not import transactions. We are working on it." + "selected_expenses": "Selected expenses", + "no_file_chosen": "No file chosen" }, "logout": "Logout", "messages": { diff --git a/public/locales/es-AR/common.json b/public/locales/es-AR/common.json index 345f128e..9268d0e7 100644 --- a/public/locales/es-AR/common.json +++ b/public/locales/es-AR/common.json @@ -30,8 +30,7 @@ "messages": { "import_success": "Importación exitosa" }, - "no_file_chosen": "Ningún archivo seleccionado", - "note": "Nota: Actualmente solo soporta la importación de amigos y grupos. No importará transacciones. Estamos trabajando en ello." + "no_file_chosen": "Ningún archivo seleccionado" }, "logout": "Cerrar sesión", "messages": { diff --git a/public/locales/es-MX/common.json b/public/locales/es-MX/common.json index 182af553..9ed31ee6 100644 --- a/public/locales/es-MX/common.json +++ b/public/locales/es-MX/common.json @@ -30,8 +30,7 @@ "messages": { "import_success": "Importación exitosa" }, - "no_file_chosen": "Ningún archivo seleccionado", - "note": "Nota: Actualmente solo soporta la importación de amigos y grupos. No importará transacciones. Estamos trabajando en ello." + "no_file_chosen": "Ningún archivo seleccionado" }, "logout": "Cerrar sesión", "notifications": { diff --git a/public/locales/fr/common.json b/public/locales/fr/common.json index 4effd275..6744abfe 100644 --- a/public/locales/fr/common.json +++ b/public/locales/fr/common.json @@ -30,8 +30,7 @@ "messages": { "import_success": "L'import est un succès" }, - "no_file_chosen": "Aucun fichier choisi", - "note": "Note : l'import ne fonctionne actuellement que pour les ami⋅es et les groupes, mais pas pour les transactions. On y travaille !" + "no_file_chosen": "Aucun fichier choisi" }, "logout": "Déconnexion", "messages": { diff --git a/public/locales/it/common.json b/public/locales/it/common.json index 2ac6ac31..bce778c3 100644 --- a/public/locales/it/common.json +++ b/public/locales/it/common.json @@ -30,8 +30,7 @@ "messages": { "import_success": "Importazione riuscita" }, - "no_file_chosen": "Nessun file scelto", - "note": "Nota: al momento supporta solo l'importazione di amici e gruppi. Non importerà transazioni. Ci stiamo lavorando." + "no_file_chosen": "Nessun file scelto" }, "logout": "Esci", "messages": { diff --git a/public/locales/pl/common.json b/public/locales/pl/common.json index 906df4dc..82574b2c 100644 --- a/public/locales/pl/common.json +++ b/public/locales/pl/common.json @@ -30,8 +30,7 @@ "messages": { "import_success": "Import zakończony pomyślnie" }, - "no_file_chosen": "Nie wybrano pliku", - "note": "Uwaga: Obecnie obsługuje tylko importowanie znajomych i grup. Nie importuje transakcji. Pracujemy nad tym." + "no_file_chosen": "Nie wybrano pliku" }, "logout": "Wyloguj", "messages": { diff --git a/public/locales/pt-BR/common.json b/public/locales/pt-BR/common.json index b53bbbf9..92bb6480 100644 --- a/public/locales/pt-BR/common.json +++ b/public/locales/pt-BR/common.json @@ -30,8 +30,7 @@ "messages": { "import_success": "Importação realizada" }, - "no_file_chosen": "Nenhum arquivo selecionado", - "note": "Aviso: Atualmente só é possível importar amigos e grupos. Transações não serão importadas. Estamos trabalhando nisso." + "no_file_chosen": "Nenhum arquivo selecionado" }, "logout": "Fazer logout", "messages": { diff --git a/public/locales/pt-PT/common.json b/public/locales/pt-PT/common.json index ebb438fe..a210b3a2 100644 --- a/public/locales/pt-PT/common.json +++ b/public/locales/pt-PT/common.json @@ -30,8 +30,7 @@ "messages": { "import_success": "Importar com sucesso" }, - "no_file_chosen": "Ficheiro não selecionado", - "note": "Nota: Ainda só temos suporte a importar amigos e grupos. A transações não serão importadas. Estamos a trabalhar nisso." + "no_file_chosen": "Ficheiro não selecionado" }, "logout": "Terminar Sessão", "messages": { diff --git a/public/locales/sv/common.json b/public/locales/sv/common.json index d40db100..ad29a318 100644 --- a/public/locales/sv/common.json +++ b/public/locales/sv/common.json @@ -30,8 +30,7 @@ "messages": { "import_success": "Import lyckades" }, - "no_file_chosen": "Ingen fil vald", - "note": "Notera: Vi stödjer för närvarande endast import av vänner och grupper. Transaktioner kommer inte importeras. Vi arbetar på det." + "no_file_chosen": "Ingen fil vald" }, "logout": "Logga ut", "messages": { diff --git a/src/pages/api/trpc/[trpc].ts b/src/pages/api/trpc/[trpc].ts index 62bca418..6c5a868b 100644 --- a/src/pages/api/trpc/[trpc].ts +++ b/src/pages/api/trpc/[trpc].ts @@ -15,3 +15,12 @@ export default createNextApiHandler({ } : undefined, }); + +export const config = { + api: { + bodyParser: { + sizeLimit: '20mb', + }, + responseLimit: '20mb', + }, +}; diff --git a/src/pages/import-splitwise.tsx b/src/pages/import-splitwise.tsx index 9715a702..e6e82512 100644 --- a/src/pages/import-splitwise.tsx +++ b/src/pages/import-splitwise.tsx @@ -3,7 +3,7 @@ import { DownloadCloud } from 'lucide-react'; import Head from 'next/head'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { toast } from 'sonner'; import { useTranslation } from 'next-i18next'; import MainLayout from '~/components/Layout/MainLayout'; @@ -12,16 +12,24 @@ import { Checkbox } from '~/components/ui/checkbox'; import { Input } from '~/components/ui/input'; import { Separator } from '~/components/ui/separator'; import { LoadingSpinner } from '~/components/ui/spinner'; -import { type NextPageWithUser, type SplitwiseGroup, type SplitwiseUser } from '~/types'; +import { + type NextPageWithUser, + type SplitwiseExpense, + type SplitwiseGroup, + type SplitwiseUserWithBalance, +} from '~/types'; import { api } from '~/utils/api'; import { withI18nStaticProps } from '~/utils/i18n/server'; +import { set } from 'date-fns'; const ImportSpliwisePage: NextPageWithUser = () => { const { t } = useTranslation(); - const [usersWithBalance, setUsersWithBalance] = useState([]); + const [usersWithBalance, setUsersWithBalance] = useState([]); const [groups, setGroups] = useState([]); + const [expenses, setExpenses] = useState([]); const [selectedUsers, setSelectedUsers] = useState>({}); const [selectedGroups, setSelectedGroups] = useState>({}); + const [selectedExpenses, setSelectedExpenses] = useState>({}); const [uploadedFile, setUploadedFile] = useState(null); const router = useRouter(); @@ -39,11 +47,11 @@ const ImportSpliwisePage: NextPageWithUser = () => { try { const json = JSON.parse(await file.text()) as Record; - const friendsWithOutStandingBalance: SplitwiseUser[] = []; + const friendsWithOutStandingBalance: SplitwiseUserWithBalance[] = []; for (const friend of json.friends as Record[]) { const balance = friend.balance as { currency_code: string; amount: string }[]; if (balance.length && 'confirmed' === friend.registration_status) { - friendsWithOutStandingBalance.push(friend as unknown as SplitwiseUser); + friendsWithOutStandingBalance.push(friend as unknown as SplitwiseUserWithBalance); } } @@ -72,12 +80,39 @@ const ImportSpliwisePage: NextPageWithUser = () => { {} as Record, ), ); + + const _expenses = (json.expenses as SplitwiseExpense[]).filter((e) => !e.deleted_at); + + setExpenses(_expenses); } catch (e) { console.error(e); toast.error(t('errors.import_failed')); } }; + useEffect(() => { + if (!expenses || !expenses.length) { + setSelectedExpenses({}); + return; + } + + setSelectedExpenses( + expenses.reduce( + (acc, expense) => { + const isUserSelected = expense.users.some((user) => selectedUsers[user.user_id]); + const isGroupSelected = expense.group_id ? selectedGroups[expense.group_id] : false; + + if (isUserSelected || isGroupSelected) { + acc[expense.id] = true; + } + + return acc; + }, + {} as Record, + ), + ); + }, [expenses, selectedUsers, selectedGroups]); + const importMutation = api.user.importUsersFromSplitWise.useMutation(); function onImport() { @@ -85,6 +120,7 @@ const ImportSpliwisePage: NextPageWithUser = () => { { usersWithBalance: usersWithBalance.filter((user) => selectedUsers[user.id]), groups: groups.filter((group) => selectedGroups[group.id]), + expenses: expenses.filter((expense) => selectedExpenses[expense.id]), }, { onSuccess: () => { @@ -155,9 +191,6 @@ const ImportSpliwisePage: NextPageWithUser = () => { {importMutation.isPending ? : t('actions.import')} -
- {t('account.import_from_splitwise_details.note')} -
{uploadedFile ? ( <> @@ -232,6 +265,18 @@ const ImportSpliwisePage: NextPageWithUser = () => { ))} ) : null} +
+
+
+
+

{t('account.import_from_splitwise_details.selected_expenses')}

+
+
+
+ {Object.keys(selectedExpenses).length} +
+
+
) : (
diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index ac497ed9..95be59c3 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -6,12 +6,17 @@ import { env } from '~/env'; import { createTRPCRouter, protectedProcedure } from '~/server/api/trpc'; import { db } from '~/server/db'; import { sendFeedbackEmail, sendInviteEmail } from '~/server/mailer'; -import { SplitwiseGroupSchema, SplitwiseUserSchema } from '~/types'; +import { + SplitwiseExpenseSchema, + SplitwiseGroupSchema, + SplitwiseUserWithBalanceSchema, +} from '~/types'; // import { sendExpensePushNotification } from '../services/notificationService'; import { getCompleteFriendsDetails, getCompleteGroupDetails, + importExpenseFromSplitwise, importGroupFromSplitwise, importUserBalanceFromSplitWise, } from '../services/splitService'; @@ -239,13 +244,20 @@ export const userRouter = createTRPCRouter({ importUsersFromSplitWise: protectedProcedure .input( z.object({ - usersWithBalance: z.array(SplitwiseUserSchema), + usersWithBalance: z.array(SplitwiseUserWithBalanceSchema), groups: z.array(SplitwiseGroupSchema), + expenses: z.array(SplitwiseExpenseSchema), }), ) .mutation(async ({ input, ctx }) => { await importUserBalanceFromSplitWise(ctx.session.user.id, input.usersWithBalance); await importGroupFromSplitwise(ctx.session.user.id, input.groups); + await importExpenseFromSplitwise( + ctx.session.user.id, + input.expenses, + input.usersWithBalance, + input.groups, + ); }), getWebPushPublicKey: protectedProcedure.query(() => env.WEB_PUSH_PUBLIC_KEY ?? ''), diff --git a/src/server/api/services/splitService.ts b/src/server/api/services/splitService.ts index 4e0fbc3a..7a9f3103 100644 --- a/src/server/api/services/splitService.ts +++ b/src/server/api/services/splitService.ts @@ -2,12 +2,18 @@ import { type User } from '@prisma/client'; import { nanoid } from 'nanoid'; import { db } from '~/server/db'; -import { type SplitwiseGroup, type SplitwiseUser } from '~/types'; +import { + SplitwiseCategoryMap, + type SplitwiseExpense, + type SplitwiseGroup, + type SplitwiseUser, + type SplitwiseUserWithBalance, +} from '~/types'; +import { isCurrencyCode } from '~/lib/currency'; import type { CreateExpense } from '~/types/expense.types'; -import { sendExpensePushNotification } from './notificationService'; import { getCurrencyHelpers } from '~/utils/numbers'; -import { isCurrencyCode } from '~/lib/currency'; +import { sendExpensePushNotification } from './notificationService'; export async function joinGroup(userId: number, publicGroupId: string) { const group = await db.group.findUnique({ @@ -741,6 +747,17 @@ export async function getCompleteGroupDetails(userId: number) { return groups; } +export async function getCompleteExpenseDetails(userId: number) { + const expenses = await db.expense.findMany({ + where: {}, + include: { + expenseParticipants: true, + }, + }); + + return expenses; +} + export async function recalculateGroupBalances(groupId: number) { const groupExpenses = await db.expense.findMany({ where: { @@ -826,7 +843,7 @@ export async function recalculateGroupBalances(groupId: number) { export async function importUserBalanceFromSplitWise( currentUserId: number, - splitWiseUsers: SplitwiseUser[], + splitWiseUsers: SplitwiseUserWithBalance[], ) { const operations = []; @@ -1029,3 +1046,121 @@ export async function importGroupFromSplitwise( await db.$transaction(operations); } + +export async function importExpenseFromSplitwise( + currentUserId: number, + splitwiseExpenses: SplitwiseExpense[], + splitwiseUsers: SplitwiseUser[], + splitwiseGroups: SplitwiseGroup[], +) { + const splitwiseUserMap: Record = {}; + + for (const user of splitwiseUsers) { + splitwiseUserMap[user.id.toString()] = user; + } + + for (const user of splitwiseGroups.flatMap((g) => g.members)) { + splitwiseUserMap[user.id.toString()] = user; + } + + const users = await createUsersFromSplitwise(Object.values(splitwiseUserMap)); + + const userMap = users.reduce( + (acc, user) => { + if (user.email) { + acc[user.email] = user; + } + return acc; + }, + {} as Record, + ); + + const operations = []; + const currencyHelperCache: Record['toSafeBigInt']> = + {}; + + const splitwiseGroupIds = [ + ...new Set(splitwiseExpenses.map((e) => e.group_id?.toString()).filter(Boolean) as string[]), + ]; + + const dbGroups = await db.group.findMany({ + where: { + splitwiseGroupId: { + in: splitwiseGroupIds, + }, + }, + select: { + id: true, + splitwiseGroupId: true, + }, + }); + + const dbGroupMap = dbGroups.reduce( + (acc, group) => { + if (group.splitwiseGroupId) { + acc[group.splitwiseGroupId] = group.id; + } + return acc; + }, + {} as Record, + ); + + for (const expense of splitwiseExpenses) { + const currency = isCurrencyCode(expense.currency_code) ? expense.currency_code : 'USD'; + + if (!currencyHelperCache[currency]) { + currencyHelperCache[currency] = getCurrencyHelpers({ currency }).toSafeBigInt; + } + const currencyHelper = currencyHelperCache[currency]; + + const payer = expense.users.find((u) => parseFloat(u.paid_share) > 0); + if (!payer) { + // oxlint-disable-next-line no-continue + continue; + } + + const email = splitwiseUserMap[payer.user_id.toString()]?.email; + const paidBy = email ? userMap[email]?.id : undefined; + if (!paidBy) { + // oxlint-disable-next-line no-continue + continue; + } + + const participants = expense.users + .map((user) => { + const splitwiseUser = splitwiseUserMap[user.user_id.toString()]; + const userId = splitwiseUser ? userMap[splitwiseUser.email]?.id : undefined; + const amount = currencyHelper(user.owed_share); + return { userId, amount }; + }) + .filter((p) => p.userId && p.amount !== 0n) as { userId: number; amount: bigint }[]; + + const totalAmount = currencyHelper(expense.cost); + const dbGroupId = expense.group_id ? dbGroupMap[expense.group_id.toString()] : undefined; + + const splitwiseCreator = splitwiseUserMap[expense.created_by.id.toString()]; + const addedBy = + (splitwiseCreator ? userMap[splitwiseCreator.email]?.id : undefined) ?? currentUserId; + + operations.push( + db.expense.create({ + data: { + name: expense.description, + amount: totalAmount, + category: SplitwiseCategoryMap[expense.category.id] ?? 'general', + currency, + paidBy, + groupId: dbGroupId, + splitType: expense.payment ? 'SETTLEMENT' : 'EXACT', + addedBy, + expenseDate: new Date(expense.date), + expenseParticipants: { + create: participants, + }, + }, + }), + ); + } + + await db.$transaction(operations); +} diff --git a/src/types.ts b/src/types.ts index 3e3f82b3..f0246e43 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,14 +26,43 @@ export interface SplitwiseUser { first_name: string; last_name: string | null; email: string; - balance: SplitwiseBalance[]; picture: SplitwisePicture; } +export type SplitwiseUserWithBalance = SplitwiseUser & { + balance: SplitwiseBalance[]; +}; + export interface SplitwiseGroup { id: number; name: string; - members: SplitwiseUser[]; + members: SplitwiseUserWithBalance[]; +} + +export interface SplitWiseCategory { + id: number; + name: string; +} + +export interface SplitwiseExpenseUser { + user_id: number; + paid_share: string; + owed_share: string; + net_balance: string; +} + +export interface SplitwiseExpense { + id: number; + group_id: number | null; + description: string; + payment: boolean; + cost: string; + currency_code: string; + date: string; + category: SplitWiseCategory; + users: SplitwiseExpenseUser[]; + created_by: Omit; + deleted_at: string | null; } export interface TransactionAddInputModel { @@ -61,12 +90,100 @@ export const SplitwiseUserSchema = z.object({ first_name: z.string(), last_name: z.string().nullable(), email: z.string().email(), - balance: z.array(SplitwiseBalanceSchema), picture: SplitwisePictureSchema, }); +export const SplitwiseUserWithBalanceSchema = SplitwiseUserSchema.extend({ + balance: z.array(SplitwiseBalanceSchema), +}); + export const SplitwiseGroupSchema = z.object({ id: z.number(), name: z.string(), - members: z.array(SplitwiseUserSchema), + members: z.array(SplitwiseUserWithBalanceSchema), +}); + +export const SplitwiseCategorySchema = z.object({ + id: z.number(), + name: z.string(), }); + +export const SplitwiseExpenseUserSchema = z.object({ + user_id: z.number(), + paid_share: z.string(), + owed_share: z.string(), + net_balance: z.string(), +}); + +export const SplitwiseExpenseSchema = z.object({ + id: z.number(), + group_id: z.number().nullable(), + description: z.string(), + payment: z.boolean(), + cost: z.string(), + currency_code: z.string(), + date: z.string(), + category: SplitwiseCategorySchema, + users: z.array(SplitwiseExpenseUserSchema), + created_by: SplitwiseUserSchema.omit({ picture: true, email: true }), + deleted_at: z.string().nullable(), +}); + +export const SplitwiseCategoryMap: Record = { + // entertainment + 19: 'entertainment', + 20: 'games', + 21: 'movies', + 22: 'music', + 24: 'sports', + 23: 'entertainment', // other + // food + 25: 'food', + 13: 'diningOut', + 12: 'groceries', + 38: 'liquor', + 26: 'food', // other + // home + 27: 'home', + 39: 'electronics', + 16: 'furniture', + 14: 'supplies', + 17: 'maintenance', + 4: 'mortgage', + 29: 'pets', + 3: 'rent', + 30: 'services', + 28: 'home', // other + // life + 40: 'life', + 50: 'childcare', + 41: 'clothing', + 49: 'education', + 42: 'gifts', + 10: 'insurance', + 43: 'medical', + 45: 'taxes', + 44: 'life', // other + // travel + 31: 'travel', + 46: 'bicycle', + 32: 'bus', // bus/train + 15: 'car', + 33: 'fuel', + 47: 'hotel', + 9: 'parking', + 35: 'plane', + 36: 'taxi', + 34: 'travel', // other + // utilities + 1: 'utilities', + 48: 'cleaning', + 5: 'electricity', + 6: 'gas', + 8: 'phone', // phone/tv/internet + 37: 'trash', + 7: 'water', + 11: 'utilities', // other + // general + 18: 'general', +};