diff --git a/messages/de-DE.json b/messages/de-DE.json
index 65104d0..4e720e5 100644
--- a/messages/de-DE.json
+++ b/messages/de-DE.json
@@ -21,6 +21,7 @@
"createFirst": "Erstelle die Erste",
"noExpenses": "Deine Gruppe hat noch keine Ausgaben.",
"exportJson": "Als JSON exportieren",
+ "exportCsv": "Als CSV exportieren",
"searchPlaceholder": "Suche nach einer Ausgabe…",
"ActiveUserModal": {
"title": "Wer bist du?",
diff --git a/messages/en-US.json b/messages/en-US.json
index 0e42ec9..493b9e3 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -20,7 +20,9 @@
"create": "Create expense",
"createFirst": "Create the first one",
"noExpenses": "Your group doesn’t contain any expense yet.",
+ "export": "Export",
"exportJson": "Export to JSON",
+ "exportCsv": "Export to CSV",
"searchPlaceholder": "Search for an expense…",
"ActiveUserModal": {
"title": "Who are you?",
diff --git a/messages/es.json b/messages/es.json
index c75a217..c89709c 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -21,6 +21,7 @@
"createFirst": "Crea el primero",
"noExpenses": "Tu grupo aun no tiene gastos.",
"exportJson": "Exportar a JSON",
+ "exportCsv": "Exportar a CSV",
"searchPlaceholder": "Busca un gasto…",
"ActiveUserModal": {
"title": "¿Quién es usted?",
diff --git a/messages/fi.json b/messages/fi.json
index a00e6b2..db05956 100644
--- a/messages/fi.json
+++ b/messages/fi.json
@@ -21,6 +21,7 @@
"createFirst": "Lisää ensimmäinen kulu",
"noExpenses": "Ryhmälläsi ei ole vielä yhtään kulua.",
"exportJson": "Vie JSON-tiedostoon",
+ "exportCsv": "Vie CSV-tiedostoon",
"searchPlaceholder": "Etsi kulua…",
"ActiveUserModal": {
"title": "Kuka olet?",
diff --git a/messages/fr-FR.json b/messages/fr-FR.json
index 8b06454..d5d21c7 100644
--- a/messages/fr-FR.json
+++ b/messages/fr-FR.json
@@ -21,6 +21,7 @@
"createFirst": "Créer la première :)",
"noExpenses": "Votre groupe n'a effectué aucune dépense pour le moment.",
"exportJson": "Exporter en JSON",
+ "exportCsv": "Exporter en CSV",
"searchPlaceholder": "Rechercher une dépense…",
"ActiveUserModal": {
"title": "Qui êtes-vous ?",
diff --git a/messages/it-IT.json b/messages/it-IT.json
index e984067..8a101a7 100644
--- a/messages/it-IT.json
+++ b/messages/it-IT.json
@@ -21,6 +21,7 @@
"createFirst": "Crea la prima",
"noExpenses": "Il tuo gruppo non contiene ancora spese.",
"exportJson": "Esporta file JSON",
+ "exportCsv": "Esporta file CSV",
"searchPlaceholder": "Cerca una spesa…",
"ActiveUserModal": {
"title": "Chi sei?",
diff --git a/messages/pl-PL.json b/messages/pl-PL.json
index b936c6b..73b3208 100644
--- a/messages/pl-PL.json
+++ b/messages/pl-PL.json
@@ -21,6 +21,7 @@
"createFirst": "Stwórz swój pierwszy",
"noExpenses": "Twoja grupa nie ma jeszcze żadnych wydatków.",
"exportJson": "Eksportuj do JSONa",
+ "exportCsv": "Eksportuj do CSVa",
"searchPlaceholder": "Szukaj wydatku...",
"ActiveUserModal": {
"title": "Kim jesteś?",
diff --git a/messages/ro.json b/messages/ro.json
index 718037a..76f8a18 100644
--- a/messages/ro.json
+++ b/messages/ro.json
@@ -21,6 +21,7 @@
"createFirst": "Adaug-o pe prima",
"noExpenses": "Grupul tău nu conține nicio cheltuială încă.",
"exportJson": "Salvează în JSON",
+ "exportCsv": "Salvează în CSV",
"searchPlaceholder": "Caută o cheltuială…",
"ActiveUserModal": {
"title": "Cum te numești?",
diff --git a/messages/ru-RU.json b/messages/ru-RU.json
index 82c48cf..91027a2 100644
--- a/messages/ru-RU.json
+++ b/messages/ru-RU.json
@@ -21,6 +21,7 @@
"createFirst": "Создать первый расход",
"noExpenses": "У вашей группы пока что нет расходов.",
"exportJson": "Экспортировать в JSON",
+ "exportCsv": "Экспортировать в CSV",
"searchPlaceholder": "Поиск расходов…",
"ActiveUserModal": {
"title": "Кто вы?",
diff --git a/messages/ua-UA.json b/messages/ua-UA.json
index e6eb561..1626305 100644
--- a/messages/ua-UA.json
+++ b/messages/ua-UA.json
@@ -21,6 +21,7 @@
"createFirst": "Створіть першу витрату",
"noExpenses": "У вашій групі ще немає витрат",
"exportJson": "Експортувати у JSON",
+ "exportCsv": "Експортувати у CSV",
"searchPlaceholder": "Пошук витрат...",
"ActiveUserModal": {
"title": "Хто ви?",
diff --git a/messages/zh-CN.json b/messages/zh-CN.json
index ac98b8c..1ced8bf 100644
--- a/messages/zh-CN.json
+++ b/messages/zh-CN.json
@@ -21,6 +21,7 @@
"createFirst": "创建首个消费",
"noExpenses": "你的群组内目前没有任何消费。",
"exportJson": "导出到JSON",
+ "exportCsv": "导出到CSV",
"searchPlaceholder": "查找消费……",
"ActiveUserModal": {
"title": "你是哪位?",
diff --git a/messages/zh-TW.json b/messages/zh-TW.json
index 3206197..037b6a8 100644
--- a/messages/zh-TW.json
+++ b/messages/zh-TW.json
@@ -21,6 +21,7 @@
"createFirst": "新增第一筆消費紀錄",
"noExpenses": "你的群組內目前沒有任何消費紀錄。",
"exportJson": "匯出為 JSON",
+ "exportCsv": "匯出為 CSV",
"searchPlaceholder": "搜尋消費紀錄……",
"ActiveUserModal": {
"title": "你是誰?",
diff --git a/package-lock.json b/package-lock.json
index ec87fac..867dee2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,6 +11,7 @@
"dependencies": {
"@formatjs/intl-localematcher": "^0.5.4",
"@hookform/resolvers": "^3.3.2",
+ "@json2csv/plainjs": "^7.0.6",
"@prisma/client": "^5.6.0",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
@@ -5320,6 +5321,22 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@json2csv/formatters": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/@json2csv/formatters/-/formatters-7.0.6.tgz",
+ "integrity": "sha512-hjIk1H1TR4ydU5ntIENEPgoMGW+Q7mJ+537sDFDbsk+Y3EPl2i4NfFVjw0NJRgT+ihm8X30M67mA8AS6jPidSA==",
+ "license": "MIT"
+ },
+ "node_modules/@json2csv/plainjs": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/@json2csv/plainjs/-/plainjs-7.0.6.tgz",
+ "integrity": "sha512-4Md7RPDCSYpmW1HWIpWBOqCd4vWfIqm53S3e/uzQ62iGi7L3r34fK/8nhOMEe+/eVfCx8+gdSCt1d74SlacQHw==",
+ "license": "MIT",
+ "dependencies": {
+ "@json2csv/formatters": "^7.0.6",
+ "@streamparser/json": "^0.0.20"
+ }
+ },
"node_modules/@next/env": {
"version": "14.2.5",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.5.tgz",
@@ -8930,6 +8947,12 @@
"node": ">=16.0.0"
}
},
+ "node_modules/@streamparser/json": {
+ "version": "0.0.20",
+ "resolved": "https://registry.npmjs.org/@streamparser/json/-/json-0.0.20.tgz",
+ "integrity": "sha512-VqAAkydywPpkw63WQhPVKCD3SdwXuihCUVZbbiY3SfSTGQyHmwRoq27y4dmJdZuJwd5JIlQoMPyGvMbUPY0RKQ==",
+ "license": "MIT"
+ },
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
diff --git a/package.json b/package.json
index 842e03e..7cbf133 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,7 @@
"dependencies": {
"@formatjs/intl-localematcher": "^0.5.4",
"@hookform/resolvers": "^3.3.2",
+ "@json2csv/plainjs": "^7.0.6",
"@prisma/client": "^5.6.0",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
diff --git a/src/app/groups/[groupId]/expenses/export/csv/route.ts b/src/app/groups/[groupId]/expenses/export/csv/route.ts
new file mode 100644
index 0000000..b942abf
--- /dev/null
+++ b/src/app/groups/[groupId]/expenses/export/csv/route.ts
@@ -0,0 +1,142 @@
+import { Parser } from '@json2csv/plainjs'
+import { PrismaClient } from '@prisma/client'
+import contentDisposition from 'content-disposition'
+import { NextResponse } from 'next/server'
+
+const splitModeLabel = {
+ EVENLY: 'Evenly',
+ BY_SHARES: 'Unevenly – By shares',
+ BY_PERCENTAGE: 'Unevenly – By percentage',
+ BY_AMOUNT: 'Unevenly – By amount',
+}
+
+function formatDate(isoDateString: Date): string {
+ const date = new Date(isoDateString)
+ const year = date.getFullYear()
+ const month = String(date.getMonth() + 1).padStart(2, '0') // Months are zero-based
+ const day = String(date.getDate()).padStart(2, '0')
+ return `${year}-${month}-${day}` // YYYY-MM-DD format
+}
+
+const prisma = new PrismaClient()
+
+export async function GET(
+ req: Request,
+ { params: { groupId } }: { params: { groupId: string } },
+) {
+ const group = await prisma.group.findUnique({
+ where: { id: groupId },
+ select: {
+ id: true,
+ name: true,
+ currency: true,
+ expenses: {
+ select: {
+ expenseDate: true,
+ title: true,
+ category: { select: { name: true } },
+ amount: true,
+ paidById: true,
+ paidFor: { select: { participantId: true, shares: true } },
+ isReimbursement: true,
+ splitMode: true,
+ },
+ },
+ participants: { select: { id: true, name: true } },
+ },
+ })
+
+ if (!group) {
+ return NextResponse.json({ error: 'Invalid group ID' }, { status: 404 })
+ }
+
+ /*
+
+ CSV Structure:
+
+ --------------------------------------------------------------
+ | Date | Description | Category | Currency | Cost
+ --------------------------------------------------------------
+ | Is Reimbursement | Split mode | UserA | UserB
+ --------------------------------------------------------------
+
+ Columns:
+ - Date: The date of the expense.
+ - Description: A brief description of the expense.
+ - Category: The category of the expense (e.g., Food, Travel, etc.).
+ - Currency: The currency in which the expense is recorded.
+ - Cost: The amount spent.
+ - Is Reimbursement: Whether the expense is a reimbursement or not.
+ - Split mode: The method used to split the expense (e.g., Evenly, By shares, By percentage, By amount).
+ - UserA, UserB: User-specific data or balances (e.g., amount owed or contributed by each user).
+
+ Example Row:
+ ------------------------------------------------------------------------------------------
+ | 2025-01-06 | Dinner with team | Food | ₹ | 5000 | No | Evenly | John | Jane
+ ------------------------------------------------------------------------------------------
+
+*/
+
+ const fields = [
+ { label: 'Date', value: 'date' },
+ { label: 'Description', value: 'title' },
+ { label: 'Category', value: 'categoryName' },
+ { label: 'Currency', value: 'currency' },
+ { label: 'Cost', value: 'amount' },
+ { label: 'Is Reimbursement', value: 'isReimbursement' },
+ { label: 'Split mode', value: 'splitMode' },
+ ...group.participants.map((participant) => ({
+ label: participant.name,
+ value: participant.name,
+ })),
+ ]
+
+ const expenses = group.expenses.map((expense) => ({
+ date: formatDate(expense.expenseDate),
+ title: expense.title,
+ categoryName: expense.category?.name || '',
+ currency: group.currency,
+ amount: (expense.amount / 100).toFixed(2),
+ isReimbursement: expense.isReimbursement ? 'Yes' : 'No',
+ splitMode: splitModeLabel[expense.splitMode],
+ ...Object.fromEntries(
+ group.participants.map((participant) => {
+ const { totalShares, participantShare } = expense.paidFor.reduce(
+ (acc, { participantId, shares }) => {
+ acc.totalShares += shares
+ if (participantId === participant.id) {
+ acc.participantShare = shares
+ }
+ return acc
+ },
+ { totalShares: 0, participantShare: 0 },
+ )
+
+ const isPaidByParticipant = expense.paidById === participant.id
+ const participantAmountShare = +(
+ ((expense.amount / totalShares) * participantShare) /
+ 100
+ ).toFixed(2)
+
+ return [
+ participant.name,
+ participantAmountShare * (isPaidByParticipant ? 1 : -1),
+ ]
+ }),
+ ),
+ }))
+
+ const json2csvParser = new Parser({ fields })
+ const csv = json2csvParser.parse(expenses)
+
+ const date = new Date().toISOString().split('T')[0]
+ const filename = `Spliit Export - ${group.name} - ${date}.csv`
+
+ // \uFEFF character is added at the beginning of the CSV content to ensure that it is interpreted as UTF-8 with BOM (Byte Order Mark), which helps some applications correctly interpret the encoding.
+ return new NextResponse(`\uFEFF${csv}`, {
+ headers: {
+ 'Content-Type': 'text/csv; charset=utf-8',
+ 'Content-Disposition': contentDisposition(filename),
+ },
+ })
+}
diff --git a/src/app/groups/[groupId]/expenses/page.client.tsx b/src/app/groups/[groupId]/expenses/page.client.tsx
index 2a9888e..5547986 100644
--- a/src/app/groups/[groupId]/expenses/page.client.tsx
+++ b/src/app/groups/[groupId]/expenses/page.client.tsx
@@ -3,6 +3,7 @@
import { ActiveUserModal } from '@/app/groups/[groupId]/expenses/active-user-modal'
import { CreateFromReceiptButton } from '@/app/groups/[groupId]/expenses/create-from-receipt-button'
import { ExpenseList } from '@/app/groups/[groupId]/expenses/expense-list'
+import ExportButton from '@/app/groups/[groupId]/export-button'
import { Button } from '@/components/ui/button'
import {
Card,
@@ -11,7 +12,7 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card'
-import { Download, Plus } from 'lucide-react'
+import { Plus } from 'lucide-react'
import { Metadata } from 'next'
import { useTranslations } from 'next-intl'
import Link from 'next/link'
@@ -40,16 +41,7 @@ export default function GroupExpensesPageClient({
{t('description')}
-
+
{enableReceiptExtract && }