diff --git a/src/controllers/split.controller.js b/src/controllers/split.controller.js new file mode 100644 index 0000000..d1c6802 --- /dev/null +++ b/src/controllers/split.controller.js @@ -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); + } +}; diff --git a/src/models/groupExpense.model.js b/src/models/groupExpense.model.js new file mode 100644 index 0000000..405a4e1 --- /dev/null +++ b/src/models/groupExpense.model.js @@ -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); diff --git a/src/models/settlement.model.js b/src/models/settlement.model.js new file mode 100644 index 0000000..6a8e3b1 --- /dev/null +++ b/src/models/settlement.model.js @@ -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); diff --git a/src/models/splitGroup.model.js b/src/models/splitGroup.model.js new file mode 100644 index 0000000..c35c485 --- /dev/null +++ b/src/models/splitGroup.model.js @@ -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); diff --git a/src/routes/index.js b/src/routes/index.js index f517a5c..c874b7e 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -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(); @@ -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; diff --git a/src/routes/split.routes.js b/src/routes/split.routes.js new file mode 100644 index 0000000..efa0e2b --- /dev/null +++ b/src/routes/split.routes.js @@ -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; diff --git a/src/services/split.service.js b/src/services/split.service.js new file mode 100644 index 0000000..e3ab2a1 --- /dev/null +++ b/src/services/split.service.js @@ -0,0 +1,407 @@ +import { SplitGroup } from '../models/splitGroup.model.js'; +import { GroupExpense } from '../models/groupExpense.model.js'; +import { Settlement } from '../models/settlement.model.js'; + +export class SplitService { + /** + * BR-16: Group Creation and Member-adding + */ + async createGroup({ title, icon = '👥', members = [], createdBy }) { + if (!title || !title.trim()) { + throw new Error('Group title is required'); + } + + // Ensure createdBy is in members list + const hasCreator = members.some((m) => m.memberId === createdBy || m.isCurrentUser); + const finalMembers = hasCreator + ? members + : [ + { + memberId: createdBy, + name: 'You', + isCurrentUser: true, + }, + ...members, + ]; + + if (finalMembers.length < 2) { + throw new Error('Please add at least 2 members to create a split group'); + } + + const group = await SplitGroup.create({ + title: title.trim(), + icon: icon.trim() || '👥', + createdBy, + members: finalMembers, + }); + + return group; + } + + async getGroupsByUser(userId) { + return SplitGroup.find({ + $or: [{ createdBy: userId }, { 'members.memberId': userId }], + }).sort({ updatedAt: -1 }); + } + + async getGroupById(groupId) { + const group = await SplitGroup.findById(groupId); + if (!group) { + throw new Error('Group not found'); + } + return group; + } + + async addMemberToGroup(groupId, member) { + const group = await this.getGroupById(groupId); + + const exists = group.members.some( + (m) => + m.memberId === member.memberId || + m.name.toLowerCase() === member.name.toLowerCase() + ); + + if (exists) { + throw new Error(`Member '${member.name}' is already in this group`); + } + + group.members.push(member); + await group.save(); + return group; + } + + // =========================================================================== + // BR-17: All 3 Split Methods (Equal, Percentage, Itemized) Reconcile to 100% + // =========================================================================== + + /** + * 1. Equal Split Calculation (reconciles 100% with penny/paise remainder balancing) + */ + calculateEqualSplit({ totalAmount, members }) { + if (!members || members.length === 0 || totalAmount <= 0) { + throw new Error('Invalid members or total amount for equal split'); + } + + const count = members.length; + const totalPaise = Math.round(totalAmount * 100); + const basePaise = Math.floor(totalPaise / count); + const remainderPaise = totalPaise % count; + + const allocations = members.map((m, idx) => { + const memberPaise = basePaise + (idx < remainderPaise ? 1 : 0); + const memberAmount = memberPaise / 100.0; + const percentage = Number(((memberAmount / totalAmount) * 100).toFixed(2)); + + return { + memberId: m.memberId || m.id, + memberName: m.name, + amount: memberAmount, + percentage, + }; + }); + + // Verify 100% reconciliation + const sum = allocations.reduce((acc, a) => acc + a.amount, 0); + if (Math.abs(sum - totalAmount) > 0.001) { + throw new Error('Equal split calculation failed to reconcile to 100%'); + } + + return allocations; + } + + /** + * 2. Percentage Split Calculation (reconciles 100% of original expense) + */ + calculatePercentageSplit({ totalAmount, members, percentages }) { + if (!members || members.length === 0 || totalAmount <= 0) { + throw new Error('Invalid members or total amount for percentage split'); + } + + const totalPercent = Object.values(percentages).reduce( + (acc, p) => acc + Number(p), + 0 + ); + + if (Math.abs(totalPercent - 100.0) > 0.1) { + throw new Error( + `Total percentages must equal 100%. Currently received ${totalPercent.toFixed(1)}%` + ); + } + + const totalPaise = Math.round(totalAmount * 100); + let allocatedPaise = 0; + + const allocations = members.map((m, idx) => { + const mid = m.memberId || m.id; + const pct = Number(percentages[mid] || 0); + + let memberPaise; + if (idx === members.length - 1) { + memberPaise = totalPaise - allocatedPaise; + } else { + memberPaise = Math.round(totalAmount * (pct / 100.0) * 100); + allocatedPaise += memberPaise; + } + + const amount = memberPaise / 100.0; + return { + memberId: mid, + memberName: m.name, + amount, + percentage: pct, + }; + }); + + return allocations; + } + + /** + * 3. Itemized Split Calculation (apportion per item + shared tax/tip to 100%) + */ + calculateItemizedSplit({ totalAmount, items, members, taxAndTip = 0 }) { + if (!members || members.length === 0) { + throw new Error('Members required for itemized split'); + } + if (!items || items.length === 0) { + throw new Error('At least 1 item is required for itemized split'); + } + + const memberSubtotals = {}; + const memberItemNames = {}; + members.forEach((m) => { + const mid = m.memberId || m.id; + memberSubtotals[mid] = 0; + memberItemNames[mid] = []; + }); + + let rawItemsTotal = 0; + for (const item of items) { + rawItemsTotal += Number(item.price); + const assigned = + item.assignedMemberIds && item.assignedMemberIds.length > 0 + ? item.assignedMemberIds + : members.map((m) => m.memberId || m.id); + + const share = Number(item.price) / assigned.length; + assigned.forEach((id) => { + if (memberSubtotals[id] !== undefined) { + memberSubtotals[id] += share; + memberItemNames[id].push(item.name || item.itemName); + } + }); + } + + const grandTotal = totalAmount || (rawItemsTotal + Number(taxAndTip)); + const grandTotalPaise = Math.round(grandTotal * 100); + let allocatedPaise = 0; + + const allocations = members.map((m, idx) => { + const mid = m.memberId || m.id; + const subtotal = memberSubtotals[mid] || 0; + const proportion = + rawItemsTotal > 0 ? subtotal / rawItemsTotal : 1.0 / members.length; + const memberTaxTip = Number(taxAndTip) * proportion; + const fullShare = subtotal + memberTaxTip; + + let memberPaise; + if (idx === members.length - 1) { + memberPaise = grandTotalPaise - allocatedPaise; + } else { + memberPaise = Math.round(fullShare * 100); + allocatedPaise += memberPaise; + } + + const finalAmount = memberPaise / 100.0; + const pct = grandTotal > 0 ? (finalAmount / grandTotal) * 100 : 0; + + return { + memberId: mid, + memberName: m.name, + amount: finalAmount, + percentage: Number(pct.toFixed(2)), + items: memberItemNames[mid], + }; + }); + + return allocations; + } + + async addExpense({ + groupId, + title, + totalAmount, + paidByMemberId, + splitType, + allocations, + category = 'Food & Dining', + notes = '', + itemizedEntries = [], + }) { + const group = await this.getGroupById(groupId); + const paidMember = group.members.find((m) => m.memberId === paidByMemberId) || group.members[0]; + + // Validate allocations sum up to totalAmount within 1 cent/paise + const sum = allocations.reduce((acc, a) => acc + Number(a.amount), 0); + if (Math.abs(sum - totalAmount) > 0.05) { + throw new Error( + `Split allocations total (${sum}) must reconcile to 100% of expense amount (${totalAmount})` + ); + } + + const expense = await GroupExpense.create({ + groupId, + title: title.trim(), + totalAmount: Number(totalAmount), + paidByMemberId: paidMember.memberId, + paidByMemberName: paidMember.isCurrentUser ? `${paidMember.name} (You)` : paidMember.name, + splitType, + allocations, + category, + notes, + itemizedEntries, + }); + + return expense; + } + + // =========================================================================== + // BR-18: "Who owes whom" Net Balances & Debt Simplification Algorithm + // =========================================================================== + + async getGroupBalancesAndSimplifiedDebts(groupId) { + const group = await this.getGroupById(groupId); + const expenses = await GroupExpense.find({ groupId }); + const settlements = await Settlement.find({ groupId, isSettled: true }); + const settledKeys = new Set(settlements.map((s) => s.debtKey)); + + // 1. Calculate net balances + const netBalances = {}; + group.members.forEach((m) => { + netBalances[m.memberId] = 0; + }); + + for (const exp of expenses) { + netBalances[exp.paidByMemberId] = + (netBalances[exp.paidByMemberId] || 0) + exp.totalAmount; + + for (const a of exp.allocations) { + netBalances[a.memberId] = + (netBalances[a.memberId] || 0) - a.amount; + } + } + + // 2. Bipartite greedy debt simplification algorithm + const debtors = []; + const creditors = []; + const memberMap = {}; + group.members.forEach((m) => { + memberMap[m.memberId] = m; + }); + + Object.entries(netBalances).forEach(([memberId, balance]) => { + const rounded = Number(balance.toFixed(2)); + if (rounded < -0.05) { + debtors.push({ memberId, amount: Math.abs(rounded) }); + } else if (rounded > 0.05) { + creditors.push({ memberId, amount: rounded }); + } + }); + + debtors.sort((a, b) => b.amount - a.amount); + creditors.sort((a, b) => b.amount - a.amount); + + const simplifiedDebts = []; + let d = 0; + let c = 0; + + while (d < debtors.length && c < creditors.length) { + const debtor = debtors[d]; + const creditor = creditors[c]; + + const settleAmount = Math.min(debtor.amount, creditor.amount); + const roundedSettle = Number(settleAmount.toFixed(2)); + + if (roundedSettle > 0.05) { + const debtKey = `${groupId}_${debtor.memberId}_${creditor.memberId}`; + const isSettled = settledKeys.has(debtKey); + + simplifiedDebts.push({ + id: debtKey, + debtKey, + groupId, + fromMemberId: debtor.memberId, + fromMemberName: memberMap[debtor.memberId]?.name || 'Unknown', + toMemberId: creditor.memberId, + toMemberName: memberMap[creditor.memberId]?.name || 'Unknown', + amount: roundedSettle, + isSettled, + settlementNote: isSettled ? 'Settled via external UPI/Cash' : null, + }); + } + + debtor.amount -= settleAmount; + creditor.amount -= settleAmount; + + if (debtor.amount < 0.05) { + d++; + } + if (creditor.amount < 0.05) { + c++; + } + } + + return { + groupId, + groupTitle: group.title, + totalExpenses: expenses.length, + totalGroupSpend: expenses.reduce((s, e) => s + e.totalAmount, 0), + netBalances, + simplifiedDebts, + activeTransfersNeeded: simplifiedDebts.filter((s) => !s.isSettled).length, + }; + } + + // =========================================================================== + // BR-19: Non-Monetary Settlement Tracking & Gentle Reminders + // =========================================================================== + + async markSettlement({ + groupId, + debtKey, + fromMemberId, + fromMemberName, + toMemberId, + toMemberName, + amount, + settlementNote = 'Settled via external UPI/Cash (Non-monetary FinTrack ledger)', + userId, + }) { + // Record external settlement confirmation (no in-app funds transferred) + const settlement = await Settlement.findOneAndUpdate( + { debtKey }, + { + groupId, + debtKey, + fromMemberId, + fromMemberName, + toMemberId, + toMemberName, + amount: Number(amount), + isSettled: true, + settlementNote, + settledByUserId: userId, + settledAt: new Date(), + }, + { upsert: true, returnDocument: 'after' } + ); + + return settlement; + } + + generateReminderMessage({ groupTitle, debtorName, amount }) { + const formattedAmount = `₹${Number(amount).toFixed(amount % 1 === 0 ? 0 : 2)}`; + return `Hey ${debtorName}! 👋 FinTrack gentle nudge for your "${groupTitle}" share of ${formattedAmount}. Whenever you get a moment, settle up via UPI/Cash and let me know!`; + } +} + +export const splitService = new SplitService(); diff --git a/tests/split.test.js b/tests/split.test.js new file mode 100644 index 0000000..f3a07c2 --- /dev/null +++ b/tests/split.test.js @@ -0,0 +1,264 @@ +import request from 'supertest'; +import app from '../src/app.js'; +import { SplitGroup } from '../src/models/splitGroup.model.js'; +import { GroupExpense } from '../src/models/groupExpense.model.js'; +import { Settlement } from '../src/models/settlement.model.js'; + +describe('Split Expenses API (BR-16 to BR-19)', () => { + const authHeader = 'Bearer mock_token_123'; + let createdGroupId; + + beforeEach(async () => { + await SplitGroup.deleteMany({}); + await GroupExpense.deleteMany({}); + await Settlement.deleteMany({}); + }); + + describe('BR-16: Group Creation & Member Management', () => { + it('should create a split group with custom icon and 3+ members', async () => { + const res = await request(app) + .post('/api/v1/split/groups') + .set('Authorization', authHeader) + .send({ + title: 'Goa Trip 2026', + icon: '🏖️', + members: [ + { memberId: 'user_123', name: 'Ritesh', isCurrentUser: true }, + { memberId: 'm_ameya', name: 'Ameya', phone: '+919822012345' }, + { memberId: 'm_aarav', name: 'Aarav', phone: '+919833023456' }, + ], + }); + + expect(res.statusCode).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.data.title).toBe('Goa Trip 2026'); + expect(res.body.data.icon).toBe('🏖️'); + expect(res.body.data.members.length).toBe(3); + createdGroupId = res.body.data._id; + }); + + it('should reject creating a group with less than 2 members', async () => { + const res = await request(app) + .post('/api/v1/split/groups') + .set('Authorization', authHeader) + .send({ + title: 'Solo Trip', + members: [{ memberId: 'user_123', name: 'Ritesh' }], + }); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + }); + + it('should add a new member to an existing group', async () => { + const group = await SplitGroup.create({ + title: 'Office Lunch', + icon: '🍱', + createdBy: 'user_123', + members: [ + { memberId: 'user_123', name: 'Ritesh', isCurrentUser: true }, + { memberId: 'm_sneha', name: 'Sneha' }, + ], + }); + + const res = await request(app) + .post(`/api/v1/split/groups/${group._id}/members`) + .set('Authorization', authHeader) + .send({ + name: 'Rohan', + phone: '+919855045678', + }); + + expect(res.statusCode).toBe(200); + expect(res.body.data.members.length).toBe(3); + expect(res.body.data.members.some((m) => m.name === 'Rohan')).toBe(true); + }); + }); + + describe('BR-17: All 3 Split Methods (Equal, Percentage, Itemized)', () => { + let testGroup; + + beforeEach(async () => { + testGroup = await SplitGroup.create({ + title: 'Flatmates HSR', + icon: '🏡', + createdBy: 'user_123', + members: [ + { memberId: 'user_123', name: 'Ritesh', isCurrentUser: true }, + { memberId: 'm_ameya', name: 'Ameya' }, + { memberId: 'm_atharva', name: 'Atharva' }, + ], + }); + }); + + it('1. Equal Split: accurately calculates and reconciles to 100% of expense', async () => { + const res = await request(app) + .post(`/api/v1/split/groups/${testGroup._id}/expenses`) + .set('Authorization', authHeader) + .send({ + title: 'Grocery Run', + totalAmount: 100, + paidByMemberId: 'user_123', + splitType: 'equal', + }); + + expect(res.statusCode).toBe(201); + expect(res.body.success).toBe(true); + const allocs = res.body.data.allocations; + expect(allocs.length).toBe(3); + const sum = allocs.reduce((acc, a) => acc + a.amount, 0); + // Remainder cent balanced: 33.34 + 33.33 + 33.33 = 100.00 + expect(sum).toBeCloseTo(100.0, 2); + }); + + it('2. Percentage Split: validates 100% sum and calculates exact shares', async () => { + const res = await request(app) + .post(`/api/v1/split/groups/${testGroup._id}/expenses`) + .set('Authorization', authHeader) + .send({ + title: 'Electricity Bill', + totalAmount: 1000, + paidByMemberId: 'user_123', + splitType: 'percentage', + percentages: { + user_123: 50, + m_ameya: 25, + m_atharva: 25, + }, + }); + + expect(res.statusCode).toBe(201); + expect(res.body.success).toBe(true); + const allocs = res.body.data.allocations; + const ritShare = allocs.find((a) => a.memberId === 'user_123'); + const ameShare = allocs.find((a) => a.memberId === 'm_ameya'); + expect(ritShare.amount).toBe(500); + expect(ameShare.amount).toBe(250); + }); + + it('3. Itemized Split: correctly apportions item prices and shared tax/tip to 100%', async () => { + const res = await request(app) + .post(`/api/v1/split/groups/${testGroup._id}/expenses`) + .set('Authorization', authHeader) + .send({ + title: 'Dinner at Fisherman Wharf', + totalAmount: 1100, + paidByMemberId: 'user_123', + splitType: 'itemized', + taxAndTip: 100, + itemizedEntries: [ + { name: 'Pizza', price: 600, assignedMemberIds: ['user_123', 'm_ameya'] }, + { name: 'Pasta', price: 200, assignedMemberIds: ['m_atharva'] }, + { name: 'Drinks', price: 200, assignedMemberIds: ['user_123', 'm_ameya', 'm_atharva'] }, + ], + }); + + expect(res.statusCode).toBe(201); + expect(res.body.success).toBe(true); + const allocs = res.body.data.allocations; + const sum = allocs.reduce((acc, a) => acc + a.amount, 0); + expect(sum).toBeCloseTo(1100.0, 2); + }); + }); + + describe('BR-18: Debt Simplification & Balances', () => { + it('simplifies multi-way debts to minimum direct transactions', async () => { + const group = await SplitGroup.create({ + title: 'Goa Trip 2026', + icon: '🏖️', + createdBy: 'user_123', + members: [ + { memberId: 'user_123', name: 'Ritesh', isCurrentUser: true }, + { memberId: 'm_ameya', name: 'Ameya' }, + { memberId: 'm_atharva', name: 'Atharva' }, + ], + }); + + // Expense 1: Ameya paid 300, split 100 each + await request(app) + .post(`/api/v1/split/groups/${group._id}/expenses`) + .set('Authorization', authHeader) + .send({ + title: 'Lunch', + totalAmount: 300, + paidByMemberId: 'm_ameya', + splitType: 'equal', + }); + + // Expense 2: Atharva paid 300, split 100 each + await request(app) + .post(`/api/v1/split/groups/${group._id}/expenses`) + .set('Authorization', authHeader) + .send({ + title: 'Scooter', + totalAmount: 300, + paidByMemberId: 'm_atharva', + splitType: 'equal', + }); + + const res = await request(app) + .get(`/api/v1/split/groups/${group._id}/balances`) + .set('Authorization', authHeader); + + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + const data = res.body.data; + // Ritesh owes Ameya 100, Ritesh owes Atharva 100 + expect(data.simplifiedDebts.length).toBe(2); + expect(data.simplifiedDebts.every((d) => d.fromMemberId === 'user_123')).toBe(true); + }); + }); + + describe('BR-19: Non-Monetary Settlement & Reminders', () => { + it('marks a debt as settled externally without monetary transfer', async () => { + const group = await SplitGroup.create({ + title: 'Settlement Test', + icon: '🤝', + createdBy: 'user_123', + members: [ + { memberId: 'user_123', name: 'Ritesh', isCurrentUser: true }, + { memberId: 'm_ameya', name: 'Ameya' }, + ], + }); + + const debtKey = `${group._id}_user_123_m_ameya`; + + const res = await request(app) + .post('/api/v1/split/settlements') + .set('Authorization', authHeader) + .send({ + groupId: group._id.toString(), + debtKey, + fromMemberId: 'user_123', + fromMemberName: 'Ritesh', + toMemberId: 'm_ameya', + toMemberName: 'Ameya', + amount: 500, + settlementNote: 'Settled via external Google Pay (non-monetary tracking)', + }); + + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.isSettled).toBe(true); + expect(res.body.data.debtKey).toBe(debtKey); + }); + + it('generates friendly pre-composed reminder copy', async () => { + const res = await request(app) + .get('/api/v1/split/reminder') + .set('Authorization', authHeader) + .query({ + groupTitle: 'Goa Trip 2026', + debtorName: 'Ameya', + amount: 750, + }); + + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.message).toContain('Ameya'); + expect(res.body.data.message).toContain('750'); + expect(res.body.data.message).toContain('Goa Trip 2026'); + expect(res.body.data.message).toContain('FinTrack'); + }); + }); +});