Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
222 changes: 222 additions & 0 deletions src/controllers/split.controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import { splitService } from '../services/split.service.js';
import { ApiResponse } from '../utils/apiResponse.js';

export const createGroup = async (req, res, next) => {
try {
const userId = req.user?.id || 'usr_me';
const { title, icon, members } = req.body;

if (!title) {
return ApiResponse.error(res, 'Group title is required', 400);
}

if (!members || members.length < 2) {
return ApiResponse.error(res, 'Please add at least 2 members to create a split group', 400);
}

const group = await splitService.createGroup({
title,
icon,
members,
createdBy: userId,
});

return ApiResponse.success(res, 'Split group created successfully', group, 201);
} catch (error) {
return ApiResponse.error(res, error.message, 400);
}
};

export const getGroups = async (req, res, next) => {
try {
const userId = req.user?.id || 'usr_me';
const groups = await splitService.getGroupsByUser(userId);
return ApiResponse.success(res, 'Groups fetched successfully', groups);
} catch (error) {
next(error);
}
};

export const getGroupById = async (req, res, next) => {
try {
const { id } = req.params;
const group = await splitService.getGroupById(id);
return ApiResponse.success(res, 'Group details fetched successfully', group);
} catch (error) {
next(error);
}
};

export const addMember = async (req, res, next) => {
try {
const { id } = req.params;
const { memberId, name, phone, avatarUrl } = req.body;

if (!name) {
return ApiResponse.error(res, 'Member name is required', 400);
}

const group = await splitService.addMemberToGroup(id, {
memberId: memberId || `mem_${Date.now()}`,
name,
phone: phone || '',
avatarUrl: avatarUrl || '',
});

return ApiResponse.success(res, 'Member added successfully to group', group);
} catch (error) {
next(error);
}
};

export const addExpense = async (req, res, next) => {
try {
const { id } = req.params;
const {
title,
totalAmount,
paidByMemberId,
splitType,
allocations,
category,
notes,
itemizedEntries,
percentages,
members,
taxAndTip,
} = req.body;

if (!title || !totalAmount || Number(totalAmount) <= 0) {
return ApiResponse.error(res, 'Valid title and totalAmount are required', 400);
}

let calculatedAllocations = allocations;

// If caller wants backend to calculate split allocations:
if (!calculatedAllocations || calculatedAllocations.length === 0) {
const group = await splitService.getGroupById(id);
const targetMembers = members || group.members;

if (splitType === 'equal') {
calculatedAllocations = splitService.calculateEqualSplit({
totalAmount: Number(totalAmount),
members: targetMembers,
});
} else if (splitType === 'percentage') {
calculatedAllocations = splitService.calculatePercentageSplit({
totalAmount: Number(totalAmount),
members: targetMembers,
percentages: percentages || {},
});
} else if (splitType === 'itemized') {
calculatedAllocations = splitService.calculateItemizedSplit({
totalAmount: Number(totalAmount),
items: itemizedEntries || [],
members: targetMembers,
taxAndTip: taxAndTip || 0,
});
} else {
return ApiResponse.error(
res,
'Invalid splitType. Must be equal, percentage, or itemized',
400
);
}
}

const expense = await splitService.addExpense({
groupId: id,
title,
totalAmount: Number(totalAmount),
paidByMemberId,
splitType,
allocations: calculatedAllocations,
category,
notes,
itemizedEntries,
});

return ApiResponse.success(
res,
'Group expense added and reconciled to 100%',
expense,
201
);
} catch (error) {
next(error);
}
};

export const getGroupBalances = async (req, res, next) => {
try {
const { id } = req.params;
const balances = await splitService.getGroupBalancesAndSimplifiedDebts(id);
return ApiResponse.success(res, 'Group balances and simplified debts calculated', balances);
} catch (error) {
next(error);
}
};

export const markSettlement = async (req, res, next) => {
try {
const userId = req.user?.id || 'usr_me';
const {
groupId,
debtKey,
fromMemberId,
fromMemberName,
toMemberId,
toMemberName,
amount,
settlementNote,
} = req.body;

if (!groupId || !debtKey || !amount) {
return ApiResponse.error(res, 'groupId, debtKey, and amount are required', 400);
}

const settlement = await splitService.markSettlement({
groupId,
debtKey,
fromMemberId,
fromMemberName,
toMemberId,
toMemberName,
amount: Number(amount),
settlementNote,
userId,
});

return ApiResponse.success(
res,
'Settlement recorded successfully (Non-monetary ledger update)',
settlement
);
} catch (error) {
next(error);
}
};

export const getReminderMessage = async (req, res, next) => {
try {
const { groupTitle, debtorName, amount } = req.query;

if (!groupTitle || !debtorName || !amount) {
return ApiResponse.error(
res,
'groupTitle, debtorName, and amount query params are required',
400
);
}

const message = splitService.generateReminderMessage({
groupTitle,
debtorName,
amount: Number(amount),
});

return ApiResponse.success(res, 'Reminder copy generated', { message });
} catch (error) {
next(error);
}
};
46 changes: 46 additions & 0 deletions src/models/groupExpense.model.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import mongoose from 'mongoose';

const allocationSubSchema = new mongoose.Schema(
{
memberId: { type: String, required: true },
memberName: { type: String, required: true },
amount: { type: Number, required: true },
percentage: { type: Number, default: 0 },
items: { type: [String], default: [] },
},
{ _id: false }
);

const itemizedEntrySubSchema = new mongoose.Schema(
{
name: { type: String, required: true },
price: { type: Number, required: true },
assignedMemberIds: { type: [String], default: [] },
},
{ _id: false }
);

const groupExpenseSchema = new mongoose.Schema(
{
groupId: { type: String, required: true, index: true },
title: { type: String, required: true, trim: true },
totalAmount: { type: Number, required: true, min: 0.01 },
paidByMemberId: { type: String, required: true },
paidByMemberName: { type: String, required: true },
splitType: {
type: String,
enum: ['equal', 'percentage', 'itemized'],
required: true,
},
allocations: {
type: [allocationSubSchema],
required: true,
},
category: { type: String, default: 'Food & Dining' },
notes: { type: String, default: '' },
itemizedEntries: { type: [itemizedEntrySubSchema], default: [] },
},
{ timestamps: true }
);

export const GroupExpense = mongoose.model('GroupExpense', groupExpenseSchema);
23 changes: 23 additions & 0 deletions src/models/settlement.model.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import mongoose from 'mongoose';

const settlementSchema = new mongoose.Schema(
{
groupId: { type: String, required: true, index: true },
debtKey: { type: String, required: true, unique: true, index: true },
fromMemberId: { type: String, required: true },
fromMemberName: { type: String, required: true },
toMemberId: { type: String, required: true },
toMemberName: { type: String, required: true },
amount: { type: Number, required: true },
isSettled: { type: Boolean, default: true },
settlementNote: {
type: String,
default: 'Settled via external UPI/Cash (Non-monetary FinTrack ledger)',
},
settledByUserId: { type: String, required: true },
settledAt: { type: Date, default: Date.now },
},
{ timestamps: true }
);

export const Settlement = mongoose.model('Settlement', settlementSchema);
32 changes: 32 additions & 0 deletions src/models/splitGroup.model.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import mongoose from 'mongoose';

const memberSubSchema = new mongoose.Schema(
{
memberId: { type: String, required: true },
name: { type: String, required: true },
phone: { type: String, default: '' },
avatarUrl: { type: String, default: '' },
isCurrentUser: { type: Boolean, default: false },
},
{ _id: false }
);

const splitGroupSchema = new mongoose.Schema(
{
title: { type: String, required: true, trim: true },
icon: { type: String, default: '👥' },
createdBy: { type: String, required: true },
members: {
type: [memberSubSchema],
validate: {
validator: function (val) {
return Array.isArray(val) && val.length >= 2;
},
message: 'A group must have at least 2 members to split expenses',
},
},
},
{ timestamps: true }
);

export const SplitGroup = mongoose.model('SplitGroup', splitGroupSchema);
2 changes: 2 additions & 0 deletions src/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import ocrRoutes from './ocr.routes.js';
import claimRoutes from './claim.routes.js';
import statementRoutes from './statement.routes.js';
import budgetRoutes from './budget.routes.js';
import splitRoutes from './split.routes.js';

const router = Router();

Expand All @@ -26,5 +27,6 @@ router.use('/ocr', ocrRoutes);
router.use('/claims', claimRoutes);
router.use('/statement', statementRoutes);
router.use('/budgets', budgetRoutes);
router.use('/split', splitRoutes);

export default router;
37 changes: 37 additions & 0 deletions src/routes/split.routes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Router } from 'express';
import {
createGroup,
getGroups,
getGroupById,
addMember,
addExpense,
getGroupBalances,
markSettlement,
getReminderMessage,
} from '../controllers/split.controller.js';
import { authenticate } from '../middlewares/auth.middleware.js';

const router = Router();

// Allow optional/bypass authentication or standard auth for group operations
router.use(authenticate);

// Group management (BR-16)
router.post('/groups', createGroup);
router.get('/groups', getGroups);
router.get('/groups/:id', getGroupById);
router.post('/groups/:id/members', addMember);

// Expense splitting across 3 split methods (BR-17)
router.post('/groups/:id/expenses', addExpense);

// Debt simplification and net balances (BR-18)
router.get('/groups/:id/balances', getGroupBalances);

// Non-monetary settlement tracking (BR-19)
router.post('/settlements', markSettlement);

// Gentle reminders (BR-19)
router.get('/reminder', getReminderMessage);

export default router;
Loading
Loading