diff --git a/eslint.config.mjs b/eslint.config.mjs
index f7768c06c..87f29108c 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -10,6 +10,8 @@ const eslintConfig = defineConfig([
'out/**',
'build/**',
'next-env.d.ts',
+ 'postgres-data/**',
+ 'node_modules/**',
]),
{
rules: {
diff --git a/messages/de-DE.json b/messages/de-DE.json
index 9dace19fb..e091a033b 100644
--- a/messages/de-DE.json
+++ b/messages/de-DE.json
@@ -52,6 +52,15 @@
"myGroups": "Meine Gruppen",
"create": "Erstellen",
"loadingRecent": "Lade letzte Gruppen…",
+ "CreateOptions": {
+ "openMenu": "Erstelloptionen öffnen",
+ "newGroup": "Neue Gruppe",
+ "importFromFile": "Aus Datei importieren"
+ },
+ "ImportDialog": {
+ "successTitle": "Gruppe erstellt",
+ "successDescription": "Gruppe {name} wurde aus dem Import erstellt."
+ },
"NoRecent": {
"description": "Du hast in der letzten Zeit keine Gruppe besucht.",
"create": "Erstelle eine",
@@ -456,4 +465,47 @@
"heading": "Geläufigste"
}
}
+ ,
+ "FileImport": {
+ "title": "Gruppe aus Datei importieren",
+ "buttonLabel": "Import-Dialog öffnen",
+ "errorTitle": "Fehler",
+ "processing": "Verarbeite…",
+ "fileLabel": "Datei auswählen",
+ "uploadDragTitle": "Datei hier ablegen oder klicken, um auszuwählen",
+ "uploadDragDescription": "Unterstützt: JSON (Spliit-JSON) und Debug-Dateien",
+ "fileReadError": "Die ausgewählte Datei konnte nicht gelesen werden.",
+ "newGroupNameLabel": "Name der neuen Gruppe",
+ "newGroupNamePlaceholder": "Gruppennamen eingeben (optional)",
+ "analysisAwaiting": "Warte auf Datei-Analyse…",
+ "analysisExplanation": "Wähle eine Datei, um sie vor dem Import zu analysieren.",
+ "previewErrorTitle": "Die Datei konnte nicht analysiert werden",
+ "generalInfoTitle": "Allgemeine Informationen",
+ "generalInfoFormat": "Format",
+ "generalInfoRows": "Zeilen",
+ "generalInfoInvalidRows": "Ungültige Zeilen",
+ "generalInfoTotal": "Gesamtbetrag",
+ "generalInfoParticipants": "Teilnehmer und Salden",
+ "generalInfoParticipantsEmpty": "Keine Teilnehmerinformationen erkannt.",
+ "generalInfoUnknown": "Unbekannt",
+ "analysisHeaderTitle": "Probleme im Header",
+ "analysisGeneralWarningsTitle": "Allgemeine Warnungen",
+ "analysisCategoryTitle": "Kategorie-Zuordnungswarnungen",
+ "analysisErrorsTitle": "Zeilenfehler",
+ "analysisFatalHint": "Behebe diese Fehler, um den Import zu aktivieren.",
+ "import": "Importieren",
+ "importing": "Importiere…",
+ "importProgressLabel": "Importiere Ausgaben",
+ "importCancel": "Import abbrechen",
+ "importCanceling": "Breche ab…",
+ "importResultCompleted": "{created} von {total} Zeilen importiert",
+ "importResultCancelledSimple": "Import abgebrochen",
+ "importResultConfirm": "Weiter"
+ },
+ "FileImportErrors": {
+ "noParticipants": "Keine Teilnehmer in der Datei definiert.",
+ "fileEmpty": "Die hochgeladene Datei war leer.",
+ "invalidAmount": "Ungültiger Betrag in einer oder mehreren Zeilen.",
+ "invalidDate": "Ungültiges Ausgabedatum in einer oder mehreren Zeilen."
+ }
}
diff --git a/messages/en-US.json b/messages/en-US.json
index 10f5b7464..3eb7f4355 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -52,6 +52,15 @@
"myGroups": "My groups",
"create": "Create",
"loadingRecent": "Loading recent groups…",
+ "CreateOptions": {
+ "openMenu": "Open create options",
+ "newGroup": "New group",
+ "importFromFile": "Import from file"
+ },
+ "ImportDialog": {
+ "successTitle": "Group created",
+ "successDescription": "Group {name} was created from import."
+ },
"NoRecent": {
"description": "You have not visited any group recently.",
"create": "Create one",
@@ -449,4 +458,47 @@
"heading": "Other currencies"
}
}
+ ,
+ "FileImport": {
+ "title": "Import group from file",
+ "buttonLabel": "Open import dialog",
+ "errorTitle": "Error",
+ "processing": "Processing…",
+ "fileLabel": "Select a file",
+ "uploadDragTitle": "Drop a file here or click to select",
+ "uploadDragDescription": "Supported: JSON (Spliit-JSON) and debug files",
+ "fileReadError": "Could not read the selected file.",
+ "newGroupNameLabel": "New group name",
+ "newGroupNamePlaceholder": "Enter a group name (optional)",
+ "analysisAwaiting": "Awaiting file analysis…",
+ "analysisExplanation": "Select a file to analyze its content before importing.",
+ "previewErrorTitle": "We couldn't analyze your file",
+ "generalInfoTitle": "General information",
+ "generalInfoFormat": "Format",
+ "generalInfoRows": "Rows",
+ "generalInfoInvalidRows": "Invalid rows",
+ "generalInfoTotal": "Total amount",
+ "generalInfoParticipants": "Participants and balances",
+ "generalInfoParticipantsEmpty": "No participant information detected.",
+ "generalInfoUnknown": "Unknown",
+ "analysisHeaderTitle": "Header issues",
+ "analysisGeneralWarningsTitle": "General warnings",
+ "analysisCategoryTitle": "Category mapping warnings",
+ "analysisErrorsTitle": "Row errors",
+ "analysisFatalHint": "Fix these errors to enable import.",
+ "import": "Import",
+ "importing": "Importing…",
+ "importProgressLabel": "Importing expenses",
+ "importCancel": "Cancel import",
+ "importCanceling": "Cancelling…",
+ "importResultCompleted": "Imported {created} of {total} rows",
+ "importResultCancelledSimple": "Import cancelled",
+ "importResultConfirm": "Continue"
+ },
+ "FileImportErrors": {
+ "noParticipants": "No participants defined in file.",
+ "fileEmpty": "The uploaded file was empty.",
+ "invalidAmount": "Invalid amount in one or more rows.",
+ "invalidDate": "Invalid expense date in one or more rows."
+ }
}
diff --git a/messages/es.json b/messages/es.json
index 6b5a36114..9b968a139 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -52,6 +52,15 @@
"myGroups": "Mis grupos",
"create": "Crear",
"loadingRecent": "Cargando grupos recientes…",
+ "CreateOptions": {
+ "openMenu": "Abrir opciones de creación",
+ "newGroup": "Nuevo grupo",
+ "importFromFile": "Importar desde archivo"
+ },
+ "ImportDialog": {
+ "successTitle": "Grupo creado",
+ "successDescription": "El grupo {name} se creó a partir de la importación."
+ },
"NoRecent": {
"description": "No has visitado ningun grupo recientemente.",
"create": "Crea uno",
@@ -448,4 +457,47 @@
"heading": "Otras monedas"
}
}
+ ,
+ "FileImport": {
+ "title": "Importar grupo desde archivo",
+ "buttonLabel": "Abrir diálogo de importación",
+ "errorTitle": "Error",
+ "processing": "Procesando…",
+ "fileLabel": "Seleccionar un archivo",
+ "uploadDragTitle": "Suelta un archivo aquí o haz clic para seleccionar",
+ "uploadDragDescription": "Compatible: JSON (Spliit‑JSON) y archivos de depuración",
+ "fileReadError": "No se pudo leer el archivo seleccionado.",
+ "newGroupNameLabel": "Nombre del nuevo grupo",
+ "newGroupNamePlaceholder": "Introduce un nombre de grupo (opcional)",
+ "analysisAwaiting": "Esperando el análisis del archivo…",
+ "analysisExplanation": "Selecciona un archivo para analizar su contenido antes de importar.",
+ "previewErrorTitle": "No pudimos analizar tu archivo",
+ "generalInfoTitle": "Información general",
+ "generalInfoFormat": "Formato",
+ "generalInfoRows": "Filas",
+ "generalInfoInvalidRows": "Filas inválidas",
+ "generalInfoTotal": "Monto total",
+ "generalInfoParticipants": "Participantes y saldos",
+ "generalInfoParticipantsEmpty": "No se detectó información de participantes.",
+ "generalInfoUnknown": "Desconocido",
+ "analysisHeaderTitle": "Problemas en el encabezado",
+ "analysisGeneralWarningsTitle": "Advertencias generales",
+ "analysisCategoryTitle": "Advertencias de categorías",
+ "analysisErrorsTitle": "Errores por fila",
+ "analysisFatalHint": "Corrige estos errores para habilitar la importación.",
+ "import": "Importar",
+ "importing": "Importando…",
+ "importProgressLabel": "Importando gastos",
+ "importCancel": "Cancelar importación",
+ "importCanceling": "Cancelando…",
+ "importResultCompleted": "{created} de {total} filas importadas",
+ "importResultCancelledSimple": "Importación cancelada",
+ "importResultConfirm": "Continuar"
+ },
+ "FileImportErrors": {
+ "noParticipants": "No hay participantes definidos en el archivo.",
+ "fileEmpty": "El archivo subido estaba vacío.",
+ "invalidAmount": "Monto inválido en una o más filas.",
+ "invalidDate": "Fecha de gasto inválida en una o más filas."
+ }
}
diff --git a/messages/fr-FR.json b/messages/fr-FR.json
index 3e69f676b..4fc48c4b1 100644
--- a/messages/fr-FR.json
+++ b/messages/fr-FR.json
@@ -52,6 +52,15 @@
"myGroups": "Mes groupes",
"create": "Créer",
"loadingRecent": "Chargement des groupes récents…",
+ "CreateOptions": {
+ "openMenu": "Ouvrir les options de création",
+ "newGroup": "Nouveau groupe",
+ "importFromFile": "Importer depuis un fichier"
+ },
+ "ImportDialog": {
+ "successTitle": "Groupe créé",
+ "successDescription": "Le groupe {name} a été créé à partir de l'import."
+ },
"NoRecent": {
"description": "Vous n'avez visité aucun groupe récemment.",
"create": "Créer un groupe",
@@ -456,4 +465,47 @@
"heading": "Autres devises"
}
}
+ ,
+ "FileImport": {
+ "title": "Importer un groupe depuis un fichier",
+ "buttonLabel": "Ouvrir la boîte d'import",
+ "errorTitle": "Erreur",
+ "processing": "Traitement…",
+ "fileLabel": "Sélectionner un fichier",
+ "uploadDragTitle": "Déposez un fichier ici ou cliquez pour sélectionner",
+ "uploadDragDescription": "Pris en charge : JSON (Spliit‑JSON) et fichiers de débogage",
+ "fileReadError": "Impossible de lire le fichier sélectionné.",
+ "newGroupNameLabel": "Nom du nouveau groupe",
+ "newGroupNamePlaceholder": "Entrez un nom de groupe (optionnel)",
+ "analysisAwaiting": "En attente de l'analyse du fichier…",
+ "analysisExplanation": "Sélectionnez un fichier pour analyser son contenu avant l'import.",
+ "previewErrorTitle": "Nous n'avons pas pu analyser votre fichier",
+ "generalInfoTitle": "Informations générales",
+ "generalInfoFormat": "Format",
+ "generalInfoRows": "Lignes",
+ "generalInfoInvalidRows": "Lignes invalides",
+ "generalInfoTotal": "Montant total",
+ "generalInfoParticipants": "Participants et soldes",
+ "generalInfoParticipantsEmpty": "Aucune information de participant détectée.",
+ "generalInfoUnknown": "Inconnu",
+ "analysisHeaderTitle": "Problèmes d'en‑tête",
+ "analysisGeneralWarningsTitle": "Avertissements généraux",
+ "analysisCategoryTitle": "Avertissements de catégorisation",
+ "analysisErrorsTitle": "Erreurs de lignes",
+ "analysisFatalHint": "Corrigez ces erreurs pour activer l'import.",
+ "import": "Importer",
+ "importing": "Importation…",
+ "importProgressLabel": "Import des dépenses",
+ "importCancel": "Annuler l'import",
+ "importCanceling": "Annulation…",
+ "importResultCompleted": "{created} sur {total} lignes importées",
+ "importResultCancelledSimple": "Import annulé",
+ "importResultConfirm": "Continuer"
+ },
+ "FileImportErrors": {
+ "noParticipants": "Aucun participant défini dans le fichier.",
+ "fileEmpty": "Le fichier téléchargé était vide.",
+ "invalidAmount": "Montant invalide dans une ou plusieurs lignes.",
+ "invalidDate": "Date de dépense invalide dans une ou plusieurs lignes."
+ }
}
diff --git a/package-lock.json b/package-lock.json
index 17f239100..51f339d80 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -764,6 +764,7 @@
"version": "3.622.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.622.0.tgz",
"integrity": "sha512-dwWDfN+S98npeY77Ugyv8VIHKRHN+n/70PWE4EgolcjaMrTINjvUh9a/SypFEs5JmBOAeCQt8S2QpM3Wvzp+pQ==",
+ "peer": true,
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
@@ -1424,6 +1425,7 @@
"version": "3.622.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.622.0.tgz",
"integrity": "sha512-Yqtdf/wn3lcFVS42tR+zbz4HLyWxSmztjVW9L/yeMlvS7uza5nSkWqP/7ca+RxZnXLyrnA4jJtSHqykcErlhyg==",
+ "peer": true,
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
@@ -4007,6 +4009,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.2.tgz",
"integrity": "sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==",
"dev": true,
+ "peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.24.7",
@@ -9837,6 +9840,7 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.59.15.tgz",
"integrity": "sha512-QbVlAkTI78wB4Mqgf2RDmgC0AOiJqer2c5k9STOOSXGv1S6ZkY37r/6UpE8DbQ2Du0ohsdoXgFNEyv+4eDoPEw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@tanstack/query-core": "5.59.13"
},
@@ -9853,6 +9857,7 @@
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
"integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==",
"dev": true,
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -9958,6 +9963,7 @@
"https://trpc.io/sponsor"
],
"license": "MIT",
+ "peer": true,
"peerDependencies": {
"@trpc/server": "11.0.0-rc.586+3388c9691"
}
@@ -9985,7 +9991,8 @@
"funding": [
"https://trpc.io/sponsor"
],
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/@tsconfig/node10": {
"version": "1.0.11",
@@ -10193,6 +10200,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.9.tgz",
"integrity": "sha512-UzykFsT3FhHb1h7yD4CA4YhBHq545JC0YnEz41xkipN88eKQtL6rSgocL5tbAP6Ola9Izm/Aw4Ora8He4x0BHg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"undici-types": "~5.26.4"
}
@@ -10291,6 +10299,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.48.tgz",
"integrity": "sha512-qboRCl6Ie70DQQG9hhNREz81jqC1cs9EVNcjQ1AU+jH6NFfSAhVVbrrY/+nSF+Bsk4AOwm9Qa61InvMCyV+H3w==",
"devOptional": true,
+ "peer": true,
"dependencies": {
"@types/prop-types": "*",
"@types/scheduler": "*",
@@ -10302,6 +10311,7 @@
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.18.tgz",
"integrity": "sha512-TJxDm6OfAX2KJWJdMEVTwWke5Sc/E/RlnPGvGfS0W7+6ocy2xhDVQVh/KvC2Uf7kACs+gDytdusDSdWfWkaNzw==",
"devOptional": true,
+ "peer": true,
"dependencies": {
"@types/react": "*"
}
@@ -10393,6 +10403,7 @@
"integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.48.1",
"@typescript-eslint/types": "8.48.1",
@@ -10923,6 +10934,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -11559,6 +11571,7 @@
"url": "https://github.com/sponsors/ai"
}
],
+ "peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001646",
"electron-to-chromium": "^1.5.4",
@@ -12575,7 +12588,8 @@
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/embla-carousel-react": {
"version": "8.6.0",
@@ -12876,6 +12890,7 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -13100,6 +13115,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -13263,6 +13279,7 @@
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
"dev": true,
"license": "MIT",
+ "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -15836,6 +15853,7 @@
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.20.0.tgz",
"integrity": "sha512-3TV69ZbrvV6U5DfQimop50jE9Dl6J8O1ja1dvBbMba/sZ3YBEQqJ2VZRoQPVnhlzjNtU1vaXRZVrVjU4qtm8yA==",
"license": "MIT",
+ "peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -16380,6 +16398,7 @@
"resolved": "https://registry.npmjs.org/next/-/next-16.0.7.tgz",
"integrity": "sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@next/env": "16.0.7",
"@swc/helpers": "0.5.15",
@@ -16501,17 +16520,6 @@
}
}
},
- "node_modules/next-intl/node_modules/@swc/helpers": {
- "version": "0.5.17",
- "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
- "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==",
- "license": "Apache-2.0",
- "optional": true,
- "peer": true,
- "dependencies": {
- "tslib": "^2.8.0"
- }
- },
"node_modules/next-intl/node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
@@ -17488,6 +17496,7 @@
"version": "8.11.3",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.11.3.tgz",
"integrity": "sha512-+9iuvG8QfaaUrrph+kpF24cXkH1YOOUeArRNYIxq1viYHZagBxrTno7cecY1Fa44tJeZvaoG+Djpkc3JwehN5g==",
+ "peer": true,
"dependencies": {
"buffer-writer": "2.0.0",
"packet-reader": "1.0.0",
@@ -17721,6 +17730,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"nanoid": "^3.3.6",
"picocolors": "^1.0.0",
@@ -17908,6 +17918,7 @@
"integrity": "sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -17977,6 +17988,7 @@
"integrity": "sha512-bXWy3vTk8mnRmT+SLyZBQoC2vtV9Z8u7OHvEu+aULYxwiop/CPiFZ+F56KsNRNf35jw+8wcu8pmLsjxpBxAO9g==",
"hasInstallScript": true,
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@prisma/config": "6.18.0",
"@prisma/engines": "6.18.0"
@@ -18092,6 +18104,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -18101,6 +18114,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz",
"integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -18113,6 +18127,7 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.68.0.tgz",
"integrity": "sha512-oNN3fjrZ/Xo40SWlHf1yCjlMK417JxoSJVUXQjGdvdRCU07NTFei1i1f8ApUAts+IVh14e4EdakeLEA+BEAs/Q==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -19171,6 +19186,7 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.5.tgz",
"integrity": "sha512-5SEZU4J7pxZgSkv7FP1zY8i2TIAOooNZ1e/OGtxIEv6GltpoiXUqWvLy89+a10qYTB1N5Ifkuw9lqQkN9sscvA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@@ -19306,6 +19322,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -19374,6 +19391,7 @@
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"devOptional": true,
+ "peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@@ -19548,6 +19566,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz",
"integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==",
"devOptional": true,
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml
index fbffa92c2..044d57cdb 100644
--- a/prisma/migrations/migration_lock.toml
+++ b/prisma/migrations/migration_lock.toml
@@ -1,3 +1,3 @@
# Please do not edit this file manually
-# It should be added in your version-control system (i.e. Git)
-provider = "postgresql"
\ No newline at end of file
+# It should be added in your version-control system (e.g., Git)
+provider = "postgresql"
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index b20aa0e24..8f515c39d 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -131,3 +131,4 @@ enum ActivityType {
UPDATE_EXPENSE
DELETE_EXPENSE
}
+
diff --git a/src/app/groups/[groupId]/expenses/create-from-receipt-button.tsx b/src/app/groups/[groupId]/expenses/create-from-receipt-button.tsx
index 53a6d72c8..2853482e4 100644
--- a/src/app/groups/[groupId]/expenses/create-from-receipt-button.tsx
+++ b/src/app/groups/[groupId]/expenses/create-from-receipt-button.tsx
@@ -176,7 +176,7 @@ function ReceiptDialogContent() {
{t('Dialog.titleLabel')}
-
{receiptInfo ? receiptInfo.title ?? : '…'}
+
{receiptInfo ? (receiptInfo.title ?? ) : '…'}
{t('Dialog.categoryLabel')}
diff --git a/src/app/groups/[groupId]/expenses/expense-form.tsx b/src/app/groups/[groupId]/expenses/expense-form.tsx
index ebf2c883c..4f3f921bc 100644
--- a/src/app/groups/[groupId]/expenses/expense-form.tsx
+++ b/src/app/groups/[groupId]/expenses/expense-form.tsx
@@ -209,64 +209,64 @@ export function ExpenseForm({
recurrenceRule: expense.recurrenceRule ?? undefined,
}
: searchParams.get('reimbursement')
- ? {
- title: t('reimbursement'),
- expenseDate: new Date(),
- amount: amountAsDecimal(
- Number(searchParams.get('amount')) || 0,
- groupCurrency,
- ),
- originalCurrency: group.currencyCode,
- originalAmount: undefined,
- conversionRate: undefined,
- category: 1, // category with Id 1 is Payment
- paidBy: searchParams.get('from') ?? undefined,
- paidFor: [
- searchParams.get('to')
- ? {
- participant: searchParams.get('to')!,
- shares: '1' as any, // String for consistent form handling
- }
- : undefined,
- ],
- isReimbursement: true,
- splitMode: defaultSplittingOptions.splitMode,
- saveDefaultSplittingOptions: false,
- documents: [],
- notes: '',
- recurrenceRule: RecurrenceRule.NONE,
- }
- : {
- title: searchParams.get('title') ?? '',
- expenseDate: searchParams.get('date')
- ? new Date(searchParams.get('date') as string)
- : new Date(),
- amount: Number(searchParams.get('amount')) || 0,
- originalCurrency: group.currencyCode ?? undefined,
- originalAmount: undefined,
- conversionRate: undefined,
- category: searchParams.get('categoryId')
- ? Number(searchParams.get('categoryId'))
- : 0, // category with Id 0 is General
- // paid for all, split evenly
- paidFor: defaultSplittingOptions.paidFor,
- paidBy: getSelectedPayer(),
- isReimbursement: false,
- splitMode: defaultSplittingOptions.splitMode,
- saveDefaultSplittingOptions: false,
- documents: searchParams.get('imageUrl')
- ? [
- {
- id: randomId(),
- url: searchParams.get('imageUrl') as string,
- width: Number(searchParams.get('imageWidth')),
- height: Number(searchParams.get('imageHeight')),
- },
- ]
- : [],
- notes: '',
- recurrenceRule: RecurrenceRule.NONE,
- },
+ ? {
+ title: t('reimbursement'),
+ expenseDate: new Date(),
+ amount: amountAsDecimal(
+ Number(searchParams.get('amount')) || 0,
+ groupCurrency,
+ ),
+ originalCurrency: group.currencyCode,
+ originalAmount: undefined,
+ conversionRate: undefined,
+ category: 1, // category with Id 1 is Payment
+ paidBy: searchParams.get('from') ?? undefined,
+ paidFor: [
+ searchParams.get('to')
+ ? {
+ participant: searchParams.get('to')!,
+ shares: '1' as any, // String for consistent form handling
+ }
+ : undefined,
+ ],
+ isReimbursement: true,
+ splitMode: defaultSplittingOptions.splitMode,
+ saveDefaultSplittingOptions: false,
+ documents: [],
+ notes: '',
+ recurrenceRule: RecurrenceRule.NONE,
+ }
+ : {
+ title: searchParams.get('title') ?? '',
+ expenseDate: searchParams.get('date')
+ ? new Date(searchParams.get('date') as string)
+ : new Date(),
+ amount: Number(searchParams.get('amount')) || 0,
+ originalCurrency: group.currencyCode ?? undefined,
+ originalAmount: undefined,
+ conversionRate: undefined,
+ category: searchParams.get('categoryId')
+ ? Number(searchParams.get('categoryId'))
+ : 0, // category with Id 0 is General
+ // paid for all, split evenly
+ paidFor: defaultSplittingOptions.paidFor,
+ paidBy: getSelectedPayer(),
+ isReimbursement: false,
+ splitMode: defaultSplittingOptions.splitMode,
+ saveDefaultSplittingOptions: false,
+ documents: searchParams.get('imageUrl')
+ ? [
+ {
+ id: randomId(),
+ url: searchParams.get('imageUrl') as string,
+ width: Number(searchParams.get('imageWidth')),
+ height: Number(searchParams.get('imageHeight')),
+ },
+ ]
+ : [],
+ notes: '',
+ recurrenceRule: RecurrenceRule.NONE,
+ },
})
const [isCategoryLoading, setCategoryLoading] = useState(false)
const activeUserId = useActiveUser(group.id)
@@ -927,12 +927,12 @@ export function ExpenseForm({
'BY_PERCENTAGE'
? Number(shares) * 100 // Convert percentage to basis points (e.g., 50% -> 5000)
: form.watch('splitMode') ===
- 'BY_AMOUNT'
- ? amountAsMinorUnits(
- shares,
- groupCurrency,
- )
- : shares,
+ 'BY_AMOUNT'
+ ? amountAsMinorUnits(
+ shares,
+ groupCurrency,
+ )
+ : shares,
expenseId: '',
participantId: '',
}),
diff --git a/src/app/groups/create/create-group.tsx b/src/app/groups/create/create-group.tsx
index c98e296d4..2753a06c7 100644
--- a/src/app/groups/create/create-group.tsx
+++ b/src/app/groups/create/create-group.tsx
@@ -12,7 +12,7 @@ export const CreateGroup = () => {
return (
{
- const { groupId } = await mutateAsync({ groupFormValues })
+ const { id: groupId } = await mutateAsync({ groupFormValues })
await utils.groups.invalidate()
router.push(`/groups/${groupId}`)
}}
diff --git a/src/app/groups/recent-group-list.tsx b/src/app/groups/recent-group-list.tsx
index 3d6465e67..a7f8dd184 100644
--- a/src/app/groups/recent-group-list.tsx
+++ b/src/app/groups/recent-group-list.tsx
@@ -5,14 +5,23 @@ import {
getArchivedGroups,
getRecentGroups,
getStarredGroups,
+ saveRecentGroup,
} from '@/app/groups/recent-groups-helpers'
+import { FileImportModal } from '@/components/file-import-modal'
import { Button } from '@/components/ui/button'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
import { getGroups } from '@/lib/api'
import { trpc } from '@/trpc/client'
import { AppRouterOutput } from '@/trpc/routers/_app'
-import { Loader2 } from 'lucide-react'
+import { ChevronDown, Loader2 } from 'lucide-react'
import { useTranslations } from 'next-intl'
import Link from 'next/link'
+import { useRouter } from 'next/navigation'
import { PropsWithChildren, useEffect, useState } from 'react'
import { RecentGroupListCard } from './recent-group-list-card'
@@ -222,6 +231,8 @@ function GroupsPage({
reload,
}: PropsWithChildren<{ reload: () => void }>) {
const t = useTranslations('Groups')
+ const router = useRouter()
+ const [importOpen, setImportOpen] = useState(false)
return (
<>
@@ -230,14 +241,39 @@ function GroupsPage({
-
+
+
+
+
+
+
+
+ setImportOpen(true)}>
+ {t('CreateOptions.importFromFile')}
+
+
+
+
+ {
+ saveRecentGroup({ id: groupId, name: groupName })
+ reload()
+ router.push(`/groups/${groupId}`)
+ }}
+ />
{children}
>
)
diff --git a/src/components/file-import-modal.tsx b/src/components/file-import-modal.tsx
new file mode 100644
index 000000000..3b306d130
--- /dev/null
+++ b/src/components/file-import-modal.tsx
@@ -0,0 +1,287 @@
+'use client'
+
+// File import modal used from the Groups overview.
+// It provides:
+// - Upload + drag/drop area for a Spliit JSON export
+// - Preview (detected format, totals, warnings)
+// - Scroll-to-confirm gate before import
+// - Chunked import with a progress bar
+// - Cancel deletes the created group and any imported expenses
+// - Finalize returns the new group id/name to the caller for navigation
+
+import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react'
+
+import { ImportAnalysisPanel } from '@/components/import/import-analysis-panel'
+import { UploadDropzone } from '@/components/import/upload-dropzone'
+import { useFileImportProcess } from '@/components/import/use-import-process'
+import { Button } from '@/components/ui/button'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@/components/ui/dialog'
+import { Label } from '@/components/ui/label'
+import { Upload } from 'lucide-react'
+import { useTranslations } from 'next-intl'
+import { Input } from './ui/input'
+
+export function FileImportModal({
+ open: controlledOpen,
+ onOpenChange,
+ hideTrigger,
+ onCreateSuccess,
+}: {
+ open?: boolean
+ onOpenChange?: (open: boolean) => void
+ hideTrigger?: boolean
+ onCreateSuccess?: (result: { groupId: string; groupName: string }) => void
+}) {
+ const t = useTranslations('FileImport')
+
+ const [dialogOpen, setDialogOpen] = useState(controlledOpen ?? false)
+ const setOpen = onOpenChange ?? setDialogOpen
+ const open = controlledOpen ?? dialogOpen
+
+ const {
+ processState,
+ fileName,
+ groupName,
+ setGroupName,
+ previewResult,
+ previewError,
+ importProgress,
+ importResult,
+ resultActionLoading,
+ analyzeFile,
+ startImport,
+ requestCancel,
+ finalizeImport,
+ resetProcess,
+ } = useFileImportProcess({
+ onImportSuccess: onCreateSuccess,
+ onClose: () => setOpen(false),
+ })
+
+ const scrollContainerRef = useRef(null)
+ const [hasReachedBottom, setHasReachedBottom] = useState(false)
+
+ const handleFileRead = (file: File) => {
+ analyzeFile(file)
+ }
+
+ const handleFileChange = (event: ChangeEvent) => {
+ const file = event.target.files?.[0]
+ if (!file) return
+ handleFileRead(file)
+ event.target.value = ''
+ }
+
+ const handleDropSelect = (file: File) => handleFileRead(file)
+
+ const hasFatalErrors = (previewResult?.errors.length ?? 0) > 0
+ const canImport = Boolean(previewResult) && !hasFatalErrors
+ const jobRunning = processState === 'importing'
+ const importLoading =
+ processState === 'analyzing' || processState === 'importing'
+
+ // Reset process when modal closes
+ const prevOpenRef = useRef(open)
+ useEffect(() => {
+ if (prevOpenRef.current && !open) {
+ resetProcess()
+ }
+ prevOpenRef.current = open
+ }, [open, resetProcess])
+
+ const handleScroll = useCallback(() => {
+ const element = scrollContainerRef.current
+ if (!element) return
+ const reachedBottom =
+ element.scrollTop + element.clientHeight >= element.scrollHeight - 8
+ setHasReachedBottom(reachedBottom)
+ }, [])
+
+ useEffect(() => {
+ handleScroll()
+ }, [handleScroll, previewResult, importProgress])
+
+ const renderContent = () => {
+ if (jobRunning) {
+ return (
+
+
+
{t('importProgressLabel')}
+
+ {importProgress.processed}/{importProgress.total}
+
+
+
+
+
+ )
+ }
+
+ if (importResult) {
+ return (
+
+
+
+ {importResult.status === 'completed'
+ ? t('importResultCompleted', {
+ created: importResult.created,
+ total: importResult.total,
+ })
+ : t('importResultCancelledSimple')}
+
+
+
+ {importResult.status === 'completed' && (
+
+ )}
+ {importResult.status === 'cancelled' && (
+
+ )}
+
+
+ )
+ }
+
+ return (
+
+
+
+ {previewResult && (
+
+
+ setGroupName(event.target.value)}
+ />
+
+ )}
+
+
+
+
+
+
+
+ )
+ }
+ return (
+
+ )
+}
diff --git a/src/components/import/import-analysis-panel.tsx b/src/components/import/import-analysis-panel.tsx
new file mode 100644
index 000000000..44825313d
--- /dev/null
+++ b/src/components/import/import-analysis-panel.tsx
@@ -0,0 +1,212 @@
+'use client'
+
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
+import {
+ Currency as CurrencyType,
+ getCurrency as lookupCurrency,
+} from '@/lib/currency'
+import { ImportBuildResult } from '@/lib/imports/file-import'
+import { formatCurrency } from '@/lib/utils'
+import { useLocale, useTranslations } from 'next-intl'
+
+export function ImportAnalysisPanel({
+ previewResult,
+ previewError,
+}: {
+ previewResult: ImportBuildResult | null
+ previewError: string | null
+}) {
+ const t = useTranslations('FileImport')
+ const locale = useLocale()
+
+ const totalRows =
+ (previewResult?.expenses.length ?? 0) + (previewResult?.errors.length ?? 0)
+ const headerErrors: string[] = []
+ const generalWarnings: string[] = []
+ const rowErrors = previewResult?.errors ?? []
+ const categoryWarnings: { row: number; label: string; message: string }[] = []
+ const participantSummaries = previewResult?.participantSummaries ?? []
+
+ let currency: CurrencyType | null = null
+ const currencyCode = previewResult?.group?.currencyCode
+ const currencySymbol = previewResult?.group?.currency
+ if (currencyCode) {
+ try {
+ currency = lookupCurrency(currencyCode)
+ } catch {
+ currency = null
+ }
+ } else if (currencySymbol) {
+ currency = {
+ name: 'Custom',
+ symbol_native: currencySymbol,
+ symbol: currencySymbol,
+ code: '',
+ name_plural: '',
+ rounding: 0,
+ decimal_digits: 2,
+ }
+ }
+
+ const formatSignedAmount = (amount: number) => {
+ if (!currency) return t('generalInfoUnknown')
+ if (amount === 0) return formatCurrency(currency, amount, locale)
+ const formatted = formatCurrency(currency, Math.abs(amount), locale)
+ const sign = amount > 0 ? '+' : '-'
+ return `${sign}${formatted}`
+ }
+
+ const participantList = participantSummaries.map((participant, index) => (
+
+ {participant.name}{' '}
+ = 0 ? 'text-emerald-600' : 'text-destructive'
+ }
+ >
+ ({formatSignedAmount(participant.balance)})
+
+ {index < participantSummaries.length - 1 ? , : null}
+
+ ))
+
+ const totalAmount =
+ previewResult?.expenses.reduce((sum, e) => sum + e.amount, 0) ?? 0
+ const formattedTotalAmount =
+ currency && previewResult
+ ? formatCurrency(currency, totalAmount, locale)
+ : t('generalInfoUnknown')
+
+ return (
+
+ {previewResult ? (
+ <>
+
+
+
{t('generalInfoTitle')}
+
+
+
-
+ {t('generalInfoFormat')}
+
+ -
+ {previewResult.format?.label ?? t('generalInfoUnknown')}
+
+
+ {/* Language not available in simplified import result */}
+
+
-
+ {t('generalInfoRows')}
+
+ - {totalRows}
+
+
+
-
+ {t('generalInfoInvalidRows')}
+
+ - {rowErrors.length}
+
+
+
-
+ {t('generalInfoTotal')}
+
+ -
+ {formattedTotalAmount}
+
+
+
+
+
+
+ {t('generalInfoParticipants')}
+
+ {participantSummaries.length > 0 ? (
+
+ {participantList}
+
+ ) : (
+
+ {t('generalInfoParticipantsEmpty')}
+
+ )}
+
+
+
+ {headerErrors.length > 0 && (
+
+ {t('analysisHeaderTitle')}
+
+
+ {headerErrors.map((error) => (
+ - {error}
+ ))}
+
+
+
+ )}
+
+ {generalWarnings.length > 0 && (
+
+
+ {t('analysisGeneralWarningsTitle')}
+
+
+
+ {generalWarnings.map((warning, index) => (
+ - {warning}
+ ))}
+
+
+
+ )}
+
+ {categoryWarnings.length > 0 && (
+
+ {t('analysisCategoryTitle')}
+
+
+ {categoryWarnings.map((warning) => (
+ -
+ {warning.label}: {warning.message}
+
+ ))}
+
+
+
+ )}
+
+ {rowErrors.length > 0 && (
+
+
+ {t('analysisErrorsTitle')}
+
+
+
+ {rowErrors.map((error) => (
+ -
+ #{error.row}: {error.message}
+
+ ))}
+
+
+ {t('analysisFatalHint')}
+
+
+
+ )}
+ >
+ ) : (
+
+
+ {previewError ? t('previewErrorTitle') : t('analysisAwaiting')}
+
+
+
+ {previewError ? previewError : t('analysisExplanation')}
+
+
+
+ )}
+
+ )
+}
diff --git a/src/components/import/import-progress-view.tsx b/src/components/import/import-progress-view.tsx
new file mode 100644
index 000000000..676110028
--- /dev/null
+++ b/src/components/import/import-progress-view.tsx
@@ -0,0 +1,50 @@
+'use client'
+
+import { Button } from '@/components/ui/button'
+
+export function ImportProgressView({
+ label,
+ importingText,
+ processed,
+ total,
+ onCancel,
+ cancelLabel,
+ cancelling,
+}: {
+ label: string
+ importingText: string
+ processed: number
+ total: number
+ onCancel: () => void
+ cancelLabel: string
+ cancelling: boolean
+}) {
+ const percent = Math.min(100, (processed / Math.max(total, 1)) * 100)
+ return (
+
+
+
{label}
+
+ {processed}/{total}
+
+
+
+
+
+ )
+}
diff --git a/src/components/import/import-result-view.tsx b/src/components/import/import-result-view.tsx
new file mode 100644
index 000000000..6c0c4356c
--- /dev/null
+++ b/src/components/import/import-result-view.tsx
@@ -0,0 +1,37 @@
+'use client'
+
+import { Button } from '@/components/ui/button'
+
+export type ImportResultStatus = 'completed' | 'cancelled'
+
+export function ImportResultView({
+ status,
+ title,
+ confirmLabel,
+ onConfirm,
+ confirmDisabled,
+}: {
+ status: ImportResultStatus
+ title: string
+ confirmLabel: string
+ onConfirm: () => void
+ confirmDisabled?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/import/upload-dropzone.tsx b/src/components/import/upload-dropzone.tsx
new file mode 100644
index 000000000..2c7e1a25e
--- /dev/null
+++ b/src/components/import/upload-dropzone.tsx
@@ -0,0 +1,72 @@
+'use client'
+
+import { Label } from '@/components/ui/label'
+import { Upload } from 'lucide-react'
+import { useRef, useState } from 'react'
+
+export function UploadDropzone({
+ inputId,
+ label,
+ title,
+ description,
+ accept = '.csv,.json,text/csv,application/json',
+ onSelect,
+}: {
+ inputId: string
+ label: string
+ title: string
+ description?: string
+ accept?: string
+ onSelect: (file: File) => void
+}) {
+ const inputRef = useRef(null)
+ const [isDragging, setIsDragging] = useState(false)
+
+ const handleFileChange: React.ChangeEventHandler = (e) => {
+ const file = e.target.files?.[0]
+ if (file) onSelect(file)
+ e.currentTarget.value = ''
+ }
+
+ return (
+
+
+
+
inputRef.current?.click()}
+ onDragOver={(e) => {
+ e.preventDefault()
+ setIsDragging(true)
+ }}
+ onDragLeave={(e) => {
+ e.preventDefault()
+ setIsDragging(false)
+ }}
+ onDrop={(e) => {
+ e.preventDefault()
+ setIsDragging(false)
+ const file = e.dataTransfer.files?.[0]
+ if (file) onSelect(file)
+ }}
+ className={`flex cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-6 text-center transition ${
+ isDragging
+ ? 'border-primary bg-primary/10'
+ : 'border-muted bg-muted/20'
+ }`}
+ >
+
+
{title}
+ {description ? (
+
{description}
+ ) : null}
+
+
+ )
+}
diff --git a/src/components/import/use-import-process.ts b/src/components/import/use-import-process.ts
new file mode 100644
index 000000000..aa7575cab
--- /dev/null
+++ b/src/components/import/use-import-process.ts
@@ -0,0 +1,319 @@
+'use client'
+
+import { useCallback, useRef, useState } from 'react'
+
+import { useToast } from '@/components/ui/use-toast'
+import {
+ buildExpensesFromFileImport,
+ type ImportBuildResult,
+} from '@/lib/imports/file-import'
+import { ExpenseFormValues } from '@/lib/schemas'
+import { trpc } from '@/trpc/client'
+import { useTranslations } from 'next-intl'
+
+// Minimal shape of the result state used by the UI.
+type ImportResultState = null | {
+ status: 'completed' | 'cancelled'
+ created: number
+ total: number
+ resultId: string
+ groupId?: string
+ groupName?: string
+}
+
+export type FileImportProcessState =
+ | 'idle'
+ | 'analyzing'
+ | 'preview'
+ | 'importing'
+ | 'completed'
+ | 'cancelled'
+ | 'error'
+
+const BATCH_SIZE = 50
+const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
+
+export function useFileImportProcess(options?: {
+ onImportSuccess?: (result: { groupId: string; groupName: string }) => void
+ onClose?: () => void
+}) {
+ const t = useTranslations('FileImport')
+ const tErrors = useTranslations('FileImportErrors')
+ const { toast } = useToast()
+
+ const [processState, setProcessState] =
+ useState('idle')
+ const [fileContent, setFileContent] = useState('')
+ const [fileName, setFileName] = useState(null)
+ const [groupName, setGroupName] = useState('')
+ const [previewResult, setPreviewResult] = useState(
+ null,
+ )
+ const [previewError, setPreviewError] = useState(null)
+
+ // Ref to stop the loop
+ const cancelRequestedRef = useRef(false)
+
+ const [importResult, setImportResult] = useState(null)
+ const [resultActionLoading, setResultActionLoading] = useState(false)
+ const [importProgress, setImportProgress] = useState<{
+ processed: number
+ total: number
+ }>({
+ processed: 0,
+ total: 0,
+ })
+
+ const localizeErrorMessage = useCallback(
+ (message: string) => {
+ const normalized = message.toLowerCase()
+ if (normalized.includes('no participants'))
+ return tErrors('noParticipants')
+ if (normalized.includes('uploaded file was empty'))
+ return tErrors('fileEmpty')
+ if (normalized.includes('invalid amount')) return tErrors('invalidAmount')
+ if (normalized.includes('invalid expense date'))
+ return tErrors('invalidDate')
+ if (normalized.includes('unsupported file format'))
+ return tErrors('unsupportedFormat')
+ return message
+ },
+ [tErrors],
+ )
+
+ const utils = trpc.useUtils()
+
+ // Mutations
+ const createGroupMutation = trpc.groups.create.useMutation()
+ const processBatchMutation = trpc.groups.importProcessBatch.useMutation()
+
+ const analyzeFile = useCallback(
+ async (file: File) => {
+ if (file.size > MAX_FILE_SIZE) {
+ toast({
+ title: t('errorTitle'),
+ description: t('fileTooLarge', { size: '10MB' }),
+ variant: 'destructive',
+ })
+ return
+ }
+
+ setFileName(file.name)
+ setProcessState('analyzing')
+ setPreviewResult(null)
+ setPreviewError(null)
+
+ try {
+ const content = await new Promise((resolve, reject) => {
+ const reader = new FileReader()
+ reader.onload = () => resolve(String(reader.result ?? ''))
+ reader.onerror = () => reject(new Error('Failed to read file'))
+ reader.readAsText(file, 'utf-8')
+ })
+
+ if (!content.trim()) {
+ setPreviewResult(null)
+ setPreviewError(null)
+ setProcessState('idle')
+ return
+ }
+
+ setFileContent(content)
+ const result = await buildExpensesFromFileImport(content)
+ setPreviewResult(result)
+ setProcessState('preview')
+ if (result.group?.name) {
+ setGroupName(result.group.name)
+ }
+ } catch (error) {
+ setPreviewResult(null)
+ const msg = error instanceof Error ? error.message : 'Analysis failed'
+ setPreviewError(localizeErrorMessage(msg))
+ setProcessState('error')
+ }
+ },
+ [localizeErrorMessage, t, toast],
+ )
+
+ const handleStartImport = useCallback(async () => {
+ if (
+ !fileContent ||
+ !previewResult ||
+ previewResult.errors.length > 0 ||
+ processState !== 'preview'
+ )
+ return
+
+ cancelRequestedRef.current = false
+ setProcessState('importing')
+ setImportResult(null)
+
+ const totalExpenses = previewResult.expenses.length
+ setImportProgress({ processed: 0, total: totalExpenses })
+
+ try {
+ // 1. Create Group
+ let importParticipants = previewResult.group?.participants || []
+ if (
+ importParticipants.length === 0 &&
+ previewResult.participants.length > 0
+ ) {
+ importParticipants = previewResult.participants.map((name) => ({
+ id: name,
+ name,
+ }))
+ }
+
+ const finalGroupName =
+ groupName.trim() || previewResult.group?.name || 'Imported Group'
+
+ const createdGroup = await createGroupMutation.mutateAsync({
+ groupFormValues: {
+ name: finalGroupName,
+ currency: previewResult.group?.currency || '$',
+ currencyCode: previewResult.group?.currencyCode,
+ participants: importParticipants.map((p) => ({ name: p.name })),
+ information: `Imported from ${fileName || 'file'}`,
+ },
+ })
+
+ const groupId = createdGroup.id
+ const createdParticipants = createdGroup.participants
+
+ // Create a map of ImportID -> RealID
+ // We map by Name because createGroup creates them in order or we match by name.
+ // Assuming unique names for simplicity in this context.
+ const nameToRealId = new Map()
+ for (const p of createdParticipants) {
+ nameToRealId.set(p.name, p.id)
+ }
+
+ const importIdToRealId = new Map()
+ for (const ip of importParticipants) {
+ const realId = nameToRealId.get(ip.name)
+ if (realId && ip.id) {
+ importIdToRealId.set(ip.id, realId)
+ }
+ }
+
+ // Helper to remap expense
+ const remapExpense = (exp: ExpenseFormValues): ExpenseFormValues => ({
+ ...exp,
+ paidBy:
+ importIdToRealId.get(exp.paidBy) ??
+ nameToRealId.get(exp.paidBy) ??
+ exp.paidBy,
+ paidFor: exp.paidFor.map((pf) => ({
+ ...pf,
+ participant:
+ importIdToRealId.get(pf.participant) ??
+ nameToRealId.get(pf.participant) ??
+ pf.participant,
+ })),
+ })
+
+ // 2. Process Batches
+ const expenses = previewResult.expenses
+ let processedCount = 0
+
+ for (let i = 0; i < expenses.length; i += BATCH_SIZE) {
+ if (cancelRequestedRef.current) break
+
+ const chunk = expenses.slice(i, i + BATCH_SIZE).map(remapExpense)
+
+ await processBatchMutation.mutateAsync({
+ groupId,
+ expenses: chunk,
+ })
+
+ processedCount += chunk.length
+ setImportProgress({ processed: processedCount, total: totalExpenses })
+ }
+
+ if (cancelRequestedRef.current) {
+ // Cancelled
+ setImportResult({
+ status: 'cancelled',
+ created: processedCount,
+ total: totalExpenses,
+ resultId: groupId, // Using groupId as resultId
+ groupId,
+ groupName: finalGroupName,
+ })
+ setProcessState('cancelled')
+ } else {
+ // Completed
+ setImportResult({
+ status: 'completed',
+ created: processedCount,
+ total: totalExpenses,
+ resultId: groupId,
+ groupId,
+ groupName: finalGroupName,
+ })
+ setProcessState('completed')
+ options?.onImportSuccess?.({ groupId, groupName: finalGroupName })
+ }
+ } catch (error) {
+ console.error(error)
+ setProcessState('error')
+ toast({
+ title: t('errorTitle'),
+ description: error instanceof Error ? error.message : 'Unknown error',
+ variant: 'destructive',
+ })
+ }
+ }, [
+ fileContent,
+ fileName,
+ groupName,
+ previewResult,
+ processState,
+ createGroupMutation,
+ processBatchMutation,
+ options,
+ toast,
+ t,
+ ])
+
+ const requestCancel = useCallback(() => {
+ cancelRequestedRef.current = true
+ }, [])
+
+ const resetProcess = useCallback(() => {
+ setProcessState('idle')
+ setFileContent('')
+ setFileName(null)
+ setGroupName('')
+ setPreviewResult(null)
+ setPreviewError(null)
+ cancelRequestedRef.current = false
+ setImportResult(null)
+ setResultActionLoading(false)
+ setImportProgress({ processed: 0, total: 0 })
+ }, [])
+
+ const finalizeImport = useCallback(async () => {
+ // Just close the modal, as the import is already done client-side.
+ options?.onClose?.()
+ }, [options])
+
+ return {
+ processState,
+ fileContent,
+ fileName,
+ groupName,
+ previewResult,
+ previewError,
+ importProgress,
+ importResult,
+ resultActionLoading,
+
+ setGroupName,
+ analyzeFile,
+ startImport: handleStartImport,
+ requestCancel,
+ finalizeImport,
+ resetProcess,
+ }
+}
diff --git a/src/components/money.tsx b/src/components/money.tsx
index e3cac1d4d..4bd0d2ce4 100644
--- a/src/components/money.tsx
+++ b/src/components/money.tsx
@@ -23,8 +23,8 @@ export function Money({
colored && amount <= 1
? 'text-red-600'
: colored && amount >= 1
- ? 'text-green-600'
- : '',
+ ? 'text-green-600'
+ : '',
bold && 'font-bold',
)}
>
diff --git a/src/lib/imports/file-import.ts b/src/lib/imports/file-import.ts
new file mode 100644
index 000000000..e684ae26e
--- /dev/null
+++ b/src/lib/imports/file-import.ts
@@ -0,0 +1,94 @@
+import { getBalances } from '@/lib/balances'
+import { parseWithDetectionInternal } from '@/lib/imports/registry'
+import { ExpenseFormValues } from '@/lib/schemas'
+
+// Return type of the import builder.
+export type ImportParticipantSummary = { name: string; balance: number }
+export type ImportBuildResult = {
+ expenses: ExpenseFormValues[]
+ errors: { row: number; message: string }[]
+ participantSummaries: ImportParticipantSummary[]
+ participants: string[]
+ group?: import('@/lib/imports/types').ImportParsedGroupInfo
+ format: { id: string; label: string }
+}
+
+// Compute net balance per participant from internal expenses for preview purposes.
+//
+// This leverages the existing getBalances() algorithm to ensure consistency with
+// app-wide balance logic (split modes, rounding behavior). We adapt the
+// internal form values into the minimal structure expected by getBalances.
+const buildParticipantSummariesFromInternal = (
+ expenses: ExpenseFormValues[],
+ group?: import('@/lib/imports/types').ImportParsedGroupInfo,
+) => {
+ // Adapt form values to the shape consumed by getBalances.
+ const previewExpenses = expenses.map((e) => ({
+ amount: Math.round(e.amount),
+ splitMode: e.splitMode,
+ isReimbursement: e.isReimbursement,
+ paidBy: { id: e.paidBy },
+ paidFor: e.paidFor.map((p) => ({
+ participant: { id: p.participant },
+ shares: Number(p.shares),
+ })),
+ })) as any
+
+ const balances = getBalances(previewExpenses)
+
+ // Build a mapping from external participant id -> display name if provided by adapter.
+ const idToName = new Map()
+ if (group?.participants) {
+ for (const p of group.participants) {
+ const pid = (p.id ?? '').trim()
+ const pname = p.name?.trim()
+ if (pid && pname) idToName.set(pid, pname)
+ }
+ }
+
+ // Map balances by participant id to {name, balance} entries, prefer nice names.
+ return Object.entries(balances).map(([participantId, { total }]) => ({
+ name: idToName.get(participantId) ?? participantId,
+ balance: total,
+ }))
+}
+
+// Convert a parsed/normalized import file into ExpenseFormValues for a group.
+// Design notes:
+// - Focused on the creation flow.
+// - Omits deduplication and category-mapping heuristics.
+export async function buildExpensesFromFileImport(
+ fileContent: string,
+): Promise {
+ const trimmed = fileContent.trim()
+ if (!trimmed) throw new Error('Uploaded file was empty.')
+
+ const { expenses, errors, group, format } =
+ await parseWithDetectionInternal(trimmed)
+
+ // Derive definitive participant list for group creation.
+ // Prefer participants supplied by the adapter; otherwise derive from expenses.
+ let participantNames: string[] | undefined = group?.participants?.map(
+ (p) => p.name,
+ )
+ if (!participantNames || participantNames.length === 0) {
+ const set = new Set()
+ for (const e of expenses) {
+ set.add(e.paidBy)
+ for (const pf of e.paidFor) set.add(pf.participant)
+ }
+ participantNames = Array.from(set)
+ }
+
+ return {
+ expenses,
+ errors,
+ participantSummaries: buildParticipantSummariesFromInternal(
+ expenses,
+ group,
+ ),
+ participants: participantNames,
+ group,
+ format: { id: format.id, label: format.label },
+ }
+}
diff --git a/src/lib/imports/fixtures/debug-errors.txt b/src/lib/imports/fixtures/debug-errors.txt
new file mode 100644
index 000000000..6ea9947a0
--- /dev/null
+++ b/src/lib/imports/fixtures/debug-errors.txt
@@ -0,0 +1,6 @@
+DEBUG_ERRORS
+
+Invalid amount in row 3
+Missing paidById
+Invalid expense date: 2024-99-99
+Participant ID not found: user-xyz
diff --git a/src/lib/imports/formats/debug-format.ts b/src/lib/imports/formats/debug-format.ts
new file mode 100644
index 000000000..8de46ae33
--- /dev/null
+++ b/src/lib/imports/formats/debug-format.ts
@@ -0,0 +1,61 @@
+import { importFormats, type ImportFormat } from '@/lib/imports/types'
+
+// Extremely simple debug format to aid manual testing.
+//
+// Expected content:
+// DEBUG_IMPORT\n
+// { ...Spliit-JSON payload... }
+//
+// Rationale:
+// - Keeps detection unambiguous (hard prefix), avoiding conflicts with real formats
+// - Reuses Spliit-JSON parsing for convenience when a JSON payload follows the marker
+// - Allows devs to quickly craft debug fixtures without changing the main adapters
+
+export class DebugImportFormat implements ImportFormat {
+ id = 'debug'
+ label = 'Debug Import'
+ priority = 5
+
+ private stripPrefix(content: string) {
+ const trimmed = content.trimStart()
+ const prefixes = ['DEBUG_IMPORT', 'DEBUG_ERRORS']
+ for (const prefix of prefixes) {
+ if (trimmed.startsWith(prefix)) {
+ const idx = trimmed.indexOf('\n')
+ return idx >= 0 ? trimmed.slice(idx + 1) : ''
+ }
+ }
+ return content
+ }
+
+ async detect(content: string): Promise {
+ const trimmed = content.trimStart()
+ if (
+ trimmed.startsWith('DEBUG_IMPORT') ||
+ trimmed.startsWith('DEBUG_ERRORS')
+ )
+ return 0.99
+ return 0
+ }
+
+ async parseToInternal(content: string) {
+ // Debug format: only emit errors from payload lines after marker.
+ // Usage:
+ // DEBUG_ERRORS\n
+ //
+ // or:
+ // DEBUG_IMPORT\n
+ //
+ const body = this.stripPrefix(content)
+ const errors = body
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0)
+ .map((message, index) => ({ row: index + 1, message }))
+
+ return { expenses: [], group: undefined, errors }
+ }
+}
+
+// Self-register
+importFormats.register(new DebugImportFormat())
diff --git a/src/lib/imports/registry.ts b/src/lib/imports/registry.ts
new file mode 100644
index 000000000..4b887e060
--- /dev/null
+++ b/src/lib/imports/registry.ts
@@ -0,0 +1,43 @@
+import {
+ ImportFormat,
+ importFormats,
+ type ImportParsedGroupInfo,
+} from '@/lib/imports/types'
+// Self-registering formats (side effect imports)
+import '@/lib/imports/formats/debug-format'
+import '@/lib/imports/spliit-json'
+
+// Registry entry points
+//
+// This module exposes small helpers that orchestrate the format registry.
+// Responsibilities:
+// - Collect all self‑registering formats
+// - Run detection on content + context to find the best matching adapter
+// - Delegate parsing to the selected adapter into internal ExpenseFormValues
+
+// Choose the most likely format for given content.
+// Returns the selected ImportFormat instance or null if none match.
+export async function detectFormat(
+ content: string,
+): Promise {
+ return importFormats.detect(content)
+}
+
+// Parse a file into internal form values using the detected format.
+//
+// - Detection: calls detectFormat(...) to select an adapter based on content head and context.
+// - Parsing: invokes the adapter's parseToInternal(...) to produce ExpenseFormValues.
+// - Meta: adapters may optionally return meta information (e.g., source currency/name).
+export async function parseWithDetectionInternal(content: string): Promise<{
+ expenses: import('@/lib/schemas').ExpenseFormValues[]
+ format: ImportFormat
+ group?: ImportParsedGroupInfo
+ errors: { row: number; message: string }[]
+}> {
+ const format = await detectFormat(content)
+ if (!format) throw new Error('Unsupported file format.')
+ if (!format.parseToInternal)
+ throw new Error('Format does not support internal parsing.')
+ const { expenses, group, errors } = await format.parseToInternal(content)
+ return { expenses, group, format, errors: errors ?? [] }
+}
diff --git a/src/lib/imports/spliit-json.test.ts b/src/lib/imports/spliit-json.test.ts
new file mode 100644
index 000000000..7238a7cec
--- /dev/null
+++ b/src/lib/imports/spliit-json.test.ts
@@ -0,0 +1,218 @@
+import {
+ CATEGORY_LOOKUP,
+ SpliitJsonFormat,
+ resolveCategoryId,
+} from './spliit-json'
+
+describe('SpliitJsonFormat', () => {
+ const format = new SpliitJsonFormat()
+
+ describe('detect', () => {
+ it('should return 0 for invalid JSON', async () => {
+ expect(await format.detect('invalid json')).toBe(0)
+ })
+
+ it('should return 0 for JSON without required fields', async () => {
+ expect(await format.detect('{}')).toBe(0)
+ expect(await format.detect('{"foo": "bar"}')).toBe(0)
+ })
+
+ it('should return a high score for valid Spliit JSON', async () => {
+ const content = JSON.stringify({
+ participants: [{ name: 'Alice' }, { name: 'Bob' }],
+ expenses: [
+ {
+ paidById: '1',
+ paidFor: [{ participantId: '2', shares: 1 }],
+ amount: 100,
+ expenseDate: '2023-01-01',
+ title: 'Test',
+ },
+ ],
+ })
+ expect(await format.detect(content)).toBeGreaterThan(0.8)
+ })
+ })
+
+ describe('parseToInternal', () => {
+ it('should parse a valid export correctly', async () => {
+ const content = JSON.stringify({
+ participants: [
+ { id: 'p1', name: 'Alice' },
+ { id: 'p2', name: 'Bob' },
+ ],
+ expenses: [
+ {
+ paidById: 'p1',
+ paidFor: [
+ { participantId: 'p2', shares: 1 },
+ { participantId: 'p1', shares: 2 },
+ ],
+ amount: 1234, // 12.34
+ expenseDate: '2023-05-20',
+ title: 'Lunch',
+ category: 'Food',
+ },
+ ],
+ name: 'My Group',
+ currency: '$',
+ })
+
+ const result = await format.parseToInternal(content)
+
+ expect(result.errors).toHaveLength(0)
+ expect(result.group).toEqual({
+ name: 'My Group',
+ currency: '$',
+ currencyCode: undefined,
+ participants: [{ name: 'Alice' }, { name: 'Bob' }],
+ })
+
+ expect(result.expenses).toHaveLength(1)
+ const expense = result.expenses[0]
+ expect(expense.title).toBe('Lunch')
+ expect(expense.amount).toBe(1234)
+ expect(expense.paidBy).toBe('Alice') // ID resolved to name
+ expect(expense.paidFor).toHaveLength(2)
+ expect(expense.paidFor).toContainEqual(
+ expect.objectContaining({ participant: 'Bob', shares: 1 }),
+ )
+ expect(expense.paidFor).toContainEqual(
+ expect.objectContaining({ participant: 'Alice', shares: 2 }),
+ )
+ expect(expense.expenseDate).toBeInstanceOf(Date)
+ expect(expense.expenseDate.toISOString()).toContain('2023-05-20')
+ })
+
+ it('should handle missing participant names by falling back to ID or index', async () => {
+ const content = JSON.stringify({
+ participants: [
+ { id: 'p1' }, // No name
+ {}, // No ID or name
+ ],
+ expenses: [],
+ })
+
+ const result = await format.parseToInternal(content)
+ const names = result.group?.participants?.map((p) => p.name)
+ expect(names).toEqual(['p1', 'Participant 2'])
+ })
+
+ it('should collect errors for invalid expenses', async () => {
+ const content = JSON.stringify({
+ participants: [{ id: 'p1', name: 'Alice' }],
+ expenses: [
+ {
+ paidById: 'p1',
+ amount: 'invalid',
+ title: 'Bad Amount',
+ expenseDate: '2023-01-01',
+ },
+ ],
+ })
+
+ // Note: The current implementation is quite lenient.
+ // - "invalid" amount might throw during coercion.
+
+ const result = await format.parseToInternal(content)
+ expect(result.errors).toHaveLength(1)
+ expect(result.errors?.[0].message).toContain('Invalid amount')
+ })
+ })
+
+ describe('CATEGORY_LOOKUP and resolveCategoryId', () => {
+ // Exact mapping from DB migration/seeds
+ const expectedCategories = [
+ { id: 0, grouping: 'uncategorized', name: 'general' },
+ { id: 1, grouping: 'uncategorized', name: 'payment' },
+ { id: 2, grouping: 'entertainment', name: 'entertainment' },
+ { id: 3, grouping: 'entertainment', name: 'games' },
+ { id: 4, grouping: 'entertainment', name: 'movies' },
+ { id: 5, grouping: 'entertainment', name: 'music' },
+ { id: 6, grouping: 'entertainment', name: 'sports' },
+ { id: 7, grouping: 'food and drink', name: 'food and drink' },
+ { id: 8, grouping: 'food and drink', name: 'dining out' },
+ { id: 9, grouping: 'food and drink', name: 'groceries' },
+ { id: 10, grouping: 'food and drink', name: 'liquor' },
+ { id: 11, grouping: 'home', name: 'home' },
+ { id: 12, grouping: 'home', name: 'electronics' },
+ { id: 13, grouping: 'home', name: 'furniture' },
+ { id: 14, grouping: 'home', name: 'household supplies' },
+ { id: 15, grouping: 'home', name: 'maintenance' },
+ { id: 16, grouping: 'home', name: 'mortgage' },
+ { id: 17, grouping: 'home', name: 'pets' },
+ { id: 18, grouping: 'home', name: 'rent' },
+ { id: 19, grouping: 'home', name: 'services' },
+ { id: 20, grouping: 'life', name: 'childcare' },
+ { id: 21, grouping: 'life', name: 'clothing' },
+ { id: 22, grouping: 'life', name: 'education' },
+ { id: 23, grouping: 'life', name: 'gifts' },
+ { id: 24, grouping: 'life', name: 'insurance' },
+ { id: 25, grouping: 'life', name: 'medical expenses' },
+ { id: 26, grouping: 'life', name: 'taxes' },
+ { id: 27, grouping: 'transportation', name: 'transportation' },
+ { id: 28, grouping: 'transportation', name: 'bicycle' },
+ { id: 29, grouping: 'transportation', name: 'bus/train' },
+ { id: 30, grouping: 'transportation', name: 'car' },
+ { id: 31, grouping: 'transportation', name: 'gas/fuel' },
+ { id: 32, grouping: 'transportation', name: 'hotel' },
+ { id: 33, grouping: 'transportation', name: 'parking' },
+ { id: 34, grouping: 'transportation', name: 'plane' },
+ { id: 35, grouping: 'transportation', name: 'taxi' },
+ { id: 36, grouping: 'utilities', name: 'utilities' },
+ { id: 37, grouping: 'utilities', name: 'cleaning' },
+ { id: 38, grouping: 'utilities', name: 'electricity' },
+ { id: 39, grouping: 'utilities', name: 'heat/gas' },
+ { id: 40, grouping: 'utilities', name: 'trash' },
+ { id: 41, grouping: 'utilities', name: 'tv/phone/internet' },
+ { id: 42, grouping: 'utilities', name: 'water' },
+ { id: 43, grouping: 'life', name: 'donation' },
+ ]
+
+ it('should have all expected categories in lookup', () => {
+ // Ensure we haven't missed any
+ expect(Object.keys(CATEGORY_LOOKUP).length).toBe(
+ expectedCategories.length,
+ )
+
+ expectedCategories.forEach((cat) => {
+ const key = `${cat.grouping}|${cat.name}`
+ expect(CATEGORY_LOOKUP).toHaveProperty(key, cat.id)
+ })
+ })
+
+ // Validate resolution logic for each category
+ expectedCategories.forEach((cat) => {
+ const { id, grouping, name } = cat
+
+ it(`should resolve category [${id}] ${grouping}|${name} correctly`, () => {
+ // 1. By ID (number)
+ expect(resolveCategoryId(id)).toBe(id)
+
+ // 2. By ID (string)
+ expect(resolveCategoryId(String(id))).toBe(id)
+
+ // 3. By full object
+ expect(
+ resolveCategoryId({
+ grouping: grouping.toUpperCase(),
+ name: name.toUpperCase(),
+ }),
+ ).toBe(id)
+
+ // 4. By name only (fallback)
+ expect(resolveCategoryId({ name: name.toUpperCase() })).toBe(id)
+ })
+ })
+
+ it('should return default (0) for unknown categories or invalid input', () => {
+ expect(resolveCategoryId(null)).toBe(0)
+ expect(resolveCategoryId(undefined)).toBe(0)
+ expect(resolveCategoryId('unknown')).toBe(0)
+ expect(
+ resolveCategoryId({ grouping: 'NonExistent', name: 'Category' }),
+ ).toBe(0)
+ expect(resolveCategoryId({ name: 'NonExistent' })).toBe(0)
+ })
+ })
+})
diff --git a/src/lib/imports/spliit-json.ts b/src/lib/imports/spliit-json.ts
new file mode 100644
index 000000000..d6942dfbf
--- /dev/null
+++ b/src/lib/imports/spliit-json.ts
@@ -0,0 +1,294 @@
+import { importFormats, type ImportFormat } from '@/lib/imports/types'
+import { expenseFormSchema, type ExpenseFormValues } from '@/lib/schemas'
+
+// Detection on full content: parse JSON and validate minimal structure.
+// Requirements:
+// - top-level object with arrays: participants[], expenses[] (arrays can be empty)
+// - participant objects should have at least one of: name or id (string)
+// - expense objects should include: paidById (string), paidFor (array), amount, expenseDate, title
+export const looksLikeSpliitJson = (content: string) => {
+ try {
+ const root = JSON.parse(content)
+ if (!root || typeof root !== 'object') return false
+ const participants = (root as any).participants
+ const expenses = (root as any).expenses
+ if (!Array.isArray(participants) || !Array.isArray(expenses)) return false
+
+ // Check a few participant entries (or pass if empty)
+ if (participants.length > 0) {
+ const p: any = participants[0]
+ const hasName = typeof p?.name === 'string'
+ const hasId = typeof p?.id === 'string'
+ if (!(hasName || hasId)) return false
+ }
+
+ // Check a few expense entries (or pass if empty)
+ if (expenses.length > 0) {
+ const e: any = expenses[0]
+ const ok =
+ typeof e?.paidById === 'string' &&
+ Array.isArray(e?.paidFor) &&
+ (typeof e?.amount === 'number' || typeof e?.amount === 'string') &&
+ (typeof e?.expenseDate === 'string' ||
+ typeof e?.expenseDate === 'number') &&
+ typeof e?.title === 'string'
+ if (!ok) return false
+ }
+ return true
+ } catch {
+ return false
+ }
+}
+
+// Small helpers for coercion with clear error messages (used within parseToInternal)
+const coerceNumber = (value: unknown, label: string): number => {
+ const n = typeof value === 'string' ? Number(value) : value
+ if (typeof n !== 'number' || !Number.isFinite(n)) {
+ throw new Error(`Invalid ${label} value in export`)
+ }
+ return n
+}
+const normalize = (value: unknown) =>
+ String(value ?? '')
+ .trim()
+ .toLowerCase()
+
+// Seeded category ids (see prisma/migrations/20240108194443_add_categories/migration.sql)
+export const CATEGORY_LOOKUP: Record = {
+ 'uncategorized|general': 0,
+ 'uncategorized|payment': 1,
+ 'entertainment|entertainment': 2,
+ 'entertainment|games': 3,
+ 'entertainment|movies': 4,
+ 'entertainment|music': 5,
+ 'entertainment|sports': 6,
+ 'food and drink|food and drink': 7,
+ 'food and drink|dining out': 8,
+ 'food and drink|groceries': 9,
+ 'food and drink|liquor': 10,
+ 'home|home': 11,
+ 'home|electronics': 12,
+ 'home|furniture': 13,
+ 'home|household supplies': 14,
+ 'home|maintenance': 15,
+ 'home|mortgage': 16,
+ 'home|pets': 17,
+ 'home|rent': 18,
+ 'home|services': 19,
+ 'life|childcare': 20,
+ 'life|clothing': 21,
+ 'life|education': 22,
+ 'life|gifts': 23,
+ 'life|insurance': 24,
+ 'life|medical expenses': 25,
+ 'life|taxes': 26,
+ 'transportation|transportation': 27,
+ 'transportation|bicycle': 28,
+ 'transportation|bus/train': 29,
+ 'transportation|car': 30,
+ 'transportation|gas/fuel': 31,
+ 'transportation|hotel': 32,
+ 'transportation|parking': 33,
+ 'transportation|plane': 34,
+ 'transportation|taxi': 35,
+ 'utilities|utilities': 36,
+ 'utilities|cleaning': 37,
+ 'utilities|electricity': 38,
+ 'utilities|heat/gas': 39,
+ 'utilities|trash': 40,
+ 'utilities|tv/phone/internet': 41,
+ 'utilities|water': 42,
+ 'life|donation': 43, // see follow-up migration 20250308000000_add_category_donation
+}
+
+export const resolveCategoryId = (value: unknown): number => {
+ // Numeric ids pass through.
+ if (typeof value === 'number' && Number.isFinite(value))
+ return Math.max(0, Math.trunc(value))
+
+ // Strings that are numeric -> numeric id.
+ if (typeof value === 'string' && value.trim() !== '') {
+ const parsed = Number(value)
+ if (Number.isFinite(parsed)) return Math.max(0, Math.trunc(parsed))
+ }
+
+ // Object shape with grouping/name (as exported by Spliit).
+ if (value && typeof value === 'object') {
+ const grouping = normalize((value as any).grouping)
+ const name = normalize((value as any).name)
+ const key = `${grouping}|${name}`
+ if (CATEGORY_LOOKUP[key] !== undefined) return CATEGORY_LOOKUP[key]
+ // Fallback: try by name only
+ const byName = Object.entries(CATEGORY_LOOKUP).find(
+ ([k]) => k.split('|')[1] === name,
+ )
+ if (byName) return byName[1]
+ }
+
+ // Default: uncategorized
+ return 0
+}
+const coerceDate = (value: unknown): Date => {
+ if (value instanceof Date) return value
+ const str = String(value ?? '').trim()
+ if (!str) throw new Error('Missing expense date in export')
+ const date = new Date(str)
+ if (Number.isNaN(date.getTime()))
+ throw new Error('Invalid expense date in export')
+ return date
+}
+
+export class SpliitJsonFormat implements ImportFormat {
+ id = 'spliit-json'
+ label = 'Spliit JSON'
+ priority = 100
+
+ async detect(content: string): Promise {
+ return looksLikeSpliitJson(content) ? 0.95 : 0
+ }
+
+ async parseToInternal(content: string): Promise<{
+ expenses: ExpenseFormValues[]
+ group?: import('@/lib/imports/types').ImportParsedGroupInfo
+ errors?: { row: number; message: string }[]
+ }> {
+ let raw: any
+ try {
+ raw = JSON.parse(content)
+ } catch {
+ throw new Error('Invalid JSON: unable to parse file contents.')
+ }
+
+ const { externalIdToName, participantNames } = this.extractParticipants(
+ raw?.participants,
+ )
+ const { expenses, errors } = this.extractExpenses(
+ raw?.expenses,
+ externalIdToName,
+ )
+
+ const group = {
+ name: raw?.name ?? undefined,
+ currency: raw?.currency ?? undefined,
+ currencyCode: raw?.currencyCode ?? undefined,
+ participants: participantNames.length
+ ? participantNames.map((name) => ({ name }))
+ : undefined,
+ }
+
+ return { expenses, group, errors }
+ }
+
+ private extractParticipants(rawParticipants: any[]) {
+ const externalIdToName = new Map()
+ const participantNames: string[] = []
+
+ if (Array.isArray(rawParticipants)) {
+ rawParticipants.forEach((p: any, i: number) => {
+ const id = typeof p?.id === 'string' ? p.id.trim() : ''
+ const nameRaw = typeof p?.name === 'string' ? p.name.trim() : ''
+ const name = nameRaw || id || `Participant ${i + 1}`
+ participantNames.push(name)
+ if (id) externalIdToName.set(id, name)
+ })
+ }
+ return { externalIdToName, participantNames }
+ }
+
+ private extractExpenses(
+ rawExpenses: any[],
+ externalIdToName: Map,
+ ) {
+ const expenses: ExpenseFormValues[] = []
+ const errors: { row: number; message: string }[] = []
+
+ const list = Array.isArray(rawExpenses) ? rawExpenses : []
+
+ list.forEach((e: any, index: number) => {
+ try {
+ const paidByRaw = String(e?.paidById ?? '').trim()
+ if (!paidByRaw) throw new Error('Missing paidById')
+ const paidBy = externalIdToName.get(paidByRaw) ?? paidByRaw
+
+ const paidFor = this.extractPaidFor(
+ Array.isArray(e?.paidFor) ? e.paidFor : [],
+ externalIdToName,
+ )
+
+ const base = this.mapToFormBase(e, index, paidBy, paidFor)
+ const result = expenseFormSchema.safeParse(base)
+ if (result.success) {
+ expenses.push(result.data)
+ } else {
+ const message =
+ result.error.issues.map((i) => i.message).join(', ') ||
+ 'Invalid expense'
+ errors.push({ row: index + 1, message })
+ }
+ } catch (err: any) {
+ errors.push({
+ row: index + 1,
+ message: String(err?.message ?? 'Invalid expense'),
+ })
+ }
+ })
+
+ return { expenses, errors }
+ }
+
+ private extractPaidFor(
+ entries: any[],
+ externalIdToName: Map,
+ ): ExpenseFormValues['paidFor'] {
+ const seen = new Set()
+ const paidFor: ExpenseFormValues['paidFor'] = []
+ entries.forEach((pf: any) => {
+ const rawId = String(pf?.participantId ?? '').trim()
+ if (!rawId) throw new Error('paidFor without participantId')
+ if (seen.has(rawId)) return
+ seen.add(rawId)
+ const shares = Math.max(
+ 1,
+ Math.trunc(coerceNumber(pf?.shares ?? 1, 'shares')),
+ )
+ const name = externalIdToName.get(rawId) ?? rawId
+ paidFor.push({ participant: name, shares, originalAmount: undefined })
+ })
+ return paidFor
+ }
+
+ private mapToFormBase(
+ e: any,
+ index: number,
+ paidBy: string,
+ paidFor: ExpenseFormValues['paidFor'],
+ ) {
+ return {
+ expenseDate: coerceDate(e?.expenseDate),
+ title: String(e?.title ?? '').trim() || `Expense ${index + 1}`,
+ category: resolveCategoryId(e?.categoryId ?? e?.category),
+ amount: Math.round(coerceNumber(e?.amount, 'amount')),
+ originalAmount:
+ e?.originalAmount != null
+ ? coerceNumber(e.originalAmount, 'originalAmount')
+ : undefined,
+ originalCurrency: e?.originalCurrency ?? undefined,
+ conversionRate:
+ e?.conversionRate != null
+ ? coerceNumber(e.conversionRate, 'conversionRate')
+ : undefined,
+ paidBy,
+ paidFor,
+ splitMode: (e?.splitMode as ExpenseFormValues['splitMode']) ?? 'EVENLY',
+ saveDefaultSplittingOptions: false,
+ isReimbursement: e?.isReimbursement ?? false,
+ documents: [],
+ notes: e?.notes ?? undefined,
+ recurrenceRule:
+ (e?.recurrenceRule as ExpenseFormValues['recurrenceRule']) ?? 'NONE',
+ }
+ }
+}
+
+// Self-register the format with the global registry.
+importFormats.register(new SpliitJsonFormat())
diff --git a/src/lib/imports/types.ts b/src/lib/imports/types.ts
new file mode 100644
index 000000000..decbec1ea
--- /dev/null
+++ b/src/lib/imports/types.ts
@@ -0,0 +1,67 @@
+import type { ExpenseFormValues } from '@/lib/schemas'
+
+// Interface a format adapter must implement.
+//
+// Each adapter provides:
+// - id/label/priority: identity + tie‑breaks
+// - detect: structural detection on full content (0..1)
+// - parseToInternal: full parse → ExpenseFormValues
+export type ImportParsedGroupInfo = {
+ // Optional group name from the file (if present)
+ name?: string
+ // Display currency symbol (e.g., "€") and ISO code (e.g., "EUR") if available
+ currency?: string
+ currencyCode?: string
+ // Optional participant list provided by the file (names preferred for display)
+ participants?: { id?: string; name: string }[]
+}
+
+export interface ImportFormat {
+ // Unique, stable identifier (e.g. "spliit-json").
+ id: string
+ // Human-readable label (e.g. "Spliit JSON").
+ label: string
+ // Priority used to break ties between formats with equal detection scores.
+ priority: number
+ // Detection on the full content. Return 0 to opt out.
+ // Implementations may do a fast check or a full parse depending on format.
+ detect(content: string): Promise
+ // Parse full content into internal values.
+ parseToInternal?: (content: string) => Promise<{
+ expenses: ExpenseFormValues[]
+ group?: ImportParsedGroupInfo
+ errors?: { row: number; message: string }[]
+ }>
+}
+
+// Simple in-memory registry of available formats.
+export class ImportFormatRegistry {
+ private formats: ImportFormat[] = []
+ register(format: ImportFormat) {
+ this.formats.push(format)
+ }
+ list(): ImportFormat[] {
+ return [...this.formats].sort((a, b) => b.priority - a.priority)
+ }
+ // Selection strategy for detect():
+ // - Provide full content to each format.
+ // - Each format returns a score in [0..1]; 0 means "not a candidate".
+ // - Rank by score (desc), then by format.priority (desc) for deterministic tie‑breaks.
+ // - Return the top candidate or null if no format opts in.
+ async detect(content: string) {
+ const scored = await Promise.all(
+ this.list().map(async (f) => ({
+ format: f,
+ score: await f.detect(content),
+ })),
+ )
+ const candidates = scored
+ .filter((x) => x.score > 0)
+ .sort(
+ (a, b) => b.score - a.score || b.format.priority - a.format.priority,
+ )
+ return candidates[0]?.format ?? null
+ }
+}
+
+export const importFormats = new ImportFormatRegistry()
diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts
index 2fa8629be..d66a5e864 100644
--- a/src/lib/schemas.ts
+++ b/src/lib/schemas.ts
@@ -119,9 +119,10 @@ export const expenseFormSchema = z
}
}),
splitMode: z
- .enum(
- Object.values(SplitMode) as any,
- )
+ .enum<
+ SplitMode,
+ [SplitMode, ...SplitMode[]]
+ >(Object.values(SplitMode) as any)
.default('EVENLY'),
saveDefaultSplittingOptions: z.boolean(),
isReimbursement: z.boolean(),
@@ -137,9 +138,10 @@ export const expenseFormSchema = z
.default([]),
notes: z.string().optional(),
recurrenceRule: z
- .enum(
- Object.values(RecurrenceRule) as any,
- )
+ .enum<
+ RecurrenceRule,
+ [RecurrenceRule, ...RecurrenceRule[]]
+ >(Object.values(RecurrenceRule) as any)
.default('NONE'),
})
.superRefine((expense, ctx) => {
diff --git a/src/trpc/routers/groups/create.procedure.ts b/src/trpc/routers/groups/create.procedure.ts
index 9cf31a424..2fc433cba 100644
--- a/src/trpc/routers/groups/create.procedure.ts
+++ b/src/trpc/routers/groups/create.procedure.ts
@@ -11,5 +11,5 @@ export const createGroupProcedure = baseProcedure
)
.mutation(async ({ input: { groupFormValues } }) => {
const group = await createGroup(groupFormValues)
- return { groupId: group.id }
+ return group
})
diff --git a/src/trpc/routers/groups/import/import-group.procedure.ts b/src/trpc/routers/groups/import/import-group.procedure.ts
new file mode 100644
index 000000000..536a7a066
--- /dev/null
+++ b/src/trpc/routers/groups/import/import-group.procedure.ts
@@ -0,0 +1,76 @@
+import { createExpense, createGroup } from '@/lib/api'
+import { buildExpensesFromFileImport } from '@/lib/imports/file-import'
+import { baseProcedure } from '@/trpc/init'
+import { z } from 'zod'
+
+// Creates a new group from a file in a single request (no progress tracking).
+// Kept for potential future use; the UI currently prefers the chunked job flow.
+export const importGroupFromFileProcedure = baseProcedure
+ .input(
+ z.object({
+ fileContent: z.string().min(1),
+ groupName: z.string().trim().optional(),
+ fileName: z.string().trim().optional(),
+ }),
+ )
+ .mutation(async ({ input: { fileContent, groupName } }) => {
+ const trimmed = fileContent.trim()
+ if (!trimmed) {
+ throw new Error('Uploaded file was empty.')
+ }
+
+ // Parse the file into internal expenses first.
+ const result = await buildExpensesFromFileImport(trimmed)
+
+ if (result.errors.length > 0) {
+ throw new Error(
+ 'Unable to import file. Please fix the reported issues and try again.',
+ )
+ }
+
+ // Prefer participants supplied by the adapter; otherwise derive from expenses.
+ let participantNames: string[] | undefined =
+ result.group?.participants?.map((p) => p.name)
+ if (!participantNames || participantNames.length === 0) {
+ const set = new Set()
+ for (const e of result.expenses) {
+ set.add(e.paidBy)
+ for (const pf of e.paidFor) set.add(pf.participant)
+ }
+ participantNames = Array.from(set)
+ }
+ if (participantNames.length === 0)
+ throw new Error('No participants found in file.')
+
+ const currency =
+ (result.group?.currency as string | undefined)?.trim() || '€'
+ const currencyCode =
+ (result.group?.currencyCode as string | undefined)?.trim() || ''
+ const inferredName = (result.group?.name as string | undefined)?.trim()
+ const group = await createGroup({
+ name: groupName?.trim() || inferredName || 'Imported group',
+ information: undefined,
+ currency,
+ currencyCode,
+ participants: participantNames.map((name) => ({ name })),
+ })
+
+ // Simple name-based remapping: adapters set participants to display names.
+ const createdNameToId = new Map(
+ group.participants.map((p) => [p.name, p.id] as const),
+ )
+ const remapExpense = (exp: (typeof result.expenses)[number]) => ({
+ ...exp,
+ paidBy: createdNameToId.get(exp.paidBy) ?? exp.paidBy,
+ paidFor: exp.paidFor.map((pf) => ({
+ ...pf,
+ participant: createdNameToId.get(pf.participant) ?? pf.participant,
+ })),
+ })
+
+ for (const expense of result.expenses.map(remapExpense)) {
+ await createExpense(expense, group.id)
+ }
+
+ return { groupId: group.id, groupName: group.name }
+ })
diff --git a/src/trpc/routers/groups/import/index.ts b/src/trpc/routers/groups/import/index.ts
new file mode 100644
index 000000000..451965c82
--- /dev/null
+++ b/src/trpc/routers/groups/import/index.ts
@@ -0,0 +1,2 @@
+export { importGroupFromFileProcedure } from './import-group.procedure'
+export { processBatchProcedure } from './process-batch.procedure'
diff --git a/src/trpc/routers/groups/import/process-batch.procedure.ts b/src/trpc/routers/groups/import/process-batch.procedure.ts
new file mode 100644
index 000000000..f6c0e76a8
--- /dev/null
+++ b/src/trpc/routers/groups/import/process-batch.procedure.ts
@@ -0,0 +1,89 @@
+import { prisma } from '@/lib/prisma'
+import { expenseFormSchema } from '@/lib/schemas'
+import { baseProcedure } from '@/trpc/init'
+import { RecurrenceRule } from '@prisma/client'
+import { nanoid } from 'nanoid'
+import { z } from 'zod'
+
+export const processBatchProcedure = baseProcedure
+ .input(
+ z.object({
+ groupId: z.string(),
+ expenses: z.array(expenseFormSchema),
+ }),
+ )
+ .mutation(async ({ input: { groupId, expenses } }) => {
+ const group = await prisma.group.findUnique({
+ where: { id: groupId },
+ include: { participants: true },
+ })
+ if (!group) throw new Error('Group not found')
+
+ const participantIds = new Set(group.participants.map((p) => p.id))
+ const createdIds: string[] = []
+
+ // Validate all participants before starting transaction
+ for (const expense of expenses) {
+ if (!participantIds.has(expense.paidBy)) {
+ throw new Error(`Invalid payer ID: ${expense.paidBy}`)
+ }
+ for (const pf of expense.paidFor) {
+ if (!participantIds.has(pf.participant)) {
+ throw new Error(`Invalid receiver ID: ${pf.participant}`)
+ }
+ }
+ }
+
+ await prisma.$transaction(async (tx) => {
+ for (const expense of expenses) {
+ const expenseId = nanoid()
+ createdIds.push(expenseId)
+
+ // Note: We skip individual activity logging for performance and noise reduction.
+ // We also skip complex recurrence creation for imports as they are usually historical.
+
+ await tx.expense.create({
+ data: {
+ id: expenseId,
+ groupId,
+ expenseDate: expense.expenseDate,
+ categoryId: expense.category,
+ amount: expense.amount,
+ originalAmount: expense.originalAmount,
+ originalCurrency: expense.originalCurrency,
+ conversionRate: expense.conversionRate,
+ title: expense.title,
+ paidById: expense.paidBy,
+ splitMode: expense.splitMode,
+ recurrenceRule: expense.recurrenceRule || RecurrenceRule.NONE,
+ isReimbursement: expense.isReimbursement,
+ notes: expense.notes,
+ paidFor: {
+ createMany: {
+ data: expense.paidFor.map((pf) => ({
+ participantId: pf.participant,
+ shares: pf.shares,
+ })),
+ },
+ },
+ documents: {
+ createMany: {
+ data: expense.documents.map((doc) => ({
+ id: nanoid(),
+ url: doc.url,
+ width: doc.width,
+ height: doc.height,
+ })),
+ },
+ },
+ },
+ })
+ }
+
+ // Optional: Add a single activity log for the batch?
+ // Or just rely on the UI to show success.
+ // For now, no activity log to keep it simple and fast.
+ })
+
+ return createdIds
+ })
diff --git a/src/trpc/routers/groups/import/shared.ts b/src/trpc/routers/groups/import/shared.ts
new file mode 100644
index 000000000..441bf2e1d
--- /dev/null
+++ b/src/trpc/routers/groups/import/shared.ts
@@ -0,0 +1,8 @@
+export const DEFAULT_IMPORT_CHUNK_SIZE = 50
+export const getImportChunkSize = () => {
+ const envValue = process.env.IMPORT_CHUNK_SIZE
+ if (!envValue) return DEFAULT_IMPORT_CHUNK_SIZE
+ const parsed = Number(envValue)
+ if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_IMPORT_CHUNK_SIZE
+ return Math.floor(parsed)
+}
diff --git a/src/trpc/routers/groups/index.ts b/src/trpc/routers/groups/index.ts
index 132228835..e4ad41413 100644
--- a/src/trpc/routers/groups/index.ts
+++ b/src/trpc/routers/groups/index.ts
@@ -7,6 +7,7 @@ import { getGroupProcedure } from '@/trpc/routers/groups/get.procedure'
import { groupStatsRouter } from '@/trpc/routers/groups/stats'
import { updateGroupProcedure } from '@/trpc/routers/groups/update.procedure'
import { getGroupDetailsProcedure } from './getDetails.procedure'
+import { importGroupFromFileProcedure, processBatchProcedure } from './import'
import { listGroupsProcedure } from './list.procedure'
export const groupsRouter = createTRPCRouter({
@@ -19,5 +20,7 @@ export const groupsRouter = createTRPCRouter({
getDetails: getGroupDetailsProcedure,
list: listGroupsProcedure,
create: createGroupProcedure,
+ importFromFile: importGroupFromFileProcedure,
+ importProcessBatch: processBatchProcedure,
update: updateGroupProcedure,
})