Add complete Cospend expense sharing feature

- Add MongoDB models for Payment and PaymentSplit with proper splitting logic
- Implement API routes for CRUD operations and balance calculations
- Create dashboard with balance overview and recent activity
- Add payment creation form with file upload (using $IMAGE_DIR)
- Implement shallow routing with modal side panel for payment details
- Support multiple split methods: equal, full payment, custom proportions
- Add responsive design for desktop and mobile
- Integrate with existing Authentik authentication

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-08 21:15:45 +02:00
parent 95b49ab6ce
commit 815975dba0
21 changed files with 3435 additions and 191 deletions

View File

@@ -1,119 +0,0 @@
import type { RequestHandler } from '@sveltejs/kit';
import fs from 'fs';
import path from 'path';
import { mkdir } from 'fs/promises';
import { Payment } from '$lib/models/Payment'; // adjust path as needed
import { dbConnect, dbDisconnect } from '$lib/db/db';
import { error } from '@sveltejs/kit';
const UPLOAD_DIR = './static/cospend';
const BASE_CURRENCY = 'CHF'; // Default currency
export const POST: RequestHandler = async ({ request, locals }) => {
let auth = await locals.auth();
if(!auth){
throw error(401, "Not logged in")
}
const formData = await request.formData();
try {
const name = formData.get('name') as string;
const category = formData.get('category') as string;
const transaction_date= new Date(formData.get('transaction_date') as string);
const description = formData.get('description') as string;
const note = formData.get('note') as string;
const tags = JSON.parse(formData.get('tags') as string) as string[];
const paid_by = formData.get('paid_by') as string
const type = formData.get('type') as string
let currency = formData.get('currency') as string;
let original_amount = parseFloat(formData.get('original_amount') as string);
let total_amount = NaN;
let for_self = parseFloat(formData.get('for_self') as string);
let for_other = parseFloat(formData.get('for_other') as string);
let conversion_rate = 1.0; // Default conversion rate
// if currency is not BASE_CURRENCY, fetch current conversion rate using frankfurter API and date in YYYY-MM-DD format
if (!currency || currency === BASE_CURRENCY) {
currency = BASE_CURRENCY;
total_amount = original_amount;
} else {
console.log(transaction_date);
const date_fmt = transaction_date.toISOString().split('T')[0]; // Convert date to YYYY-MM-DD format
// Fetch conversion rate logic here (not implemented in this example)
console.log(`Fetching conversion rate for ${currency} to ${BASE_CURRENCY} on ${date_fmt}`);
const res = await fetch(`https://api.frankfurter.app/${date_fmt}?from=${currency}&to=${BASE_CURRENCY}`)
console.log(res);
const result = await res.json();
console.log(result);
if (!result || !result.rates[BASE_CURRENCY]) {
return new Response(JSON.stringify({ message: 'Currency conversion failed.' }), { status: 400 });
}
// Assuming you want to convert the total amount to BASE_CURRENCY
conversion_rate = parseFloat(result.rates[BASE_CURRENCY]);
console.log(`Conversion rate from ${currency} to ${BASE_CURRENCY} on ${date_fmt}: ${conversion_rate}`);
total_amount = original_amount * conversion_rate;
for_self = for_self * conversion_rate;
for_other = for_other * conversion_rate;
}
//const personal_amounts = JSON.parse(formData.get('personal_amounts') as string) as { user: string, amount: number }[];
if (!name || isNaN(total_amount)) {
return new Response(JSON.stringify({ message: 'Invalid required fields.' }), { status: 400 });
}
await mkdir(UPLOAD_DIR, { recursive: true });
const images: { mediapath: string }[] = [];
const imageFiles = formData.getAll('images') as File[];
for (const file of imageFiles) {
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const safeName = `${Date.now()}_${file.name.replace(/[^a-zA-Z0-9_.-]/g, '_')}`;
const fullPath = path.join(UPLOAD_DIR, safeName);
fs.writeFileSync(fullPath, buffer);
images.push({ mediapath: `/static/test/${safeName}` });
}
await dbConnect();
const payment = new Payment({
type,
name,
category,
transaction_date,
images,
description,
note,
tags,
original_amount,
total_amount,
paid_by,
for_self,
for_other,
conversion_rate,
currency,
});
try{
await Payment.create(payment);
} catch(e){
return new Response(JSON.stringify({ message: `Error creating payment event. ${e}` }), { status: 500 });
}
await dbDisconnect();
return new Response(JSON.stringify({ message: 'Payment event created successfully.' }), { status: 201 });
} catch (err) {
console.error(err);
return new Response(JSON.stringify({ message: 'Error processing request.' }), { status: 500 });
}
};

View File

@@ -1,38 +1,76 @@
import type { RequestHandler } from '@sveltejs/kit';
import { json } from '@sveltejs/kit';
import { Payment } from '$lib/models/Payment'; // adjust path as needed
import { dbConnect, dbDisconnect } from '$lib/db/db';
import { PaymentSplit } from '../../../../models/PaymentSplit';
import { Payment } from '../../../../models/Payment'; // Need to import Payment for populate to work
import { dbConnect, dbDisconnect } from '../../../../utils/db';
import { error, json } from '@sveltejs/kit';
const UPLOAD_DIR = '/var/lib/www/static/test';
const BASE_CURRENCY = 'CHF'; // Default currency
export const GET: RequestHandler = async ({ request, locals }) => {
await dbConnect();
const result = await Payment.aggregate([
{
$group: {
_id: "$paid_by",
totalPaid: { $sum: "$total_amount" },
totalForSelf: { $sum: { $ifNull: ["$for_self", 0] } },
totalForOther: { $sum: { $ifNull: ["$for_other", 0] } }
}
},
{
$project: {
_id: 0,
paid_by: "$_id",
netTotal: {
$multiply: [
{ $add: [
{ $subtract: ["$totalPaid", "$totalForSelf"] },
"$totalForOther"
] },
0.5]
}
}
export const GET: RequestHandler = async ({ locals, url }) => {
const auth = await locals.auth();
if (!auth || !auth.user?.nickname) {
throw error(401, 'Not logged in');
}
]);
const username = auth.user.nickname;
const includeAll = url.searchParams.get('all') === 'true';
await dbConnect();
try {
if (includeAll) {
const allSplits = await PaymentSplit.aggregate([
{
$group: {
_id: '$username',
totalOwed: { $sum: { $cond: [{ $gt: ['$amount', 0] }, '$amount', 0] } },
totalOwing: { $sum: { $cond: [{ $lt: ['$amount', 0] }, { $abs: '$amount' }, 0] } },
netBalance: { $sum: '$amount' }
}
},
{
$project: {
username: '$_id',
totalOwed: 1,
totalOwing: 1,
netBalance: 1,
_id: 0
}
}
]);
const currentUserBalance = allSplits.find(balance => balance.username === username) || {
username,
totalOwed: 0,
totalOwing: 0,
netBalance: 0
};
return json({
currentUser: currentUserBalance,
allBalances: allSplits
});
} else {
const userSplits = await PaymentSplit.find({ username }).lean();
// Calculate net balance: negative = you are owed money, positive = you owe money
const netBalance = userSplits.reduce((sum, split) => sum + split.amount, 0);
const recentSplits = await PaymentSplit.find({ username })
.populate('paymentId')
.sort({ createdAt: -1 })
.limit(10)
.lean();
return json({
netBalance,
recentSplits
});
}
} catch (e) {
console.error('Error calculating balance:', e);
throw error(500, 'Failed to calculate balance');
} finally {
await dbDisconnect();
return json(result);
};
}
};

View File

@@ -1,20 +0,0 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { Payment } from '$lib/models/Payment';
import { dbConnect, dbDisconnect } from '$lib/db/db';
import { error } from '@sveltejs/kit';
export const GET: RequestHandler = async ({params}) => {
await dbConnect();
const number_payments = 10;
const number_skip = params.pageno ? (parseInt(params.pageno) - 1 ) * number_payments : 0;
let payments = await Payment.find()
.sort({ transaction_date: -1 })
.skip(number_skip)
.limit(number_payments);
await dbDisconnect();
if(payments == null){
throw error(404, "No more payments found");
}
return json(payments);
};

View File

@@ -0,0 +1,92 @@
import type { RequestHandler } from '@sveltejs/kit';
import { Payment } from '../../../../models/Payment';
import { PaymentSplit } from '../../../../models/PaymentSplit';
import { dbConnect, dbDisconnect } from '../../../../utils/db';
import { error, json } from '@sveltejs/kit';
export const GET: RequestHandler = async ({ locals, url }) => {
const auth = await locals.auth();
if (!auth || !auth.user?.nickname) {
throw error(401, 'Not logged in');
}
const limit = parseInt(url.searchParams.get('limit') || '20');
const offset = parseInt(url.searchParams.get('offset') || '0');
await dbConnect();
try {
const payments = await Payment.find()
.populate('splits')
.sort({ date: -1 })
.limit(limit)
.skip(offset)
.lean();
return json({ payments });
} catch (e) {
throw error(500, 'Failed to fetch payments');
} finally {
await dbDisconnect();
}
};
export const POST: RequestHandler = async ({ request, locals }) => {
const auth = await locals.auth();
if (!auth || !auth.user?.nickname) {
throw error(401, 'Not logged in');
}
const data = await request.json();
const { title, description, amount, paidBy, date, image, splitMethod, splits } = data;
if (!title || !amount || !paidBy || !splitMethod || !splits) {
throw error(400, 'Missing required fields');
}
if (amount <= 0) {
throw error(400, 'Amount must be positive');
}
if (!['equal', 'full', 'proportional'].includes(splitMethod)) {
throw error(400, 'Invalid split method');
}
await dbConnect();
try {
const payment = await Payment.create({
title,
description,
amount,
currency: 'CHF',
paidBy,
date: date ? new Date(date) : new Date(),
image,
splitMethod,
createdBy: auth.user.nickname
});
const splitPromises = splits.map((split: any) => {
return PaymentSplit.create({
paymentId: payment._id,
username: split.username,
amount: split.amount,
proportion: split.proportion
});
});
await Promise.all(splitPromises);
return json({
success: true,
payment: payment._id
});
} catch (e) {
console.error('Error creating payment:', e);
throw error(500, 'Failed to create payment');
} finally {
await dbDisconnect();
}
};

View File

@@ -0,0 +1,124 @@
import type { RequestHandler } from '@sveltejs/kit';
import { Payment } from '../../../../../models/Payment';
import { PaymentSplit } from '../../../../../models/PaymentSplit';
import { dbConnect, dbDisconnect } from '../../../../../utils/db';
import { error, json } from '@sveltejs/kit';
export const GET: RequestHandler = async ({ params, locals }) => {
const auth = await locals.auth();
if (!auth || !auth.user?.nickname) {
throw error(401, 'Not logged in');
}
const { id } = params;
await dbConnect();
try {
const payment = await Payment.findById(id).populate('splits').lean();
if (!payment) {
throw error(404, 'Payment not found');
}
return json({ payment });
} catch (e) {
if (e.status === 404) throw e;
throw error(500, 'Failed to fetch payment');
} finally {
await dbDisconnect();
}
};
export const PUT: RequestHandler = async ({ params, request, locals }) => {
const auth = await locals.auth();
if (!auth || !auth.user?.nickname) {
throw error(401, 'Not logged in');
}
const { id } = params;
const data = await request.json();
await dbConnect();
try {
const payment = await Payment.findById(id);
if (!payment) {
throw error(404, 'Payment not found');
}
if (payment.createdBy !== auth.user.nickname) {
throw error(403, 'Not authorized to edit this payment');
}
const updatedPayment = await Payment.findByIdAndUpdate(
id,
{
title: data.title,
description: data.description,
amount: data.amount,
paidBy: data.paidBy,
date: data.date ? new Date(data.date) : payment.date,
image: data.image,
splitMethod: data.splitMethod
},
{ new: true }
);
if (data.splits) {
await PaymentSplit.deleteMany({ paymentId: id });
const splitPromises = data.splits.map((split: any) => {
return PaymentSplit.create({
paymentId: id,
username: split.username,
amount: split.amount,
proportion: split.proportion
});
});
await Promise.all(splitPromises);
}
return json({ success: true, payment: updatedPayment });
} catch (e) {
if (e.status) throw e;
throw error(500, 'Failed to update payment');
} finally {
await dbDisconnect();
}
};
export const DELETE: RequestHandler = async ({ params, locals }) => {
const auth = await locals.auth();
if (!auth || !auth.user?.nickname) {
throw error(401, 'Not logged in');
}
const { id } = params;
await dbConnect();
try {
const payment = await Payment.findById(id);
if (!payment) {
throw error(404, 'Payment not found');
}
if (payment.createdBy !== auth.user.nickname) {
throw error(403, 'Not authorized to delete this payment');
}
await PaymentSplit.deleteMany({ paymentId: id });
await Payment.findByIdAndDelete(id);
return json({ success: true });
} catch (e) {
if (e.status) throw e;
throw error(500, 'Failed to delete payment');
} finally {
await dbDisconnect();
}
};

View File

@@ -0,0 +1,63 @@
import type { RequestHandler } from '@sveltejs/kit';
import { error, json } from '@sveltejs/kit';
import { writeFileSync, mkdirSync } from 'fs';
import { join } from 'path';
import { randomUUID } from 'crypto';
import { IMAGE_DIR } from '$env/static/private';
export const POST: RequestHandler = async ({ request, locals }) => {
const auth = await locals.auth();
if (!auth || !auth.user?.nickname) {
throw error(401, 'Not logged in');
}
try {
const formData = await request.formData();
const image = formData.get('image') as File;
if (!image) {
throw error(400, 'No image provided');
}
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
if (!allowedTypes.includes(image.type)) {
throw error(400, 'Invalid file type. Only JPEG, PNG, and WebP are allowed.');
}
if (image.size > 5 * 1024 * 1024) {
throw error(400, 'File too large. Maximum size is 5MB.');
}
const extension = image.type.split('/')[1];
const filename = `${randomUUID()}.${extension}`;
if (!IMAGE_DIR) {
throw error(500, 'IMAGE_DIR environment variable not configured');
}
// Ensure cospend directory exists in IMAGE_DIR
const uploadsDir = join(IMAGE_DIR, 'cospend');
try {
mkdirSync(uploadsDir, { recursive: true });
} catch (err) {
// Directory might already exist
}
const filepath = join(uploadsDir, filename);
const buffer = await image.arrayBuffer();
writeFileSync(filepath, new Uint8Array(buffer));
const publicPath = `/cospend/${filename}`;
return json({
success: true,
path: publicPath
});
} catch (err) {
if (err.status) throw err;
console.error('Upload error:', err);
throw error(500, 'Failed to upload file');
}
};